diff --git a/dita-demo/Introduction/background.html b/dita-demo/Introduction/background.html index 82167e10..69d71cc4 100644 --- a/dita-demo/Introduction/background.html +++ b/dita-demo/Introduction/background.html @@ -7,7 +7,6 @@ - @@ -43,7 +42,7 @@ - + - + diff --git a/dita-demo/Introduction/feedback.html b/dita-demo/Introduction/feedback.html index 5633b025..574ade1d 100644 --- a/dita-demo/Introduction/feedback.html +++ b/dita-demo/Introduction/feedback.html @@ -7,7 +7,6 @@ - @@ -43,7 +42,7 @@ - + - + diff --git a/dita-demo/Introduction/security.html b/dita-demo/Introduction/security.html index e020c41c..2ca4919e 100644 --- a/dita-demo/Introduction/security.html +++ b/dita-demo/Introduction/security.html @@ -7,7 +7,6 @@ - @@ -43,7 +42,7 @@ - + - + diff --git a/dita-demo/Introduction/seven-questions.html b/dita-demo/Introduction/seven-questions.html index 2b62ec71..c41e74ef 100644 --- a/dita-demo/Introduction/seven-questions.html +++ b/dita-demo/Introduction/seven-questions.html @@ -7,7 +7,6 @@ - @@ -43,7 +42,7 @@ - + - + diff --git a/dita-demo/Pages/SubjectDetail.html b/dita-demo/Pages/SubjectDetail.html index 0e45a0ea..70ec7dfe 100644 --- a/dita-demo/Pages/SubjectDetail.html +++ b/dita-demo/Pages/SubjectDetail.html @@ -6,7 +6,6 @@ - @@ -42,7 +41,7 @@ - + - + diff --git a/dita-demo/Pages/analysis-contact.html b/dita-demo/Pages/analysis-contact.html index a6dc4ad3..bd221dd0 100644 --- a/dita-demo/Pages/analysis-contact.html +++ b/dita-demo/Pages/analysis-contact.html @@ -6,7 +6,6 @@ - @@ -42,7 +41,7 @@ - + - + @@ -143,11 +142,11 @@

Contact Classific Detail How many contacts did you classify as merchant vessels? - 2 - 0.5 + qd1:M + qd1:KPR Which contact has the highest confidence level? - 1 + qd1:X
  1. C-001
  2. @@ -158,8 +157,8 @@

    Contact Classific What is the bearing of the contact classified as submarine? - 270 - 5 + qd1:MLK + qd1:R diff --git a/dita-demo/Pages/analysis-tactical.html b/dita-demo/Pages/analysis-tactical.html index 5ec70559..8255cfda 100644 --- a/dita-demo/Pages/analysis-tactical.html +++ b/dita-demo/Pages/analysis-tactical.html @@ -6,7 +6,6 @@ - @@ -42,7 +41,7 @@ - + - + @@ -131,7 +130,7 @@

    Tactical Decision Detail What threat level did you assign? - 2 + qd1:M
    1. Low
    2. @@ -142,8 +141,8 @@

      Tactical Decision What is your recommended course change in degrees? - 45 - 10 + qd1:BR + qd1:XK diff --git a/dita-demo/Pages/gram-1.html b/dita-demo/Pages/gram-1.html index 5a0413d3..52b20650 100644 --- a/dita-demo/Pages/gram-1.html +++ b/dita-demo/Pages/gram-1.html @@ -6,7 +6,6 @@ - @@ -42,7 +41,7 @@ - + - + @@ -154,12 +153,12 @@

      Gram Analysis 1Options What is the maximum temperature reached - 22 - 4 + qd1:MM + qd1:B How many seconds was the peak level observed for? - 6 - 1 + qd1:T + qd1:X diff --git a/dita-demo/Pages/gram-2.html b/dita-demo/Pages/gram-2.html index bec94e46..0a85db52 100644 --- a/dita-demo/Pages/gram-2.html +++ b/dita-demo/Pages/gram-2.html @@ -6,7 +6,6 @@ - @@ -42,7 +41,7 @@ - + - + @@ -154,12 +153,12 @@

      Gram Analysis 2Options What is the maximum temperature reached - 22 - 4 + qd1:MM + qd1:B How many seconds was the peak level observed for? - 6 - 1 + qd1:T + qd1:X diff --git a/dita-demo/Pages/gram-3.html b/dita-demo/Pages/gram-3.html index 31fe5f0d..f5934f82 100644 --- a/dita-demo/Pages/gram-3.html +++ b/dita-demo/Pages/gram-3.html @@ -6,7 +6,6 @@ - @@ -42,7 +41,7 @@ - + - + @@ -181,12 +180,12 @@

      Gram Analysis 3Options What is the maximum temperature reached - 22 - 4 + qd1:MM + qd1:B How many seconds was the peak level observed for? - 6 - 1 + qd1:T + qd1:X diff --git a/dita-demo/Pages/gramless-1.html b/dita-demo/Pages/gramless-1.html index 524f2218..d47a500d 100644 --- a/dita-demo/Pages/gramless-1.html +++ b/dita-demo/Pages/gramless-1.html @@ -6,7 +6,6 @@ - @@ -42,7 +41,7 @@ - + - + @@ -106,7 +105,7 @@

      Gramless Analysis Detail Have you considered depth effects on sound propagation? - 1 + qd1:X
      1. Yes, depth creates pressure gradients affecting speed of sound
      2. @@ -117,7 +116,7 @@

        Gramless Analysis Have you considered height of the transducer array? - 1 + qd1:X
        1. Yes, transducer height affects beam geometry and coverage
        2. @@ -128,7 +127,7 @@

          Gramless Analysis Have you considered weather and sea state conditions? - 1 + qd1:X
          1. Yes, weather affects surface noise and propagation paths
          2. @@ -139,8 +138,8 @@

            Gramless Analysis What is the typical thermocline depth in these waters (in meters)? - 50 - 15 + qd1:RK + qd1:XR diff --git a/dita-demo/Pages/gramless-2.html b/dita-demo/Pages/gramless-2.html index fc8f011a..5f2b89d7 100644 --- a/dita-demo/Pages/gramless-2.html +++ b/dita-demo/Pages/gramless-2.html @@ -6,7 +6,6 @@ - @@ -42,7 +41,7 @@ - + - + @@ -106,11 +105,11 @@

            Gramless Analysis Detail What is the optimal operating frequency (in kHz) for this scenario? - 5 - 1 + qd1:R + qd1:X Which sonar mode is most appropriate for this environment? - 1 + qd1:X
            1. Active search mode with medium pulse length
            2. @@ -121,11 +120,11 @@

              Gramless Analysis What is the expected maximum detection range (in meters)? - 5000 - 500 + qd1:RKKK + qd1:RKK Should you adjust power levels for ambient noise? - 1 + qd1:X
              1. Yes, increase power to overcome background noise
              2. diff --git a/dita-demo/Pages/quiz-mcq.html b/dita-demo/Pages/quiz-mcq.html index 5b621c6b..f7f7a1bf 100644 --- a/dita-demo/Pages/quiz-mcq.html +++ b/dita-demo/Pages/quiz-mcq.html @@ -6,7 +6,6 @@ - @@ -42,7 +41,7 @@ - + - + @@ -106,7 +105,7 @@

                MCQ Questions Onl Options What does SONAR stand for? - 1 + qd1:X
                1. Sound Navigation And Ranging
                2. @@ -117,7 +116,7 @@

                  MCQ Questions Onl Which maritime force first developed practical sonar? - 3 + qd1:Q
                  1. United States Navy
                  2. @@ -128,7 +127,7 @@

                    MCQ Questions Onl What environmental factor most affects sonar performance? - 1 + qd1:X
                    1. Water temperature and salinity
                    2. @@ -139,7 +138,7 @@

                      MCQ Questions Onl Which type of sonar uses reflections to detect objects? - 1 + qd1:X
                      1. Active sonar
                      2. @@ -150,7 +149,7 @@

                        MCQ Questions Onl What is the primary function of a sonar transducer? - 2 + qd1:M
                        1. Amplify electrical signals
                        2. diff --git a/dita-demo/Pages/quiz-mixed.html b/dita-demo/Pages/quiz-mixed.html index c25bdf37..461c4053 100644 --- a/dita-demo/Pages/quiz-mixed.html +++ b/dita-demo/Pages/quiz-mixed.html @@ -6,7 +6,6 @@ - @@ -42,7 +41,7 @@ - + - + @@ -106,7 +105,7 @@

                          Mixed Quiz Questi Detail What is the primary function of a sonar system? - 3 + qd1:Q
                          1. To navigate using stars
                          2. @@ -117,11 +116,11 @@

                            Mixed Quiz Questi At what frequency (in kHz) does a typical hull-mounted sonar operate? - 5 - 0.5 + qd1:R + qd1:KPR Which type of sonar uses reflections to detect objects? - 1 + qd1:X
                            1. Active sonar
                            2. @@ -132,11 +131,11 @@

                              Mixed Quiz Questi What is the speed of sound in seawater (in m/s)? - 1500 - 50 + qd1:XRKK + qd1:RK Which component converts electrical signals into sound waves? - 2 + qd1:M
                              1. Receiver
                              2. @@ -147,11 +146,11 @@

                                Mixed Quiz Questi What is the typical detection range (in nautical miles) for towed array sonar? - 20 - 5 + qd1:MK + qd1:R What causes the thermocline effect in sonar propagation? - 2 + qd1:M
                                1. Changes in salinity
                                2. diff --git a/dita-demo/Pages/quiz-numeric.html b/dita-demo/Pages/quiz-numeric.html index 17cd3c98..eab65814 100644 --- a/dita-demo/Pages/quiz-numeric.html +++ b/dita-demo/Pages/quiz-numeric.html @@ -6,7 +6,6 @@ - @@ -42,7 +41,7 @@ - + - + @@ -106,28 +105,28 @@

                                  Numeric Questions Tolerance (±) What is the typical beam width (in degrees) for a narrow-beam sonar? - 15 - 2 + qd1:XR + qd1:M What is the maximum detection range (in meters) for a standard hull-mounted sonar? - 5000 - 500 + qd1:RKKK + qd1:RKK What pulse length (in milliseconds) is typical for a medium-range sonar? - 10 - 2 + qd1:XK + qd1:M At what depth (in meters) does the thermocline typically occur in temperate waters? - 50 - 10 + qd1:RK + qd1:XK What is the speed of sound in seawater (in m/s)? - 1500 - 50 + qd1:XRKK + qd1:RK At what frequency (in kHz) does a typical hull-mounted sonar operate? - 5 - 0.5 + qd1:R + qd1:KPR diff --git a/dita-demo/index.html b/dita-demo/index.html index f64c2aad..2d8eba76 100644 --- a/dita-demo/index.html +++ b/dita-demo/index.html @@ -10,7 +10,6 @@ - @@ -48,7 +47,7 @@ - + - + diff --git a/dita-demo/indexTerms.html b/dita-demo/indexTerms.html index fc95d30c..e411a69e 100644 --- a/dita-demo/indexTerms.html +++ b/dita-demo/indexTerms.html @@ -11,7 +11,6 @@ - @@ -48,7 +47,7 @@ - + - `}renderSuccess(){return ne` + `}renderSuccess(){return Qt`
                                  Migration completed successfully!
                                  @@ -561,177 +941,11 @@ const tn=2;class i{constructor(t){}get _$AU(){return this._$AM._$AU}_$AT(t,n,s){
                                  -
                                  - -
                                  -
                                  - `}};Sn.styles=wt` - :host { - display: contents; - } - - .migration-content { - display: flex; - flex-direction: column; - gap: 16px; - padding: 8px 0; - } - - .warning-banner { - display: flex; - align-items: flex-start; - gap: 12px; - padding: 12px; - background: #fff3cd; - border-radius: 4px; - border-left: 4px solid #ffc107; - } - - .warning-icon { - font-size: 20px; - line-height: 1; - } - - .warning-text { - flex: 1; - } - - .warning-text strong { - display: block; - margin-bottom: 4px; - color: #856404; - } - - .format-info { - font-size: 13px; - color: #666; - } - - .format-row { - display: flex; - gap: 8px; - margin: 4px 0; - } - - .format-label { - font-weight: 500; - min-width: 100px; - } - - .format-value { - font-family: monospace; - background: #f5f5f5; - padding: 2px 6px; - border-radius: 3px; - } - - .form-field { - display: flex; - flex-direction: column; - gap: 4px; - } - - label { - font-size: 13px; - font-weight: 500; - color: #333; - } - - input[type='password'] { - padding: 8px 12px; - border: 1px solid #ccc; - border-radius: 4px; - font-size: 14px; - width: 100%; - box-sizing: border-box; - } - - input[type='password']:focus { - outline: none; - border-color: #0066cc; - box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.1); - } - - .error-message { - color: #d32f2f; - font-size: 12px; - padding: 8px; - background: #ffebee; - border-radius: 4px; - border-left: 3px solid #d32f2f; - } - - .success-message { - color: #2e7d32; - font-size: 13px; - padding: 12px; - background: #e8f5e9; - border-radius: 4px; - border-left: 3px solid #4caf50; - } - - .migrating-state { - text-align: center; - padding: 20px; - } - - .spinner { - display: inline-block; - width: 24px; - height: 24px; - border: 3px solid #e0e0e0; - border-top-color: #0066cc; - border-radius: 50%; - animation: spin 1s linear infinite; - margin-bottom: 12px; - } - - @keyframes spin { - to { - transform: rotate(360deg); - } - } - - .button-row { - display: flex; - gap: 8px; - justify-content: flex-end; - margin-top: 8px; - } - - button { - padding: 8px 16px; - border: none; - border-radius: 4px; - font-size: 13px; - font-weight: 500; - cursor: pointer; - transition: background-color 0.2s; - } - - button:disabled { - opacity: 0.6; - cursor: not-allowed; - } - - button.primary { - background: #0066cc; - color: white; - } - - button.primary:hover:not(:disabled) { - background: #0052a3; - } - - button.secondary { - background: #e0e0e0; - color: #333; - } - - button.secondary:hover:not(:disabled) { - background: #d0d0d0; - } - `,wn([ye({type:Boolean,reflect:!0})],Sn.prototype,"open",2),wn([ye({type:String})],Sn.prototype,"expected",2),wn([ye({type:String})],Sn.prototype,"found",2),wn([ye({type:String})],Sn.prototype,"dbName",2),wn([ye({type:String})],Sn.prototype,"releaseId",2),wn([ve()],Sn.prototype,"dialogState",2),wn([ve()],Sn.prototype,"password",2),wn([ve()],Sn.prototype,"error",2),wn([ve()],Sn.prototype,"migrationResult",2),wn([we('input[type="password"]')],Sn.prototype,"passwordInput",2),Sn=wn([me("qd-migration-dialog")],Sn);const xn={login:{title:"Login Help",body:'

                                  Enter Name and Service ID to log in. Provide a new PIN 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 Feedback page for more support.

                                  Instructors: click "Instructor" for instructor login page (password accompanies distribution).

                                  '},status:{title:"Student View",body:'

                                  Page color coding:

                                  • Green=All correct
                                  • Amber=Some answered
                                  • Red=None yet

                                  You can view your overall progress at attempted questions in the Test Progress panel.

                                  '},instructor:{title:"Instructor Tools",body:"

                                  • Show current answers: Toggle for display of student answers for the current page.
                                  • View All Scores: View table scores for all students.
                                  • Reset PIN: Reset student PINs.
                                  • Export CSV: CSV download of all scores/answers.
                                  • Erase All Data: Clear all stored student data.

                                  "}};function En(t){return xn[t]}var Cn=Object.defineProperty,$n=Object.getOwnPropertyDescriptor,In=(t,n,s,o)=>{for(var r,a=o>1?void 0:o?$n(n,s):n,c=t.length-1;c>=0;c--)(r=t[c])&&(a=(o?r(n,s,a):r(a))||a);return o&&a&&Cn(n,s,a),a};let qn=class extends pe{constructor(){super(...arguments),this.title="Sonar Quiz System",this.name="",this.serviceId="",this.showInstructorModal=!1,this.instructorError="",this.errorMessage="",this.isSubmitting=!1,this.pin="",this.lockoutSeconds=0,this.showPinConfirmation=!1,this.helpOpen=!1,this.showMigrationDialog=!1,this.migrationError=null,this.pendingLoginData=null,this.lockoutInterval=null,this.handleLogoutEvent=()=>{this.name="",this.serviceId="",this.errorMessage="",this.isSubmitting=!1,this.showInstructorModal=!1,this.instructorError="",this.pin="",this.lockoutSeconds=0,this.showPinConfirmation=!1,this.helpOpen=!1,this.lockoutInterval&&(clearInterval(this.lockoutInterval),this.lockoutInterval=null),this.updateVisibility()},this.handleHelpOpen=()=>{this.helpOpen=!0},this.handleHelpClose=()=>{this.helpOpen=!1},this.handleInstructorPasswordSubmit=t=>{this.handleInstructorLogin(t.detail.password)},this.handleInstructorModalClose=()=>{this.showInstructorModal=!1,this.instructorError=""},this.handlePinConfirmationDismiss=()=>{this.showPinConfirmation=!1},this.handleMigrationComplete=()=>{if(this.showMigrationDialog=!1,this.migrationError=null,this.pendingLoginData){const{serviceId:t,name:n,release:s}=this.pendingLoginData;this.pendingLoginData=null,this.retryLoginAfterMigration(t,n,s)}},this.handleMigrationCancel=()=>{this.showMigrationDialog=!1,this.migrationError=null,this.pendingLoginData=null,this.errorMessage="Data migration cancelled. Please contact your instructor for assistance.",this.isSubmitting=!1}}connectedCallback(){super.connectedCallback(),this.updateVisibility(),document.addEventListener("qd:logout",this.handleLogoutEvent)}disconnectedCallback(){super.disconnectedCallback(),document.removeEventListener("qd:logout",this.handleLogoutEvent),this.lockoutInterval&&(clearInterval(this.lockoutInterval),this.lockoutInterval=null)}firstUpdated(){this.setAttribute("data-ready","")}updateVisibility(){C(u.SESSION)?this.removeAttribute("data-show"):this.setAttribute("data-show","")}render(){return ne` +
                                  + +
                                  + + `}};Rs.styles=[Ps],_s([cn({type:Boolean,reflect:!0})],Rs.prototype,"open",2),_s([cn({type:String})],Rs.prototype,"expected",2),_s([cn({type:String})],Rs.prototype,"found",2),_s([cn({type:String})],Rs.prototype,"dbName",2),_s([cn({type:String})],Rs.prototype,"releaseId",2),_s([ln()],Rs.prototype,"dialogState",2),_s([ln()],Rs.prototype,"password",2),_s([ln()],Rs.prototype,"error",2),_s([ln()],Rs.prototype,"migrationResult",2),_s([un('input[type="password"]')],Rs.prototype,"passwordInput",2),Rs=_s([on("qd-migration-dialog")],Rs);const zs={login:{title:"Login Help",body:'

                                  Enter Name and Service ID to log in. Provide a new 4-digit PIN 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 Feedback page for more support.

                                  Instructors: click "Instructor" for instructor login page (password accompanies distribution).

                                  '},status:{title:"Student View",body:'

                                  Page badges in the contents:

                                  • Green: every question on the page answered correctly
                                  • Amber: started, with some questions unanswered or incorrect
                                  • Red: not started

                                  On a quiz page, each answer shows ✓ Correct or ✗ Incorrect. You can change an answer at any time.

                                  The Test progress panel counts the questions you have answered and how many are correct.

                                  '},instructor:{title:"Instructor Tools",body:"

                                  • Show current answers: Toggle for display of student answers for the current page.
                                  • View All Scores: View table scores for all students.
                                  • Reset PIN: Reset student PINs.
                                  • Export CSV: CSV download of all scores/answers.
                                  • Erase All Data: Clear all stored student data.

                                  "}};function Ms(t){return zs[t]}var Us=Object.defineProperty,js=Object.getOwnPropertyDescriptor,Hs=(t,n,s,r)=>{for(var o,a=r>1?void 0:r?js(n,s):n,d=t.length-1;d>=0;d--)(o=t[d])&&(a=(r?o(n,s,a):o(a))||a);return r&&a&&Us(n,s,a),a};let Bs=class extends sn{constructor(){super(...arguments),this.title="Sonar Quiz System",this.name="",this.serviceId="",this.errorMessage="",this.isSubmitting=!1,this.pin="",this.lockoutUntil=0,this.touched=new Set,this.isRegistered=null,this.pendingPin="",this.helpOpen=!1,this.sessionNotice="",this.showMigrationDialog=!1,this.migrationError=null,this.pendingLoginData=null,this.authService=new AuthService,this.handleLoginEvent=()=>{this.sessionNotice="",this.updateVisibility()},this.handleLogoutEvent=()=>{this.name="",this.serviceId="",this.errorMessage="",this.isSubmitting=!1,this.pin="",this.lockoutUntil=0,this.touched=new Set,this.helpOpen=!1,this.isRegistered=null,this.pendingPin="",this.takeSessionExpiredNotice(),this.updateVisibility()},this.handleLockoutExpired=()=>{this.lockoutUntil=0},this.handleHelpOpen=()=>{this.helpOpen=!0},this.handleHelpClose=()=>{this.helpOpen=!1},this.handleMigrationComplete=()=>{if(this.showMigrationDialog=!1,this.migrationError=null,this.pendingLoginData){const{serviceId:t,name:n,release:s}=this.pendingLoginData;this.pendingLoginData=null,this.retryLoginAfterMigration(t,n,s)}},this.handleMigrationCancel=()=>{this.showMigrationDialog=!1,this.migrationError=null,this.pendingLoginData=null,this.errorMessage="Data migration cancelled. Please contact your instructor for assistance.",this.isSubmitting=!1}}connectedCallback(){super.connectedCallback(),this.updateVisibility(),this.takeSessionExpiredNotice(),document.addEventListener("qd:logout",this.handleLogoutEvent),document.addEventListener("qd:login",this.handleLoginEvent)}disconnectedCallback(){super.disconnectedCallback(),window.clearTimeout(this.registrationTimer),document.removeEventListener("qd:logout",this.handleLogoutEvent),document.removeEventListener("qd:login",this.handleLoginEvent)}takeSessionExpiredNotice(){if(null===sessionStorage.getItem(G.SESSION_EXPIRED))return;sessionStorage.removeItem(G.SESSION_EXPIRED);const t=Math.round(10);this.sessionNotice=`Your session ended after ${t} minutes without activity. Log in again to continue.`}firstUpdated(){this.setAttribute("data-ready","")}updateVisibility(){this.toggleAttribute("data-show",!le()&&!ue())}isLockedOut(){return this.lockoutUntil>Date.now()}render(){return Qt` - - - - - - - `}handleNameInput(t){const n=t.target;this.name=n.value,this.errorMessage=""}handleServiceIdInput(t){const n=t.target;this.serviceId=n.value,this.errorMessage=""}handlePinInput(t){const n=t.target;this.pin=function(t){return t.replace(/\D/g,"")}(n.value),this.errorMessage=""}isValid(){return 0===function(t,n,s){const o=[];t&&""!==t.trim()||o.push("Name required"),n?/^[a-zA-Z0-9]{2,10}$/.test(n)||o.push("Service ID must be 2-10 alphanumeric characters"):o.push("Service ID required");s?/^\d{4}$/.test(s)||o.push("PIN must be exactly 4 digits"):o.push("PIN required");return o}(this.name,this.serviceId,this.pin).length}getRelease(){const t=document.getElementById($e),n=t?.textContent?.trim()||".wh_publication_title .title",s=document.querySelector(n);return s?.textContent?.trim()||""}async handleStudentLogin(t){if(t.preventDefault(),this.isValid()){this.isSubmitting=!0,this.errorMessage="";try{const t=this.getRelease();if(!t)return this.errorMessage="Release not found (missing publication title element)",void(this.isSubmitting=!1);const n=this.serviceId.trim(),s=this.name.trim(),o=Re(n);if(o.isLocked)return this.startLockoutCountdown(o.remainingMs),void(this.isSubmitting=!1);const r=document.getElementById(qe);if(!r?.textContent?.trim())throw new Error(`Database name not configured. Add dbName to page.`);const a=J(r.textContent.trim());await a.init();const c=await a.getStudent(t,n);if(!c){const o=await _e(this.pin),r={schema:2,docId:"",release:t,serviceId:n,name:s,attempted:0,correct:0,updated:(new Date).toISOString(),pages:{},pinHash:o,pinCreatedAt:(new Date).toISOString()};return await a.saveStudent(r),this.dispatchEvent(new CustomEvent("qd:pin-created",{detail:{serviceId:n,timestamp:(new Date).toISOString()},bubbles:!0,composed:!0})),this.showPinStoredConfirmation(),void this.completeLogin(n,s,t)}if(Oe(c)||!Te(c)){const o=Pe(c,await _e(this.pin));return await a.saveStudent(o),this.dispatchEvent(new CustomEvent("qd:pin-created",{detail:{serviceId:n,timestamp:(new Date).toISOString()},bubbles:!0,composed:!0})),this.showPinStoredConfirmation(),void this.completeLogin(n,s,t)}if(!(await Ne(this.pin,c.pinHash||""))){const t=Me(n),s=He(n);if(t.lockoutUntil){const n=new Date(t.lockoutUntil).getTime()-Date.now();this.startLockoutCountdown(n)}else this.errorMessage=`Incorrect PIN. ${s} attempt${1!==s?"s":""} remaining`;return this.pin="",void(this.isSubmitting=!1)}ze(n),this.dispatchEvent(new CustomEvent("qd:pin-verified",{detail:{serviceId:n,timestamp:(new Date).toISOString()},bubbles:!0,composed:!0}));this.completeLogin(n,s,t)}catch(n){if(n instanceof StorageFormatError){const t=document.getElementById(qe),s=t?.textContent?.trim()||"";return this.migrationError=n,this.pendingLoginData={serviceId:this.serviceId.trim(),name:this.name.trim(),release:this.getRelease(),pin:this.pin,dbName:s},this.showMigrationDialog=!0,void(this.isSubmitting=!1)}this.errorMessage="Login failed. Please try again.",console.error("Student login error:",n),this.isSubmitting=!1}}else this.errorMessage="Please enter name, service ID, and 4-digit PIN"}showPinStoredConfirmation(){this.showPinConfirmation=!0}async retryLoginAfterMigration(t,n,s){this.isSubmitting=!0,this.errorMessage="";try{const o=document.getElementById(qe),r=J(o?.textContent?.trim()||"");await r.init();const a=await r.getStudent(s,t);if(!a){const o=await _e(this.pin),a={schema:2,docId:"",release:s,serviceId:t,name:n,attempted:0,correct:0,updated:(new Date).toISOString(),pages:{},pinHash:o,pinCreatedAt:(new Date).toISOString()};return await r.saveStudent(a),this.dispatchEvent(new CustomEvent("qd:pin-created",{detail:{serviceId:t,timestamp:(new Date).toISOString()},bubbles:!0,composed:!0})),this.showPinStoredConfirmation(),void this.completeLogin(t,n,s)}if(Oe(a)||!Te(a)){const o=Pe(a,await _e(this.pin));return await r.saveStudent(o),this.dispatchEvent(new CustomEvent("qd:pin-created",{detail:{serviceId:t,timestamp:(new Date).toISOString()},bubbles:!0,composed:!0})),this.showPinStoredConfirmation(),void this.completeLogin(t,n,s)}if(!(await Ne(this.pin,a.pinHash||""))){const n=Me(t),s=He(t);if(n.lockoutUntil){const t=new Date(n.lockoutUntil).getTime()-Date.now();this.startLockoutCountdown(t)}else this.errorMessage=`Incorrect PIN. ${s} attempt${1!==s?"s":""} remaining`;return this.pin="",void(this.isSubmitting=!1)}ze(t),this.dispatchEvent(new CustomEvent("qd:pin-verified",{detail:{serviceId:t,timestamp:(new Date).toISOString()},bubbles:!0,composed:!0}));this.completeLogin(t,n,s)}catch(o){this.errorMessage="Login failed after migration. Please try again.",console.error("Post-migration login error:",o),this.isSubmitting=!1}}startLockoutCountdown(t){this.lockoutSeconds=Math.ceil(t/1e3),this.errorMessage="",this.lockoutInterval&&clearInterval(this.lockoutInterval),this.lockoutInterval=window.setInterval(()=>{this.lockoutSeconds--,this.lockoutSeconds<=0&&this.lockoutInterval&&(clearInterval(this.lockoutInterval),this.lockoutInterval=null)},1e3)}completeLogin(t,n,s){(new SessionService).createSession(t,n,s);const o=new CustomEvent("qd:login",{detail:{serviceId:t,name:n,release:s,role:"student"},bubbles:!0,composed:!0});this.dispatchEvent(o),this.pin="",this.isSubmitting=!1,this.updateVisibility()}openInstructorModal(){this.showInstructorModal=!0,this.instructorError=""}async hashPassword(t){const n=(new TextEncoder).encode(t),s=await crypto.subtle.digest("SHA-256",n);return Array.from(new Uint8Array(s)).map(t=>t.toString(16).padStart(2,"0")).join("").substring(0,12)}getExpectedHash(){const t=document.getElementById(Ie);return t?.textContent?.trim()||""}async handleInstructorLogin(t){try{const n=await this.hashPassword(t),s=this.getExpectedHash();if(!s)return void(this.instructorError="Instructor password not configured");if(n!==s)return void(this.instructorError="Incorrect password");const o=this.getRelease();(new SessionService).createSession("INSTRUCTOR","Instructor",o||""),sessionStorage.setItem(u.INSTRUCTOR,"true");const r=new CustomEvent("qd:login",{detail:{serviceId:"INSTRUCTOR",name:"Instructor",release:o||"",role:"instructor"},bubbles:!0,composed:!0});this.dispatchEvent(r),this.showInstructorModal=!1,this.instructorError="",this.updateVisibility()}catch(n){this.instructorError="Login failed. Please try again.",console.error("Instructor login error:",n)}}};qn.styles=wt` - :host { - display: none; /* Hidden if already logged in */ - font-family: - system-ui, - -apple-system, - sans-serif; - } - - :host([data-show]) { - display: block; - } - - .login-container { - padding: 8px 12px; - background: #fff; - border: 1px solid #ddd; - border-radius: 4px; - max-width: 480px; - } - - .title { - margin: 0 0 8px 0; - font-size: 15px; - font-weight: 600; - color: #333; - } - - .login-form { - display: flex; - gap: 6px; - align-items: flex-start; - flex-wrap: wrap; - } - - input { - padding: 6px 10px; - border: 1px solid #ccc; - border-radius: 4px; - font-size: 11px; - width: 110px; - min-width: 75px; - max-width: 110px; - } - - input.pin-input { - width: 45px; - min-width: 45px; - max-width: 45px; - text-align: center; - letter-spacing: 1px; - } - - input:focus { - outline: none; - border-color: #0066cc; - box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.1); - } - - input:disabled { - background-color: #f5f5f5; - cursor: not-allowed; - } - - button { - padding: 6px 12px; - border: none; - border-radius: 4px; - font-size: 11px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s; - white-space: nowrap; - } - - .login-btn { - background: #0066cc; - color: white; - } - - .login-btn:hover:not(:disabled) { - background: #0052a3; - } - - .login-btn:disabled { - background: #ccc; - cursor: not-allowed; - } - - .instructor-btn { - background: #6c757d; - color: white; - } - - .instructor-btn:hover { - background: #5a6268; - } - - .error-message { - width: 100%; - color: #d32f2f; - font-size: 11px; - margin-top: 3px; - padding: 4px 8px; - background: #ffebee; - border-radius: 3px; - border-left: 3px solid #d32f2f; - } - - .lockout-message { - width: 100%; - color: #f57c00; - font-size: 11px; - margin-top: 3px; - padding: 4px 8px; - background: #fff3e0; - border-radius: 3px; - border-left: 3px solid #f57c00; - } - - /* Responsive */ - @media (max-width: 600px) { - .login-form { - flex-direction: column; - } - - input, - button { - width: 100%; - } - } - `,In([ye({type:String})],qn.prototype,"title",2),In([ve()],qn.prototype,"name",2),In([ve()],qn.prototype,"serviceId",2),In([ve()],qn.prototype,"showInstructorModal",2),In([ve()],qn.prototype,"instructorError",2),In([ve()],qn.prototype,"errorMessage",2),In([ve()],qn.prototype,"isSubmitting",2),In([ve()],qn.prototype,"pin",2),In([ve()],qn.prototype,"lockoutSeconds",2),In([ve()],qn.prototype,"showPinConfirmation",2),In([ve()],qn.prototype,"helpOpen",2),In([ve()],qn.prototype,"showMigrationDialog",2),In([ve()],qn.prototype,"migrationError",2),In([ve()],qn.prototype,"pendingLoginData",2),qn=In([me("qd-login")],qn);var An=Object.defineProperty,kn=Object.getOwnPropertyDescriptor,On=(t,n,s,o)=>{for(var r,a=o>1?void 0:o?kn(n,s):n,c=t.length-1;c>=0;c--)(r=t[c])&&(a=(o?r(n,s,a):r(a))||a);return o&&a&&An(n,s,a),a};let Tn=class extends pe{constructor(){super(...arguments),this.total=0,this.correct=0,this.percentage=0,this.statusColor="red",this.name="",this.serviceId="",this.helpOpen=!1,this.handleStateChanged=()=>{this.loadCache()},this.handleLogin=()=>{this.updateVisibility(),this.loadCache()},this.handleCacheRebuild=()=>{this.loadCache()},this.handleLogoutEvent=()=>{this.updateVisibility()},this.handleHelpOpen=()=>{this.helpOpen=!0},this.handleHelpClose=()=>{this.helpOpen=!1}}connectedCallback(){super.connectedCallback(),this.updateVisibility(),this.loadCache(),document.addEventListener("qd:state-changed",this.handleStateChanged),document.addEventListener("qd:login",this.handleLogin),document.addEventListener("qd:logout",this.handleLogoutEvent),document.addEventListener("qd:cache-rebuild",this.handleCacheRebuild)}disconnectedCallback(){super.disconnectedCallback(),document.removeEventListener("qd:state-changed",this.handleStateChanged),document.removeEventListener("qd:login",this.handleLogin),document.removeEventListener("qd:logout",this.handleLogoutEvent),document.removeEventListener("qd:cache-rebuild",this.handleCacheRebuild)}render(){const t=this.serviceId.slice(-4);return ne` + `}handleNameInput(t){this.name=t.target.value,this.errorMessage="",this.touch("name")}handleServiceIdInput(t){this.serviceId=t.target.value,this.errorMessage="",this.touch("serviceId"),this.pendingPin="",this.scheduleRegistrationCheck()}scheduleRegistrationCheck(){window.clearTimeout(this.registrationTimer),this.isRegistered=null;const t=this.serviceId.trim();/^[a-zA-Z0-9]{2,10}$/.test(t)&&(this.registrationTimer=window.setTimeout(()=>{this.checkRegistration(t)},300))}async checkRegistration(t){let n=null;try{const s=S();n=s?await this.authService.isRegistered(t,s,x()):null}catch{n=null}t===this.serviceId.trim()&&(this.isRegistered=n)}get submitLabel(){return this.pendingPin?"Confirm":!1===this.isRegistered?"Create":"Login"}handlePinInput(t){this.pin=t.target.value.replace(/\D/g,""),this.errorMessage="",this.touch("pin")}touch(t){this.touched.has(t)||(this.touched=new Set([...this.touched,t]))}get fieldErrors(){const t=tt(this.name,this.serviceId,this.pin),n={};for(const s of this.touched)t[s]&&(n[s]=t[s]);return n}isValid(){return 0===function(t,n,s){const r=[];t&&""!==t.trim()||r.push("Name required"),n?/^[a-zA-Z0-9]{2,10}$/.test(n)||r.push("Service ID must be 2-10 alphanumeric characters"):r.push("Service ID required");s?/^\d{4}$/.test(s)||r.push("PIN must be exactly 4 digits"):r.push("PIN required");return r}(this.name,this.serviceId,this.pin).length}get validationHint(){return this.pendingPin?"Re-enter your PIN to confirm":this.name||this.serviceId||this.pin?Object.values(tt(this.name,this.serviceId,this.pin)).join(" · "):""}async handleStudentLogin(t){if(t.preventDefault(),!this.isValid())return this.touched=new Set(["name","serviceId","pin"]),void(this.errorMessage="Please enter name, service ID, and 4-digit PIN");if(null!==this.isRegistered||this.pendingPin||await this.checkRegistration(this.serviceId.trim()),this.pendingPin){if(this.pin!==this.pendingPin)return this.pendingPin="",this.pin="",void(this.errorMessage="PINs did not match. Please enter your PIN again.");this.pendingPin=""}else if(!1===this.isRegistered)return this.pendingPin=this.pin,this.pin="",void(this.errorMessage="");this.isSubmitting=!0,this.errorMessage="";const n=S();if(!n)return this.errorMessage="Release not found (missing publication title element)",void(this.isSubmitting=!1);const s=this.serviceId.trim(),r=this.name.trim(),o=x(),a=this.pin,d=await this.authService.loginStudent({serviceId:s,name:r,pin:a,release:n,dbName:o});if("needs-migration"===d.kind)return this.migrationError=d.error,this.pendingLoginData={serviceId:s,name:r,release:n,pin:a,dbName:o},this.showMigrationDialog=!0,void(this.isSubmitting=!1);this.applyLoginResult(d)}applyLoginResult(t){switch(t.kind){case"pin-created":this.dispatchPinEvent("qd:pin-created",t.serviceId),this.completeLogin(t.serviceId,t.name,t.release);break;case"pin-verified":this.dispatchPinEvent("qd:pin-verified",t.serviceId),this.completeLogin(t.serviceId,t.name,t.release);break;case"lockout":this.lockoutUntil=Date.now()+t.lockoutMs,this.errorMessage="",this.isSubmitting=!1;break;case"bad-pin":this.errorMessage=`Incorrect PIN. ${t.remaining} attempt${1!==t.remaining?"s":""} remaining`,this.pin="",this.isSubmitting=!1;break;case"error":this.errorMessage=t.message,this.isSubmitting=!1}}dispatchPinEvent(t,n){this.dispatchEvent(new CustomEvent(t,{detail:{serviceId:n,timestamp:(new Date).toISOString()},bubbles:!0,composed:!0}))}async retryLoginAfterMigration(t,n,s){this.isSubmitting=!0,this.errorMessage="";const r=await this.authService.retryAfterMigration({serviceId:t,name:n,pin:this.pin,release:s,dbName:x()});this.applyLoginResult(r)}completeLogin(t,n,s){(new SessionService).createSession(t,n,s);const r={serviceId:t,name:n,release:s,role:"student"};this.dispatchEvent(new CustomEvent("qd:login",{detail:r,bubbles:!0,composed:!0})),this.pin="",this.isSubmitting=!1,this.updateVisibility()}};Bs.styles=zn,Hs([cn({type:String})],Bs.prototype,"title",2),Hs([ln()],Bs.prototype,"name",2),Hs([ln()],Bs.prototype,"serviceId",2),Hs([ln()],Bs.prototype,"errorMessage",2),Hs([ln()],Bs.prototype,"isSubmitting",2),Hs([ln()],Bs.prototype,"pin",2),Hs([ln()],Bs.prototype,"lockoutUntil",2),Hs([ln()],Bs.prototype,"touched",2),Hs([ln()],Bs.prototype,"isRegistered",2),Hs([ln()],Bs.prototype,"pendingPin",2),Hs([ln()],Bs.prototype,"helpOpen",2),Hs([ln()],Bs.prototype,"sessionNotice",2),Hs([ln()],Bs.prototype,"showMigrationDialog",2),Hs([ln()],Bs.prototype,"migrationError",2),Hs([ln()],Bs.prototype,"pendingLoginData",2),Bs=Hs([on("qd-login")],Bs);var Fs=Object.defineProperty,Vs=Object.getOwnPropertyDescriptor,Qs=(t,n,s,r)=>{for(var o,a=r>1?void 0:r?Vs(n,s):n,d=t.length-1;d>=0;d--)(o=t[d])&&(a=(r?o(n,s,a):o(a))||a);return r&&a&&Fs(n,s,a),a};let Ks=class extends sn{constructor(){super(...arguments),this.answered=0,this.correct=0,this.questionTotal=void 0,this.statusColor="red",this.name="",this.serviceId="",this.helpOpen=!1,this.handleStateChanged=()=>{this.loadCache()},this.handleLogin=()=>{this.updateVisibility(),this.loadCache()},this.handleCacheRebuild=()=>{this.loadCache()},this.handleLogoutEvent=()=>{this.updateVisibility()},this.handleHelpOpen=()=>{this.helpOpen=!0},this.handleHelpClose=()=>{this.helpOpen=!1}}connectedCallback(){super.connectedCallback(),this.updateVisibility(),this.loadCache(),document.addEventListener("qd:state-changed",this.handleStateChanged),document.addEventListener("qd:login",this.handleLogin),document.addEventListener("qd:logout",this.handleLogoutEvent),document.addEventListener("qd:cache-rebuild",this.handleCacheRebuild)}disconnectedCallback(){super.disconnectedCallback(),document.removeEventListener("qd:state-changed",this.handleStateChanged),document.removeEventListener("qd:login",this.handleLogin),document.removeEventListener("qd:logout",this.handleLogoutEvent),document.removeEventListener("qd:cache-rebuild",this.handleCacheRebuild)}render(){const t=this.serviceId.slice(-4);return Qt`
                                  - `}loadCache(){const t=C(u.SESSION);t?(this.name=t.name||"",this.serviceId=t.serviceId||""):(this.name="",this.serviceId="");const n=C(u.CACHE);if(!n)return this.total=0,this.correct=0,this.percentage=0,void(this.statusColor="red");this.total=n.totals.total,this.correct=n.totals.correct,this.percentage=this.calculatePercentage(n.totals.total,n.totals.correct),this.statusColor=this.calculateStatusColor(n.totals.total,n.totals.correct)}calculatePercentage(t,n){return 0===t?0:Math.round(n/t*100)}calculateStatusColor(t,n){return function(t,n){return 0===t||0===n?"red":n===t?"green":"amber"}(t,n)}updateVisibility(){const t=C(u.SESSION),n="true"===sessionStorage.getItem(u.INSTRUCTOR);t&&!n?this.setAttribute("data-show",""):this.removeAttribute("data-show")}handleLogout(){const t=C(u.SESSION);(new SessionService).clearSession();const n=new CustomEvent("qd:logout",{detail:{serviceId:t?.serviceId||"unknown"},bubbles:!0,composed:!0});this.dispatchEvent(n)}};Tn.styles=wt` + `;var n,s,r}loadCache(){const t=W(G.SESSION);t?(this.name=t.name||"",this.serviceId=t.serviceId||""):(this.name="",this.serviceId=""),this.questionTotal=y();const n=W(G.CACHE);var s,r,o;this.answered=n?.totals.answered??0,this.correct=n?.totals.correct??0,this.statusColor=(s=this.answered,r=this.correct,o=this.questionTotal,0===s?"red":void 0!==o&&r>=o?"green":"amber")}updateVisibility(){this.toggleAttribute("data-show",le()&&!ue())}handleLogout(){const t=W(G.SESSION);(new SessionService).clearSession();const n=new CustomEvent("qd:logout",{detail:{serviceId:t?.serviceId||"unknown"},bubbles:!0,composed:!0});this.dispatchEvent(n)}};Ks.styles=lt` :host { display: none; /* Hidden by default, shown when logged in */ font-family: @@ -1088,7 +1151,7 @@ const tn=2;class i{constructor(t){}get _$AU(){return this._$AM._$AU}_$AT(t,n,s){ .logout-button:hover { background: #b71c1c; } - `,On([ve()],Tn.prototype,"total",2),On([ve()],Tn.prototype,"correct",2),On([ve()],Tn.prototype,"percentage",2),On([ve()],Tn.prototype,"statusColor",2),On([ve()],Tn.prototype,"name",2),On([ve()],Tn.prototype,"serviceId",2),On([ve()],Tn.prototype,"helpOpen",2),Tn=On([me("qd-status")],Tn);const Pn=wt` + `,Qs([ln()],Ks.prototype,"answered",2),Qs([ln()],Ks.prototype,"correct",2),Qs([ln()],Ks.prototype,"questionTotal",2),Qs([ln()],Ks.prototype,"statusColor",2),Qs([ln()],Ks.prototype,"name",2),Qs([ln()],Ks.prototype,"serviceId",2),Qs([ln()],Ks.prototype,"helpOpen",2),Ks=Qs([on("qd-status")],Ks);const Ws=lt` :host { display: inline-block; font-family: @@ -1173,15 +1236,16 @@ const tn=2;class i{constructor(t){}get _$AU(){return this._$AM._$AU}_$AT(t,n,s){ font-size: 13px; } + /* #0062cc keeps white text above WCAG AA 4.5:1 (#007bff was 3.98:1; SRD 013 A11Y-04) */ button.primary { - background: #007bff; + background: #0062cc; color: white; - border-color: #007bff; + border-color: #0062cc; } button.primary:hover { - background: #0056b3; - border-color: #0056b3; + background: #004c9e; + border-color: #004c9e; } button.secondary { @@ -1206,6 +1270,31 @@ const tn=2;class i{constructor(t){}get _$AU(){return this._$AM._$AU}_$AT(t,n,s){ border-color: #c82333; } + /* + * Destructive actions sit apart from routine ones and stay quiet until used + * (SRD 013 UX-08): an outline on the dark toolbar, filled only on hover. + */ + .danger-zone { + display: inline-flex; + align-items: center; + gap: 8px; + padding-left: 12px; + margin-left: 4px; + border-left: 1px solid rgba(255, 255, 255, 0.35); + } + + button.outline-danger { + background: transparent; + color: #ffcdd2; + border-color: #e57373; + } + + button.outline-danger:hover { + background: #c62828; + color: white; + border-color: #c62828; + } + button.logout { background: #6c757d; color: white; @@ -1330,7 +1419,7 @@ const tn=2;class i{constructor(t){}get _$AU(){return this._$AM._$AU}_$AT(t,n,s){ .close-button:hover { color: #000; } -`;class RateLimiter{constructor(){this.failureCount=0,this.lockoutUntil=null}attempt(){return!(this.lockoutUntil&&Date.now()=this.lockoutUntil&&(this.lockoutUntil=null),!0)}recordFailure(){this.failureCount++;const t=[2e3,4e3,8e3,16e3,3e4],n=t[Math.min(this.failureCount-1,t.length-1)]??3e4;this.lockoutUntil=Date.now()+n}reset(){this.failureCount=0,this.lockoutUntil=null}getRemainingSeconds(){if(!this.lockoutUntil)return 0;const t=Math.max(0,this.lockoutUntil-Date.now());return Math.ceil(t/1e3)}isLockedOut(){return null!==this.lockoutUntil&&Date.now(){for(var r,a=o>1?void 0:o?Ln(n,s):n,c=t.length-1;c>=0;c--)(r=t[c])&&(a=(o?r(n,s,a):r(a))||a);return o&&a&&Nn(n,s,a),a};let Rn=class extends pe{constructor(){super(...arguments),this.password="",this.error="",this.remainingSeconds=0,this.rateLimiter=new RateLimiter,this.handlePasswordInput=t=>{const n=t.target;this.password=n.value,this.error=""},this.handleSubmit=async t=>{t.preventDefault();if(!this.rateLimiter.attempt())return this.remainingSeconds=this.rateLimiter.getRemainingSeconds(),this.startCountdown(),void(this.error=`Too many attempts. Try again in ${this.remainingSeconds}s`);try{const t=function(){const t=document.getElementById(_n);if(!t){const t=`Instructor password hash not found. Expected element with id="${_n}". Check Oxygen XSL transform configuration.`;throw r(t),new Error(t)}const n=t.textContent?.trim();if(!n){const t="Instructor password hash element is empty. Check Oxygen parameter configuration.";throw r(t),new Error(t)}if(!/^[a-f0-9]{64}$/i.test(n)){const t=`Invalid password hash format. Expected 64 hex characters (SHA-256), got: ${n.substring(0,20)}...`;throw r(t),new Error(t)}return n.toLowerCase()}(),n=(new TextEncoder).encode(this.password),s=await crypto.subtle.digest("SHA-256",n),o=Array.from(new Uint8Array(s)).map(t=>t.toString(16).padStart(2,"0")).join(""),a=await async function(t,n){if(t.length!==n.length)return!1;if(0===t.length)return!0;const s=new TextEncoder,o=s.encode(t),r=s.encode(n);try{const t=await crypto.subtle.importKey("raw",o,{name:"HMAC",hash:"SHA-256"},!1,["sign"]),n=await crypto.subtle.sign("HMAC",t,r),s=await crypto.subtle.importKey("raw",r,{name:"HMAC",hash:"SHA-256"},!1,["sign"]),a=await crypto.subtle.sign("HMAC",s,o);if(n.byteLength!==a.byteLength)return!1;const c=new Uint8Array(n),d=new Uint8Array(a);let l=0;for(let o=0;o{this.remainingSeconds=this.rateLimiter.getRemainingSeconds(),0===this.remainingSeconds?(this.countdownInterval&&(window.clearInterval(this.countdownInterval),this.countdownInterval=void 0),this.error=""):this.error=`Too many attempts. Try again in ${this.remainingSeconds}s`},1e3)}render(){const t=this.remainingSeconds>0;return ne` +`;var Js=Object.defineProperty,Ys=Object.getOwnPropertyDescriptor,Xs=(t,n,s,r)=>{for(var o,a=r>1?void 0:r?Ys(n,s):n,d=t.length-1;d>=0;d--)(o=t[d])&&(a=(r?o(n,s,a):o(a))||a);return r&&a&&Js(n,s,a),a};let Gs=class extends sn{constructor(){super(...arguments),this.password="",this.error="",this.remainingSeconds=0,this.rateLimiter=new RateLimiter(Z-1),this.handlePasswordInput=t=>{const n=t.target;this.password=n.value,this.error=""},this.handleSubmit=async t=>{t.preventDefault();if(!this.rateLimiter.attempt())return this.remainingSeconds=this.rateLimiter.getRemainingSeconds(),this.startCountdown(),void(this.error=`Too many attempts. Try again in ${this.remainingSeconds}s`);try{if(!se())throw new Error("Instructor password hash not configured");await ce(this.password)?(this.rateLimiter.reset(),this.password="",this.error="",Ve(this,"qd:instructor-unlock",{})):(this.rateLimiter.recordFailure(),this.error="Invalid password",this.password="")}catch{this.error="Authentication failed",this.password=""}}}disconnectedCallback(){super.disconnectedCallback(),this.countdownInterval&&window.clearInterval(this.countdownInterval)}startCountdown(){this.countdownInterval&&window.clearInterval(this.countdownInterval),this.countdownInterval=window.setInterval(()=>{this.remainingSeconds=this.rateLimiter.getRemainingSeconds(),0===this.remainingSeconds?(this.countdownInterval&&(window.clearInterval(this.countdownInterval),this.countdownInterval=void 0),this.error=""):this.error=`Too many attempts. Try again in ${this.remainingSeconds}s`},1e3)}render(){const t=this.remainingSeconds>0;return Qt`

                                  Instructor Access

                                  Enter the instructor password to unlock administrative features.

                                  @@ -1349,14 +1438,14 @@ const tn=2;class i{constructor(t){}get _$AU(){return this._$AM._$AU}_$AT(t,n,s){ />
                                  - ${this.error?ne``:""} + ${this.error?Qt``:""}
                                  - `}};Rn.styles=Pn,Dn([ve()],Rn.prototype,"password",2),Dn([ve()],Rn.prototype,"error",2),Dn([ve()],Rn.prototype,"remainingSeconds",2),Rn=Dn([me("qd-instructor-unlock")],Rn);var Mn=Object.defineProperty,zn=Object.getOwnPropertyDescriptor,Hn=(t,n,s,o)=>{for(var r,a=o>1?void 0:o?zn(n,s):n,c=t.length-1;c>=0;c--)(r=t[c])&&(a=(o?r(n,s,a):r(a))||a);return o&&a&&Mn(n,s,a),a};let Un=class extends pe{constructor(){super(...arguments),this.open=!1,this.students=[],this.handleModalClose=()=>{this.open=!1,this.dispatchEvent(new CustomEvent("close"))}}render(){return ne` + `}};Gs.styles=Ws,Xs([ln()],Gs.prototype,"password",2),Xs([ln()],Gs.prototype,"error",2),Xs([ln()],Gs.prototype,"remainingSeconds",2),Gs=Xs([on("qd-instructor-unlock")],Gs);var Zs=Object.defineProperty,er=Object.getOwnPropertyDescriptor,tr=(t,n,s,r)=>{for(var o,a=r>1?void 0:r?er(n,s):n,d=t.length-1;d>=0;d--)(o=t[d])&&(a=(r?o(n,s,a):o(a))||a);return r&&a&&Zs(n,s,a),a};let nr=class extends sn{constructor(){super(...arguments),this.open=!1,this.students=[],this.handleModalClose=()=>{this.open=!1,this.dispatchEvent(new CustomEvent("close"))}}render(){return Qt` Student Scores
                                  - ${0===this.students.length?ne`

                                  No student data available.

                                  `:this.renderScoresTable()} + ${0===this.students.length?Qt`

                                  No student data available.

                                  `:this.renderScoresTable()}
                                  - `}renderScoresTable(){const t=[...this.students].sort((t,n)=>t.name.localeCompare(n.name));return ne` + `}renderScoresTable(){const t=[...this.students].sort((t,n)=>t.name.localeCompare(n.name));return Qt` - + + + @@ -1462,27 +1561,33 @@ const tn=2;class i{constructor(t){}get _$AU(){return this._$AM._$AU}_$AT(t,n,s){ ${t.map(t=>this.renderStudentRow(t))}
                                  Student Service IDScoreAnsweredCorrectRight first time Answers
                                  - `}renderStudentRow(t){const n=this.calculateSummary(t),s=Object.entries(t.pages);return ne` + `}renderStudentRow(t){const n=this.calculateSummary(t),s=Object.entries(t.pages);return Qt` ${n.name} ${n.serviceId} + ${this.answeredText(n.attempted)} - ${n.correct}/${n.attempted} (${n.percentage}%) + ${n.attempted>0?`${n.correct} (${n.percentage}% of answered)`:"0"} + ${this.firstTimeText(t)} - ${0===s.length?ne`—`:ne` + ${0===s.length?Qt`—`:Qt`
                                  - ${s.map(([t,n])=>ne` + ${s.map(([t,n])=>Qt`
                                  ${t}
                                  - ${n.answers.map((t,n)=>ne` - - Q${n+1}: ${t?.answer??"—"} - - `)} + ${n.analysis?Qt`${this.analysisSummary(n.analysis)}`:""} + ${n.answers.map((t,n)=>t&&""!==t.answer.trim()?Qt` + + Q${n+1}: + ${t?.answer??"—"}${t?.attempts&&t.attempts>1?` (${t.attempts} tries)`:""} + + `:"")}
                                  `)} @@ -1490,17 +1595,17 @@ const tn=2;class i{constructor(t){}get _$AU(){return this._$AM._$AU}_$AT(t,n,s){ `} - `}getScoreClass(t){return 0===t.attempted?"":100===t.percentage?"score-perfect":0===t.percentage?"score-zero":""}calculateSummary(t){const n=t.attempted>0?Math.round(t.correct/t.attempted*100):0;return{serviceId:t.serviceId,name:t.name,attempted:t.attempted,correct:t.correct,percentage:n}}show(){this.open=!0}close(){this.open=!1}};Un.styles=wt` + `}analysisSummary(t){const n=ze(t);return void 0!==t.totalCells?`Analysis: ${n} of ${t.totalCells} cells`:`Analysis: ${n} cell${1===n?"":"s"}`}answeredText(t){const n=y();return void 0!==n?`${t} of ${n}`:String(t)}firstTimeText(t){const{tracked:n,correctFirstTime:s,untracked:r}=function(t){let n=0,s=0,r=0;for(const o of Object.values(t.pages))for(const t of o.answers??[])t&&""!==t.answer.trim()&&(void 0===t.firstSuccess?r++:(n++,t.firstSuccess&&s++));return{tracked:n,correctFirstTime:s,untracked:r}}(t);if(0===n)return"–";const o=`${s} of ${n}`;return r>0?`${o} (${r} older answer${1===r?"":"s"} –)`:o}getScoreClass(t){return 0===t.attempted?"":100===t.percentage?"score-perfect":0===t.percentage?"score-zero":""}calculateSummary(t){const n=t.attempted>0?Math.round(t.correct/t.attempted*100):0;return{serviceId:t.serviceId,name:t.name,attempted:t.attempted,correct:t.correct,percentage:n}}show(){this.open=!0}close(){this.open=!1}};nr.styles=lt` :host { display: contents; } - `,Hn([ye({type:Boolean,reflect:!0})],Un.prototype,"open",2),Hn([ye({type:Array})],Un.prototype,"students",2),Un=Hn([me("qd-scores-modal")],Un);var jn=Object.defineProperty,Fn=Object.getOwnPropertyDescriptor,Bn=(t,n,s,o)=>{for(var r,a=o>1?void 0:o?Fn(n,s):n,c=t.length-1;c>=0;c--)(r=t[c])&&(a=(o?r(n,s,a):r(a))||a);return o&&a&&jn(n,s,a),a};let Vn=class extends pe{constructor(){super(...arguments),this.students=[],this.showModal=!1,this.handleClose=()=>{this.dispatchEvent(new CustomEvent("close"))}}render(){return ne` + `,tr([cn({type:Boolean,reflect:!0})],nr.prototype,"open",2),tr([cn({type:Array})],nr.prototype,"students",2),nr=tr([on("qd-scores-modal")],nr);var sr=Object.defineProperty,rr=Object.getOwnPropertyDescriptor,or=(t,n,s,r)=>{for(var o,a=r>1?void 0:r?rr(n,s):n,d=t.length-1;d>=0;d--)(o=t[d])&&(a=(r?o(n,s,a):o(a))||a);return r&&a&&sr(n,s,a),a};let ir=class extends sn{constructor(){super(...arguments),this.students=[],this.showModal=!1,this.handleClose=()=>{this.dispatchEvent(new CustomEvent("close"))}}render(){return Qt` - `}};Vn.styles=Pn,Bn([ye({type:Array})],Vn.prototype,"students",2),Bn([ye({type:Boolean})],Vn.prototype,"showModal",2),Vn=Bn([me("qd-instructor-scores")],Vn);var Qn=Object.defineProperty,Kn=Object.getOwnPropertyDescriptor,Jn=(t,n,s,o)=>{for(var r,a=o>1?void 0:o?Kn(n,s):n,c=t.length-1;c>=0;c--)(r=t[c])&&(a=(o?r(n,s,a):r(a))||a);return o&&a&&Qn(n,s,a),a};let Wn=class extends pe{constructor(){super(...arguments),this.students=[],this.handleExport=()=>{const t=this.generateCSV(),n=new Blob([t],{type:"text/csv;charset=utf-8;"}),s=URL.createObjectURL(n),o=document.createElement("a");o.href=s;const r=(new Date).toISOString().replace(/[:.]/g,"-").slice(0,19);o.download=`quiz-data-${r}.csv`,document.body.appendChild(o),o.click(),document.body.removeChild(o),URL.revokeObjectURL(s)}}escapeCSVField(t){const n=String(t);return n.includes(",")||n.includes('"')||n.includes("\n")?`"${n.replace(/"/g,'""')}"`:n}generateCSV(){const t=[];t.push("Service ID,Name,Release,Page ID,Question Index,Answer,Success,Timestamp");for(const n of this.students)for(const[s,o]of Object.entries(n.pages)){(o.answers||[]).forEach((o,r)=>{o&&t.push([this.escapeCSVField(n.serviceId),this.escapeCSVField(n.name),this.escapeCSVField(n.release),this.escapeCSVField(s),this.escapeCSVField(r),this.escapeCSVField(o.answer),this.escapeCSVField(o.success),this.escapeCSVField(o.timestamp)].join(","))})}return t.join("\n")}render(){const t=this.students.length>0&&this.students.some(t=>t.attempted>0),n=t?`Export ${this.students.length} student${1===this.students.length?"":"s"} to CSV`:this.students.length>0?"No answers to export (students have not answered any questions)":"No data to export";return ne` + `}};ir.styles=Ws,or([cn({type:Array})],ir.prototype,"students",2),or([cn({type:Boolean})],ir.prototype,"showModal",2),ir=or([on("qd-instructor-scores")],ir);var ar=Object.defineProperty,dr=Object.getOwnPropertyDescriptor,cr=(t,n,s,r)=>{for(var o,a=r>1?void 0:r?dr(n,s):n,d=t.length-1;d>=0;d--)(o=t[d])&&(a=(r?o(n,s,a):o(a))||a);return r&&a&&ar(n,s,a),a};let lr=class extends sn{constructor(){super(...arguments),this.students=[],this.handleExport=()=>{const t=this.generateCSV(),n=new Blob([t],{type:"text/csv;charset=utf-8;"}),s=URL.createObjectURL(n),r=document.createElement("a");r.href=s;const o=(new Date).toISOString().replace(/[:.]/g,"-").slice(0,19);r.download=`quiz-data-${o}.csv`,document.body.appendChild(r),r.click(),document.body.removeChild(r),URL.revokeObjectURL(s)}}escapeCSVField(t){let n=String(t);return/^[=+\-@\t\r]/.test(n)&&(n=`'${n}`),n.includes(",")||n.includes('"')||n.includes("\n")?`"${n.replace(/"/g,'""')}"`:n}generateCSV(){const t=[];t.push("Service ID,Name,Release,Page ID,Question Index,Answer,Success,Timestamp,Kind,Attempts,First Attempt Correct");for(const n of this.students){const s=[n.serviceId,n.name,n.release];for(const[r,o]of Object.entries(n.pages)){(o.answers||[]).forEach((n,o)=>{n&&""!==n.answer.trim()&&t.push(this.csvRow([...s,r,o,n.answer,n.success,n.timestamp,"quiz",n.attempts??"",n.firstSuccess??""]))});const n=o.analysis;if(n)for(const[o,a]of Object.entries(n.cells))""!==a.trim()&&t.push(this.csvRow([...s,r,n.labels?.[o]??o,a,"",n.lastEdited??"","analysis","",""]))}}return t.join("\n")}csvRow(t){return t.map(t=>this.escapeCSVField(t)).join(",")}render(){const t=this.students.length>0&&this.students.some(t=>t.attempted>0||Object.values(t.pages).some(t=>ze(t.analysis)>0)),n=t?`Export ${this.students.length} student${1===this.students.length?"":"s"} to CSV`:this.students.length>0?"No answers to export (students have not answered any questions)":"No data to export";return Qt` - `}};Wn.styles=Pn,Jn([ye({type:Array})],Wn.prototype,"students",2),Wn=Jn([me("qd-instructor-export")],Wn);var Yn=Object.defineProperty,Gn=Object.getOwnPropertyDescriptor,Zn=(t,n,s,o)=>{for(var r,a=o>1?void 0:o?Gn(n,s):n,c=t.length-1;c>=0;c--)(r=t[c])&&(a=(o?r(n,s,a):r(a))||a);return o&&a&&Yn(n,s,a),a};let Xn=class extends pe{constructor(){super(...arguments),this.showConfirmDialog=!1,this.confirmText="",this.error="",this.success="",this.modalContainer=null,this.handleClearRequest=()=>{this.showConfirmDialog=!0,this.confirmText="",this.error="",this.success=""},this.handleCancelClear=()=>{this.showConfirmDialog=!1,this.confirmText="",this.error=""},this.handleConfirmInput=t=>{const n=t.target;this.confirmText=n.value},this.handleConfirmClear=()=>{if("DELETE ALL DATA"===this.confirmText)try{q(),E(this,"qd:data-cleared",{}),this.success="All quiz data cleared successfully",this.showConfirmDialog=!1,this.confirmText="",this.error="",setTimeout(()=>{this.success=""},3e3)}catch{this.error="Failed to clear data"}else this.error="Confirmation text does not match"}}disconnectedCallback(){super.disconnectedCallback(),this.removeModalFromBody()}updated(t){super.updated(t),t.has("showConfirmDialog")&&(this.showConfirmDialog?this.renderModalToBody():this.removeModalFromBody()),this.showConfirmDialog&&(t.has("confirmText")||t.has("error"))&&this.renderModalToBody()}renderModalToBody(){this.modalContainer||(this.modalContainer=document.createElement("div"),this.modalContainer.className="qd-manage-modal-container",document.body.appendChild(this.modalContainer)),ue(this.renderConfirmDialog(),this.modalContainer)}removeModalFromBody(){this.modalContainer&&(this.modalContainer.remove(),this.modalContainer=null)}render(){return ne` + `}};lr.styles=Ws,cr([cn({type:Array})],lr.prototype,"students",2),lr=cr([on("qd-instructor-export")],lr);var ur=Object.defineProperty,pr=Object.getOwnPropertyDescriptor,hr=(t,n,s,r)=>{for(var o,a=r>1?void 0:r?pr(n,s):n,d=t.length-1;d>=0;d--)(o=t[d])&&(a=(r?o(n,s,a):o(a))||a);return r&&a&&ur(n,s,a),a};let gr=class extends sn{constructor(){super(...arguments),this.showConfirmDialog=!1,this.confirmText="",this.password="",this.checking=!1,this.error="",this.success="",this.modalContainer=null,this.handleKeydown=t=>{"Escape"===t.key&&this.showConfirmDialog&&this.handleCancelClear()},this.handleClearRequest=()=>{this.showConfirmDialog=!0,this.confirmText="",this.password="",this.error="",this.success=""},this.handleCancelClear=()=>{this.showConfirmDialog=!1,this.confirmText="",this.password="",this.error=""},this.handleConfirmInput=t=>{const n=t.target;this.confirmText=n.value},this.handlePasswordInput=t=>{this.password=t.target.value,this.error=""},this.handleConfirmClear=async()=>{if("DELETE ALL DATA"!==this.confirmText)return void(this.error="Confirmation text does not match");this.checking=!0;const t=await ae(this.password);if(this.checking=!1,!t.ok)return this.error=de(t),void(this.password="");try{await Be().clearAll(),Y(),Ve(this,"qd:data-cleared",{}),this.success="All quiz data cleared successfully",this.showConfirmDialog=!1,this.confirmText="",this.password="",this.error="",setTimeout(()=>{this.success=""},3e3)}catch{this.error="Failed to clear data"}}}disconnectedCallback(){super.disconnectedCallback(),this.removeModalFromBody(),document.removeEventListener("keydown",this.handleKeydown)}updated(t){super.updated(t),t.has("showConfirmDialog")&&(this.showConfirmDialog?(this.renderModalToBody(),document.addEventListener("keydown",this.handleKeydown),this.modalContainer?.querySelector("input")?.focus()):(this.removeModalFromBody(),document.removeEventListener("keydown",this.handleKeydown))),this.showConfirmDialog&&(t.has("confirmText")||t.has("password")||t.has("checking")||t.has("error"))&&this.renderModalToBody()}renderModalToBody(){this.modalContainer||(this.modalContainer=document.createElement("div"),this.modalContainer.className="qd-manage-modal-container",document.body.appendChild(this.modalContainer)),tn(this.renderConfirmDialog(),this.modalContainer)}removeModalFromBody(){this.modalContainer&&(this.modalContainer.remove(),this.modalContainer=null)}render(){return Qt` - ${this.success?ne` + ${this.success?Qt`
                                  ${this.success}
                                  `:""} - `}renderConfirmDialog(){const t="DELETE ALL DATA"===this.confirmText;return ne` + `}renderConfirmDialog(){const t="DELETE ALL DATA"===this.confirmText&&this.password.length>0&&!this.checking;return Qt`
                                  {t.target===t.currentTarget&&this.handleCancelClear()}} >
                                  t.stopPropagation()} >
                                  -

                                  +

                                  Confirm Data Deletion

                                  - `}};Xn.styles=Pn,Zn([ve()],Xn.prototype,"showConfirmDialog",2),Zn([ve()],Xn.prototype,"confirmText",2),Zn([ve()],Xn.prototype,"error",2),Zn([ve()],Xn.prototype,"success",2),Xn=Zn([me("qd-instructor-manage")],Xn);var ts=Object.defineProperty,es=Object.getOwnPropertyDescriptor,ns=(t,n,s,o)=>{for(var r,a=o>1?void 0:o?es(n,s):n,c=t.length-1;c>=0;c--)(r=t[c])&&(a=(o?r(n,s,a):r(a))||a);return o&&a&&ts(n,s,a),a};let ss=class extends pe{constructor(){super(...arguments),this.students=[],this.open=!1,this.searchText="",this.confirmingStudent=null,this.confirmDialogOpen=!1,this.errorMessage="",this.handleModalClose=()=>{this.confirmDialogOpen||(this.close(),this.dispatchEvent(new CustomEvent("close")))},this.handleSearchInput=t=>{const n=t.target;this.searchText=n.value},this.handleResetClick=t=>{this.confirmingStudent=t,this.confirmDialogOpen=!0},this.handleConfirmReset=()=>{this.confirmingStudent&&this.executeReset(this.confirmingStudent)},this.handleCancelReset=()=>{this.confirmDialogOpen=!1,this.confirmingStudent=null}}set showModal(t){this.open=t}get showModal(){return this.open}get filteredStudents(){if(!this.searchText.trim())return this.students;const t=this.searchText.toLowerCase().trim();return this.students.filter(n=>n.name.toLowerCase().includes(t)||n.serviceId.toLowerCase().includes(t))}close(){this.open=!1,this.confirmingStudent=null,this.confirmDialogOpen=!1,this.searchText="",this.errorMessage=""}show(){this.open=!0}async executeReset(t){try{const s=document.getElementById(qe);if(!s?.textContent?.trim())throw new Error(`Database name not configured. Add dbName to page.`);const o=J(s.textContent.trim());await o.init();const r=(n=t,{...n,pinHash:"",pinResetAt:(new Date).toISOString()});await o.saveStudent(r);const a={eventId:crypto.randomUUID(),serviceId:t.serviceId,resetBy:"instructor",resetAt:(new Date).toISOString(),release:t.release};await o.saveAuditEvent(a);const c=this.students.findIndex(n=>n.serviceId===t.serviceId);c>=0&&(this.students[c]=r,this.students=[...this.students]),this.dispatchEvent(new CustomEvent("qd:pin-reset",{detail:{serviceId:t.serviceId,resetBy:"instructor",timestamp:(new Date).toISOString()},bubbles:!0,composed:!0})),this.confirmDialogOpen=!1,this.confirmingStudent=null,this.errorMessage=""}catch(s){console.error("PIN reset error:",s),this.errorMessage="Failed to reset PIN. Please try again.",this.confirmDialogOpen=!1,this.confirmingStudent=null}var n}render(){const t=this.confirmingStudent,n=t?`Reset PIN for ${t.name} (${t.serviceId})?
                                  They will need to create a new PIN on next login.`:"";return ne` - - Reset Student PIN + `}};async function mr(t){const n=x();if(!n)return{ok:!1,error:`Database name not configured. Add dbName to page.`};try{const s=_e(n);await s.init();const r=function(t){const{pinSalt:n,pinIterations:s,pinKdf:r,...o}=t;return{...o,pinHash:"",pinResetAt:(new Date).toISOString()}}(t);await s.saveStudent(r);const o={eventId:crypto.randomUUID(),serviceId:t.serviceId,resetBy:"instructor",resetAt:(new Date).toISOString(),release:t.release};return await s.saveAuditEvent(o),{ok:!0,updated:r}}catch(s){return console.error("PIN reset error:",s),{ok:!1,error:"Failed to reset PIN. Please try again."}}} +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */gr.styles=Ws,hr([ln()],gr.prototype,"showConfirmDialog",2),hr([ln()],gr.prototype,"confirmText",2),hr([ln()],gr.prototype,"password",2),hr([ln()],gr.prototype,"checking",2),hr([ln()],gr.prototype,"error",2),hr([ln()],gr.prototype,"success",2),gr=hr([on("qd-instructor-manage")],gr);const fr=2;class i{constructor(t){}get _$AU(){return this._$AM._$AU}_$AT(t,n,s){this._$Ct=t,this._$AM=n,this._$Ci=s}_$AS(t,n){return this.update(t,n)}update(t,n){return this.render(...n)}} +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */class e extends i{constructor(t){if(super(t),this.it=Wt,t.type!==fr)throw Error(this.constructor.directiveName+"() can only be used in child bindings")}render(t){if(t===Wt||null==t)return this._t=void 0,this.it=t;if(t===Kt)return t;if("string"!=typeof t)throw Error(this.constructor.directiveName+"() called with a non-string value");if(t===this.it)return this._t;this.it=t;const n=[t];return n.raw=n,this._t={_$litType$:this.constructor.resultType,strings:n,values:[]}}}e.directiveName="unsafeHTML",e.resultType=1;const br=(t=>(...n)=>({_$litDirective$:t,values:n}))(e);var yr=Object.defineProperty,vr=Object.getOwnPropertyDescriptor,wr=(t,n,s,r)=>{for(var o,a=r>1?void 0:r?vr(n,s):n,d=t.length-1;d>=0;d--)(o=t[d])&&(a=(r?o(n,s,a):o(a))||a);return r&&a&&yr(n,s,a),a};const xr="padding: 8px 0;",Sr="font-size: 14px; color: #333; line-height: 1.5; margin-bottom: 16px;",$r="display: block; font-size: 13px; color: #333; margin: 0 0 4px;",qr="width: 100%; box-sizing: border-box; padding: 8px 10px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px; margin-bottom: 16px;",Er="display: flex; gap: 8px; justify-content: flex-end;",kr="padding: 8px 16px; border: none; border-radius: 4px; font-size: 13px; font-weight: 500; cursor: pointer;",Cr="background: #e0e0e0; color: #333;",Ir="background: #0066cc; color: white;",Ar="background: #d32f2f; color: white;",Pr="background: #ccc; color: white; cursor: not-allowed;";let Or=class extends sn{constructor(){super(...arguments),this.open=!1,this.title="Confirm",this.message="",this.confirmText="Confirm",this.cancelText="Cancel",this.destructive=!1,this.requirePassword=!1,this.password="",this.handleModalClose=()=>{this.close(),this.dispatchEvent(new CustomEvent("qd:cancel",{bubbles:!0,composed:!0}))},this.handleConfirm=()=>{if(this.requirePassword&&!this.password)return;const t=this.password;this.password="",this.close(),this.dispatchEvent(new CustomEvent("qd:confirm",{detail:this.requirePassword?{password:t}:void 0,bubbles:!0,composed:!0}))},this.handlePasswordInput=t=>{this.password=t.target.value},this.handleCancel=()=>{this.password="",this.close(),this.dispatchEvent(new CustomEvent("qd:cancel",{bubbles:!0,composed:!0}))}}show(){this.open=!0}close(){this.open=!1}get confirmBlocked(){return this.requirePassword&&!this.password}render(){return Qt` + + ${this.title} - ${this.open?ne` -
                                  - - -
                                  - ${0===this.filteredStudents.length?ne`
                                  - ${this.searchText?"No matching students":"No students found"} -
                                  `:ne` - - - - - - - - - - ${this.filteredStudents.map(t=>ne` - - - - - - `)} - -
                                  NameService IDReset PIN
                                  ${t.name}${t.serviceId} - -
                                  - `} -
                                  +
                                  +
                                  ${br(this.message)}
                                  - ${this.errorMessage?ne`
                                  ${this.errorMessage}
                                  `:""} -
                                  - `:oe} + ${this.requirePassword?Qt` + "Enter"===t.key&&this.handleConfirm()} + style=${qr} + />`:Wt} + +
                                  + ${this.cancelText?Qt``:Wt} + +
                                  +
                                  - - - `}};ss.styles=wt` + `}};Or.styles=lt` :host { display: contents; } - - .pin-reset-content { - min-width: 400px; - max-width: 500px; + `,wr([cn({type:Boolean,reflect:!0})],Or.prototype,"open",2),wr([cn({type:String})],Or.prototype,"title",2),wr([cn({type:String})],Or.prototype,"message",2),wr([cn({type:String})],Or.prototype,"confirmText",2),wr([cn({type:String})],Or.prototype,"cancelText",2),wr([cn({type:Boolean})],Or.prototype,"destructive",2),wr([cn({type:Boolean})],Or.prototype,"requirePassword",2),wr([ln()],Or.prototype,"password",2),Or=wr([on("qd-confirm-dialog")],Or);var Tr=Object.defineProperty,Lr=Object.getOwnPropertyDescriptor,Nr=(t,n,s,r)=>{for(var o,a=r>1?void 0:r?Lr(n,s):n,d=t.length-1;d>=0;d--)(o=t[d])&&(a=(r?o(n,s,a):o(a))||a);return r&&a&&Tr(n,s,a),a};let Dr=class extends sn{constructor(){super(...arguments),this.students=[],this.actionLabel="Select",this.searchText="",this.handleSearchInput=t=>{this.searchText=t.target.value}}get filteredStudents(){const t=this.searchText.toLowerCase().trim();return t?this.students.filter(n=>n.name.toLowerCase().includes(t)||n.serviceId.toLowerCase().includes(t)):this.students}emitSelect(t){this.dispatchEvent(new CustomEvent("select",{detail:t,bubbles:!0,composed:!0}))}render(){const t=this.filteredStudents;return Qt` + +
                                  + ${0===t.length?Qt`
                                  + ${this.searchText?"No matching students":"No students found"} +
                                  `:Qt` + + + + + + + + + ${t.map(t=>Qt` + + + + `)} + +
                                  NameService ID${this.actionLabel}
                                  ${t.name}${t.serviceId} + +
                                  `} +
                                  + `}};Dr.styles=lt` + :host { + display: block; } .search-input { @@ -1720,7 +1869,7 @@ const tn=2;class i{constructor(t){}get _$AU(){return this._$AM._$AU}_$AT(t,n,s){ border-bottom: none; } - .reset-btn { + .action-btn { background: #ff5722; color: white; border: none; @@ -1730,7 +1879,7 @@ const tn=2;class i{constructor(t){}get _$AU(){return this._$AM._$AU}_$AT(t,n,s){ cursor: pointer; } - .reset-btn:hover { + .action-btn:hover { background: #e64a19; } @@ -1740,6 +1889,52 @@ const tn=2;class i{constructor(t){}get _$AU(){return this._$AM._$AU}_$AT(t,n,s){ color: #666; font-size: 12px; } + `,Nr([cn({type:Array})],Dr.prototype,"students",2),Nr([cn({type:String})],Dr.prototype,"actionLabel",2),Nr([ln()],Dr.prototype,"searchText",2),Dr=Nr([on("qd-student-table")],Dr);var _r=Object.defineProperty,Rr=Object.getOwnPropertyDescriptor,zr=(t,n,s,r)=>{for(var o,a=r>1?void 0:r?Rr(n,s):n,d=t.length-1;d>=0;d--)(o=t[d])&&(a=(r?o(n,s,a):o(a))||a);return r&&a&&_r(n,s,a),a};let Mr=class extends sn{constructor(){super(...arguments),this.students=[],this.open=!1,this.confirmingStudent=null,this.confirmDialogOpen=!1,this.errorMessage="",this.handleModalClose=()=>{this.confirmDialogOpen||(this.close(),this.dispatchEvent(new CustomEvent("close")))},this.handleResetClick=t=>{this.confirmingStudent=t,this.confirmDialogOpen=!0},this.handleConfirmReset=t=>{const n=this.confirmingStudent;n&&this.verifyThenReset(n,t.detail?.password??"")},this.handleCancelReset=()=>{this.confirmDialogOpen=!1,this.confirmingStudent=null}}set showModal(t){this.open=t}get showModal(){return this.open}close(){this.open=!1,this.confirmingStudent=null,this.confirmDialogOpen=!1,this.errorMessage=""}show(){this.open=!0}async verifyThenReset(t,n){const s=await ae(n);if(!s.ok)return this.confirmDialogOpen=!1,this.confirmingStudent=null,void(this.errorMessage=`${de(s)}. The PIN was not reset.`);await this.executeReset(t)}async executeReset(t){const n=await mr(t);if(this.confirmDialogOpen=!1,this.confirmingStudent=null,n.ok){if(n.updated){const s=this.students.findIndex(n=>n.serviceId===t.serviceId);s>=0&&(this.students[s]=n.updated,this.students=[...this.students])}this.errorMessage="",this.dispatchEvent(new CustomEvent("qd:pin-reset",{detail:{serviceId:t.serviceId,resetBy:"instructor",timestamp:(new Date).toISOString()},bubbles:!0,composed:!0}))}else this.errorMessage=n.error??"Failed to reset PIN. Please try again."}render(){const t=this.confirmingStudent,n=t?`Reset PIN for ${Q(t.name)} (${Q(t.serviceId)})?
                                  They will need to create a new PIN on next login.`:"";return Qt` + + Reset Student PIN + + ${this.open?Qt` +
                                  + this.handleResetClick(t.detail)} + > + + ${this.errorMessage?Qt``:""} +
                                  + `:Wt} +
                                  + + + `}};Mr.styles=lt` + :host { + display: contents; + } + + .pin-reset-content { + min-width: 400px; + max-width: 500px; + } .error-message { color: #d32f2f; @@ -1749,7 +1944,7 @@ const tn=2;class i{constructor(t){}get _$AU(){return this._$AM._$AU}_$AT(t,n,s){ background: #ffebee; border-radius: 4px; } - `,ns([ye({type:Array})],ss.prototype,"students",2),ns([ye({type:Boolean,reflect:!0})],ss.prototype,"open",2),ns([ve()],ss.prototype,"searchText",2),ns([ve()],ss.prototype,"confirmingStudent",2),ns([ve()],ss.prototype,"confirmDialogOpen",2),ns([ve()],ss.prototype,"errorMessage",2),ns([ye({type:Boolean})],ss.prototype,"showModal",1),ss=ns([me("qd-pin-reset-dialog")],ss);var os=Object.defineProperty,rs=Object.getOwnPropertyDescriptor,is=(t,n,s,o)=>{for(var r,a=o>1?void 0:o?rs(n,s):n,c=t.length-1;c>=0;c--)(r=t[c])&&(a=(o?r(n,s,a):r(a))||a);return o&&a&&os(n,s,a),a};let as=class extends pe{constructor(){super(...arguments),this.unlocked=!1,this.showScores=!1,this.students=[],this.showStudentAnswers=!1,this.showPinReset=!1,this.helpOpen=!1,this.handleLoginEvent=t=>{const n=t,s=n.detail?.role;this.updateVisibility(),"instructor"===s&&(this.unlock(),this.loadStudents())},this.handleLogoutEvent=()=>{this.updateVisibility(),this.lock()},this.handleResetPins=async()=>{const t=C(u.SESSION);if(t){try{const n=Z(),s=await n.getStudentsByRelease(t.release);this.students=s}catch(n){console.error("Failed to load students:",n),this.students=[]}this.showPinReset=!0}},this.handleClosePinReset=()=>{this.showPinReset=!1},this.handlePinReset=()=>{this.dispatchEvent(new CustomEvent("qd:pin-reset",{bubbles:!0,composed:!0}))},this.handleUnlock=()=>{this.unlocked=!0,this.dispatchEvent(new CustomEvent("qd:instructor-unlock",{bubbles:!0,composed:!0}))},this.handleViewScores=async()=>{const t=C(u.SESSION);if(t){try{const n=Z(),s=await n.getStudentsByRelease(t.release);this.students=s}catch(n){console.error("Failed to load students:",n),this.students=[]}this.showScores=!0}},this.handleCloseScores=()=>{this.showScores=!1},this.handleDataCleared=()=>{this.dispatchEvent(new CustomEvent("qd:data-cleared",{bubbles:!0,composed:!0})),this.students=[]},this.handleLogout=()=>{const t=C(u.SESSION);(new SessionService).clearSession(),this.dispatchEvent(new CustomEvent("qd:logout",{detail:{serviceId:t?.serviceId||"unknown"},bubbles:!0,composed:!0}))},this.handleToggleStudentAnswers=async t=>{const n=t.target;if(this.showStudentAnswers=n.checked,this.showStudentAnswers&&0===this.students.length){const t=C(u.SESSION);if(t)try{const n=Z(),s=await n.getStudentsByRelease(t.release);this.students=s}catch(o){console.error("Failed to load students for toggle:",o)}}const s=this.showStudentAnswers?"qd:instructor-show-answers":"qd:instructor-hide-answers";this.dispatchEvent(new CustomEvent(s,{bubbles:!0,composed:!0})),sessionStorage.setItem("qd/instructor/showAnswers",String(this.showStudentAnswers))},this.handleHelpOpen=()=>{this.helpOpen=!0},this.handleHelpClose=()=>{this.helpOpen=!1}}connectedCallback(){super.connectedCallback(),this.updateVisibility();const t="true"===sessionStorage.getItem(u.INSTRUCTOR);t&&(this.unlock(),this.loadStudents());const n=sessionStorage.getItem("qd/instructor/showAnswers");null!==n&&(this.showStudentAnswers="true"===n,this.showStudentAnswers&&t&&setTimeout(()=>{this.dispatchEvent(new CustomEvent("qd:instructor-show-answers",{bubbles:!0,composed:!0}))},100)),document.addEventListener("qd:login",this.handleLoginEvent),document.addEventListener("qd:logout",this.handleLogoutEvent)}disconnectedCallback(){super.disconnectedCallback(),document.removeEventListener("qd:login",this.handleLoginEvent),document.removeEventListener("qd:logout",this.handleLogoutEvent)}updateVisibility(){"true"===sessionStorage.getItem(u.INSTRUCTOR)?this.setAttribute("data-show",""):this.removeAttribute("data-show")}setStudents(t){this.students=t}async loadStudents(){const t=C(u.SESSION);if(t)try{const n=Z(),s=await n.getStudentsByRelease(t.release);this.students=s}catch(n){console.error("Failed to load students:",n),this.students=[]}}unlock(){this.unlocked=!0}lock(){this.unlocked=!1,this.showScores=!1,this.showPinReset=!1}render(){return this.unlocked?ne` + `,zr([cn({type:Array})],Mr.prototype,"students",2),zr([cn({type:Boolean,reflect:!0})],Mr.prototype,"open",2),zr([ln()],Mr.prototype,"confirmingStudent",2),zr([ln()],Mr.prototype,"confirmDialogOpen",2),zr([ln()],Mr.prototype,"errorMessage",2),zr([cn({type:Boolean})],Mr.prototype,"showModal",1),Mr=zr([on("qd-pin-reset-dialog")],Mr);var Ur=Object.defineProperty,jr=Object.getOwnPropertyDescriptor,Hr=(t,n,s,r)=>{for(var o,a=r>1?void 0:r?jr(n,s):n,d=t.length-1;d>=0;d--)(o=t[d])&&(a=(r?o(n,s,a):o(a))||a);return r&&a&&Ur(n,s,a),a};let Br=class extends sn{constructor(){super(...arguments),this.unlocked=!1,this.showScores=!1,this.students=[],this.showStudentAnswers=!1,this.showPinReset=!1,this.helpOpen=!1,this.handleLoginEvent=t=>{const n=t,s=n.detail?.role;this.updateVisibility(),"instructor"===s&&(this.unlock(),this.refreshStudents())},this.handleLogoutEvent=()=>{this.updateVisibility(),this.lock()},this.handleResetPins=async()=>{await this.refreshStudents(),this.showPinReset=!0},this.handleClosePinReset=()=>{this.showPinReset=!1},this.handlePinReset=()=>{this.dispatchEvent(new CustomEvent("qd:pin-reset",{bubbles:!0,composed:!0}))},this.handleUnlock=()=>{this.unlocked=!0,this.dispatchEvent(new CustomEvent("qd:instructor-unlock",{bubbles:!0,composed:!0}))},this.handleViewScores=async()=>{await this.refreshStudents(),this.showScores=!0},this.handleCloseScores=()=>{this.showScores=!1},this.handleDataCleared=()=>{this.dispatchEvent(new CustomEvent("qd:data-cleared",{bubbles:!0,composed:!0})),this.students=[]},this.handleLogout=()=>{const t=W(G.SESSION);(new SessionService).clearSession(),this.dispatchEvent(new CustomEvent("qd:logout",{detail:{serviceId:t?.serviceId||"unknown"},bubbles:!0,composed:!0}))},this.handleToggleStudentAnswers=async t=>{const n=t.target;this.showStudentAnswers=n.checked,this.showStudentAnswers&&0===this.students.length&&await this.refreshStudents();const s=this.showStudentAnswers?"qd:instructor-show-answers":"qd:instructor-hide-answers";this.dispatchEvent(new CustomEvent(s,{bubbles:!0,composed:!0})),sessionStorage.setItem(K,String(this.showStudentAnswers))},this.handleHelpOpen=()=>{this.helpOpen=!0},this.handleHelpClose=()=>{this.helpOpen=!1}}connectedCallback(){super.connectedCallback(),this.updateVisibility();const t=ue();t&&(this.unlock(),this.refreshStudents());const n=sessionStorage.getItem(K);null!==n&&(this.showStudentAnswers="true"===n,this.showStudentAnswers&&t&&setTimeout(()=>{this.dispatchEvent(new CustomEvent("qd:instructor-show-answers",{bubbles:!0,composed:!0}))},100)),document.addEventListener("qd:login",this.handleLoginEvent),document.addEventListener("qd:logout",this.handleLogoutEvent)}disconnectedCallback(){super.disconnectedCallback(),document.removeEventListener("qd:login",this.handleLoginEvent),document.removeEventListener("qd:logout",this.handleLogoutEvent)}updateVisibility(){this.toggleAttribute("data-show",ue())}setStudents(t){this.students=t}async refreshStudents(){const t=W(G.SESSION);if(t)try{const n=Be();this.students=await n.getStudentsByRelease(t.release)}catch(n){console.error("Failed to load students:",n),this.students=[]}}unlock(){this.unlocked=!0}lock(){this.unlocked=!1,this.showScores=!1,this.showPinReset=!1}render(){return this.unlocked?Qt`
                                  Instructor Mode @@ -1771,11 +1966,12 @@ const tn=2;class i{constructor(t){}get _$AU(){return this._$AM._$AU}_$AT(t,n,s){ - - - + + + + @@ -1794,14 +1990,14 @@ const tn=2;class i{constructor(t){}get _$AU(){return this._$AM._$AU}_$AT(t,n,s){
                                  - `:ne` + `:Qt` - `}};as.styles=[Pn,wt` + `}};Br.styles=[Ws,lt` :host { display: none; /* Hidden by default, shown when instructor logged in */ } @@ -1809,5 +2005,5 @@ const tn=2;class i{constructor(t){}get _$AU(){return this._$AM._$AU}_$AT(t,n,s){ :host([data-show]) { display: block; } - `],is([ve()],as.prototype,"unlocked",2),is([ve()],as.prototype,"showScores",2),is([ve()],as.prototype,"students",2),is([ve()],as.prototype,"showStudentAnswers",2),is([ve()],as.prototype,"showPinReset",2),is([ve()],as.prototype,"helpOpen",2),as=is([me("qd-instructor")],as);const cs={statusPanel:".wh_top_menu_and_indexterms_link"};function ds(t={}){const n=t.statusPanelContainer||cs.statusPanel;!function(t){const n=document.querySelector(t);if(!n)return null;const s=document.createElement("qd-login");n.appendChild(s)}(n),function(t){const n=document.querySelector(t);if(!n)return null;const s=document.createElement("qd-status");n.appendChild(s)}(n),function(t){const n=document.querySelector(t);if(!n)return null;const s=document.createElement("qd-instructor");n.appendChild(s)}(n)}const ls={red:"qd-badge-red",amber:"qd-badge-amber",green:"qd-badge-green"},us={unstarted:"red",incomplete:"amber",complete:"green"};function hs(t){const n=function(t,n){if(!t||!n?.pages)return"unstarted";const s=n.pages[t];return s?.state??"unstarted"}(t.getAttribute("data-page-id"),C(u.CACHE));!function(t,n){Object.values(ls).forEach(n=>{t.classList.remove(n)});const s=ls[us[n]];t.classList.add(s)}(t,n)}function ps(){const t=document.querySelectorAll(".quizPageBtn"),n=C(u.CACHE),s="true"===sessionStorage.getItem(u.INSTRUCTOR);if(!n||s)return t.forEach(t=>{Object.values(ls).forEach(n=>{t.classList.remove(n)})}),void t.length;t.forEach(t=>{hs(t)}),t.length}function gs(t){const n=t,{pageId:s}=n.detail,o=document.querySelector(`[data-page-id="${s}"]`);o&&o.classList.contains("quizPageBtn")&&hs(o)}function ms(){ps()}function fs(){const t=document.querySelectorAll(".quizPageBtn");t.forEach(t=>{Object.values(ls).forEach(n=>{t.classList.remove(n)})}),t.length}const bs={initialized:!1};async function ys(t={}){if(bs.initialized)return void a("Bootstrap already initialized, skipping");if(function(){if(document.getElementById("qd-global-styles"))return;const t=document.createElement("style");t.id="qd-global-styles",t.textContent="\n /* Sonar Quiz System - Global Styles */\n .qd-hidden {\n display: none !important;\n }\n\n /* Quiz table interactive mode styles */\n .qd-quiz-interactive .qd-quiz-input {\n width: 100%;\n padding: 0.5rem;\n font-size: inherit;\n border: 1px solid #ccc;\n border-radius: 4px;\n }\n\n /* Ensure select elements inherit font properly */\n .qd-quiz-interactive select.qd-quiz-input {\n font-family: inherit;\n font-size: inherit;\n }\n\n /* Validation styling for answer cells */\n .qd-quiz-interactive .qd-answer-correct {\n background-color: #d4edda !important;\n border-color: #28a745 !important;\n }\n\n .qd-quiz-interactive .qd-answer-incorrect {\n background-color: #f8d7da !important;\n border-color: #dc3545 !important;\n }\n\n /* Home page badge styles (R/A/G indicators) */\n .qd-badge-red {\n border-left: 4px solid #d32f2f !important;\n background-color: #ffebee !important;\n }\n\n .qd-badge-amber {\n border-left: 4px solid #ff9800 !important;\n background-color: #fff3e0 !important;\n }\n\n .qd-badge-green {\n border-left: 4px solid #4caf50 !important;\n background-color: #e8f5e9 !important;\n }\n\n /* Instructor mode: Student answers display */\n .qd-student-answers {\n margin-top: 12px;\n padding: 8px;\n background: #f8f9fa;\n border-radius: 4px;\n border: 1px solid #dee2e6;\n }\n\n .qd-student-answer {\n font-size: 12px;\n padding: 4px 0;\n line-height: 1.4;\n }\n\n .qd-student-answer.qd-correct {\n color: #28a745;\n }\n\n .qd-student-answer.qd-incorrect {\n color: #dc3545;\n }\n\n .qd-student-name {\n font-weight: 600;\n }\n\n .qd-student-answer-text {\n margin: 0 4px;\n }\n\n .qd-timestamp {\n color: #6c757d;\n font-size: 11px;\n margin-left: 8px;\n }\n\n /* Modal error message styles (needed because qd-modal moves to body) */\n .error-message {\n color: #d32f2f;\n font-size: 12px;\n padding: 8px;\n background: #ffebee;\n border-radius: 4px;\n border-left: 3px solid #d32f2f;\n }\n ",document.head.appendChild(t)}(),!t.dbName){const t="FATAL: dbName not provided in bootstrap config. Processing stopped.";throw console.error(t),new Error(t)}const n=Z(t.dbName);await n.init();const s=new EventCoordinator;s.initialize(),bs.eventCoordinator=s;const o=new SessionCoordinator;o.initialize(),bs.sessionCoordinator=o,ds({statusPanelContainer:t.statusPanelContainer,dbName:t.dbName}),!1!==t.autoEnhanceQuizTables&&function(){const t=document.querySelectorAll("table.qd-quiz");if(0===t.length)return;t.length;for(const s of Array.from(t))try{tt(s,{interactive:!1})}catch(n){a(`Failed to enhance quiz table: ${n.message}`)}t.length}(),!1!==t.autoEnhanceAnalysisTables&&function(){const t=document.querySelectorAll("table.qd-analysis");if(0===t.length)return;t.length;for(const s of Array.from(t))try{pt(s,{interactive:!1})}catch(n){a(`Failed to enhance analysis table: ${n.message}`)}t.length}(),!1!==t.autoEnhanceHomeBadges&&function(){const t=document.querySelectorAll(".quizPageBtn");if(0===t.length)return;t.length;try{document.querySelectorAll(".quizPageBtn").forEach(t=>{const n=function(t){const n=t.getAttribute("href");return n&&n.substring(n.lastIndexOf("/")+1).replace(/\.html?$/i,"")||null}(t);n?(t.setAttribute("data-page-id",n),t.textContent?.trim()):t.getAttribute("href")}),ps(),document.addEventListener("qd:state-changed",gs),document.addEventListener("qd:cache-rebuild",ms),document.addEventListener("qd:logout",fs)}catch(n){a(`Failed to enhance home badges: ${n.message}`)}}(),await async function(){if("true"===sessionStorage.getItem(u.INSTRUCTOR))return void vs();const t=C(u.SESSION);if(!t)return;t.serviceId;const n=Z();let s=C(u.CACHE);if(!s)try{const o=await n.loadStudentRecord(t);s=n.buildCache(o),$(u.CACHE,s),s.totals.total}catch{a("Failed to rebuild cache from IndexedDB, using empty cache"),s={totals:{total:0,answered:0,correct:0},pages:{}},$(u.CACHE,s)}const o=window.location.pathname,r=o.substring(o.lastIndexOf("/")+1).replace(/\.html?$/i,"");if(!r)return;const c=document.querySelectorAll("table.qd-quiz");c.length>0&&(c.length,c.forEach(t=>{tt(t,{interactive:!0,pageId:r})}));const d=document.querySelectorAll("table.qd-analysis");d.length>0&&(d.length,d.forEach(t=>{pt(t,{interactive:!0,pageId:r})}))}(),document.addEventListener("qd:login",t=>{const n=t.detail;"instructor"===n?.role&&vs()}),bs.initialized=!0}function vs(){const t=window.location.pathname,n=t.substring(t.lastIndexOf("/")+1).replace(/\.html?$/i,""),s=document.querySelectorAll("table.qd-quiz");0!==s.length&&(s.forEach(t=>{const s=ot(t);if(!s)return;s.pageId=n,t.classList.add("qd-quiz-instructor");t.querySelectorAll("td:nth-child(2), th:nth-child(2)").forEach(t=>{t.classList.remove("qd-hidden")});t.querySelectorAll("tbody td:nth-child(2)").forEach((t,n)=>{const o=s.parsed.questions[n];o&&t instanceof HTMLTableCellElement&&(t.textContent=o.correctAnswer)});t.querySelectorAll("td:nth-child(3), th:nth-child(3)").forEach(t=>t.classList.remove("qd-hidden"));const o=()=>{rt(t,s)};document.addEventListener("qd:instructor-show-answers",o),document.addEventListener("qd:instructor-hide-answers",()=>{it(t)});"true"===sessionStorage.getItem("qd/instructor/showAnswers")&&o()}),s.length)}if("undefined"!=typeof window){const t=()=>{const t=ke();ys({dbName:t.dbName,statusPanelContainer:t.statusPanelContainer,autoEnhanceQuizTables:!0,autoEnhanceAnalysisTables:!0,autoEnhanceHomeBadges:!0}).catch(t=>{console.error("[FATAL] Bootstrap failed:",t)})};"loading"===document.readyState?document.addEventListener("DOMContentLoaded",()=>{t()}):t()}return t.BUILD_DATE="28/Nov/2025",t.DEFAULT_CONTAINERS=cs,t.Debouncer=Debouncer,t.OBFUSCATION_PREFIX=O,t.SCHEMA_VERSION=2,t.SESSION_TIMEOUT_MS=l,t.STORAGE_KEYS=u,t.VERSION="0.1.0-phase3.1",t.bootstrap=ys,t.calculateCompletionState=W,t.cleanup=function(){bs.initialized?(bs.eventCoordinator?.cleanup(),bs.sessionCoordinator?.cleanup(),bs.initialized=!1,bs.eventCoordinator=void 0,bs.sessionCoordinator=void 0):a("Bootstrap not initialized, nothing to cleanup")},t.clearQuizData=q,t.decode=U,t.deriveKey=T,t.encode=D,t.enhanceAnalysisTable=pt,t.enhanceQuizTable=tt,t.error=r,t.generateCellKey=dt,t.generateTableId=ct,t.getAnalysisTableMetadata=function(t){return ht.get(t)},t.getJSON=C,t.getQuizTableMetadata=ot,t.info=o,t.injectComponents=ds,t.isAnalysisTableEnhanced=function(t){return ht.has(t)},t.isCellEditable=lt,t.isInitialized=function(){return bs.initialized},t.isObfuscated=j,t.isQuizTableEnhanced=function(t){return X.has(t)},t.migrateObfuscation=fn,t.parseAnalysisTable=ut,t.parseQuizTable=c,t.setJSON=$,t.validateAnswer=d,t.warn=a,Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),t}({}); + `],Hr([ln()],Br.prototype,"unlocked",2),Hr([ln()],Br.prototype,"showScores",2),Hr([ln()],Br.prototype,"students",2),Hr([ln()],Br.prototype,"showStudentAnswers",2),Hr([ln()],Br.prototype,"showPinReset",2),Hr([ln()],Br.prototype,"helpOpen",2),Br=Hr([on("qd-instructor")],Br);const Fr={statusPanel:".wh_top_menu_and_indexterms_link"};function Vr(t={}){const n=t.statusPanelContainer||Fr.statusPanel;!function(t){const n=document.querySelector(t);if(!n)return o(`Login component not injected: container '${t}' not found`),null;const s=document.createElement("qd-login");n.appendChild(s),o("Login component injected")}(n),function(t){const n=document.querySelector(t);if(!n)return o(`Status component not injected: container '${t}' not found`),null;const s=document.createElement("qd-status");n.appendChild(s),o("Status component injected")}(n),function(t){const n=document.querySelector(t);if(!n)return o(`Instructor component not injected: container '${t}' not found`),null;const s=document.createElement("qd-instructor");n.appendChild(s),o("Instructor component injected")}(n),function(t){const n=document.querySelector(t);if(!n)return;n.classList.add(Qr),n.parentElement?.classList.add(Kr)}(n)}const Qr="qd-panel-host",Kr="qd-panel-host-parent";const Wr={red:"qd-badge-red",amber:"qd-badge-amber",green:"qd-badge-green"},Jr={unstarted:"red",incomplete:"amber",complete:"green"};function Yr(t){Object.values(Wr).forEach(n=>{t.classList.remove(n)})}function Xr(t){const n=function(t,n){if(!t||!n?.pages)return"unstarted";const s=n.pages[t];return s?.state??"unstarted"}(t.getAttribute("data-page-id"),W(G.CACHE));!function(t,n){Yr(t);const s=Wr[Jr[n]];t.classList.add(s)}(t,n)}function Gr(){const t=document.querySelectorAll(".quizPageBtn"),n=W(G.CACHE),s=ue();if(!n||s)return t.forEach(t=>{Yr(t)}),void o(s?`Removed badge styling from ${t.length} page links (instructor mode)`:`Removed badge styling from ${t.length} page links (no session)`);t.forEach(t=>{Xr(t)}),o(`Updated ${t.length} page badges`)}function Zr(t){const n=t,{pageId:s}=n.detail,r=document.querySelector(`[data-page-id="${s}"]`);r&&r.classList.contains("quizPageBtn")&&(Xr(r),o(`Updated badge for page ${s}`))}function eo(){o("Cache rebuilt, refreshing all badges"),Gr()}function to(){o("Logout detected, removing all badge styling");const t=document.querySelectorAll(".quizPageBtn");t.forEach(t=>{Yr(t)}),o(`Removed badge styling from ${t.length} page links`)}const no={initialized:!1};async function so(t={}){if(no.initialized)return void d("Bootstrap already initialized, skipping");if(o("Bootstrapping Sonar Quiz System..."),function(){if(document.getElementById("qd-global-styles"))return;const t=document.createElement("style");t.id="qd-global-styles",t.textContent="\n /* Sonar Quiz System - Global Styles */\n .qd-hidden {\n display: none !important;\n }\n\n /* Quiz table interactive mode styles */\n .qd-quiz-interactive .qd-quiz-input {\n width: 100%;\n padding: 0.5rem;\n font-size: inherit;\n border: 1px solid #ccc;\n border-radius: 4px;\n }\n\n /* Ensure select elements inherit font properly */\n .qd-quiz-interactive select.qd-quiz-input {\n font-family: inherit;\n font-size: inherit;\n }\n\n /* Visually hidden text that screen readers still announce (SRD 013 A11Y-02) */\n .qd-sr-only {\n position: absolute !important;\n width: 1px;\n height: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n white-space: nowrap;\n border: 0;\n }\n\n /* Validation styling for answer cells */\n .qd-quiz-interactive .qd-answer-correct {\n background-color: #d4edda !important;\n border-color: #28a745 !important;\n }\n\n .qd-quiz-interactive .qd-answer-incorrect {\n background-color: #f8d7da !important;\n border-color: #dc3545 !important;\n }\n\n /* Non-colour correctness cue: a mark plus hidden text (SRD 013 A11Y-02) */\n .qd-answer-feedback {\n display: inline-block;\n margin-top: 4px;\n font-size: 0.9em;\n font-weight: 600;\n }\n\n .qd-answer-correct .qd-answer-feedback {\n color: #1e7b34;\n }\n\n .qd-answer-incorrect .qd-answer-feedback {\n color: #b02a37;\n }\n\n /* Invalid numeric input message (SRD 013 UX-03) */\n .qd-input-error {\n display: block;\n margin-top: 4px;\n font-size: 0.85em;\n color: #b02a37;\n }\n\n .qd-quiz-interactive .qd-quiz-input[aria-invalid='true'] {\n border-color: #b02a37;\n outline-color: #b02a37;\n }\n\n /* Prompt shown above quiz tables while nobody is logged in (SRD 013 UX-05) */\n .qd-login-notice {\n margin: 0 0 8px;\n padding: 6px 10px;\n border-left: 3px solid #1d6fb8;\n background: #e8f1fb;\n color: #0b4f8a;\n font-size: 0.9em;\n }\n\n /*\n * Narrow screens: give the header's panel container its own full-width row\n * so the login and status panels stay on screen (SRD 013 UX-06)\n */\n /* !important: Oxygen's c-no-wrap and Bootstrap's navbar-collapse rules\n outrank a single class; these only touch elements we marked */\n @media (max-width: 1000px) {\n .qd-panel-host-parent {\n flex-wrap: wrap !important;\n }\n\n .qd-panel-host-parent > .qd-panel-host {\n flex: 1 1 100% !important;\n max-width: 100% !important;\n justify-content: flex-end;\n }\n\n .qd-panel-host qd-login,\n .qd-panel-host qd-status,\n .qd-panel-host qd-instructor {\n max-width: 100%;\n }\n }\n\n /* Home page badge styles (R/A/G indicators) */\n .qd-badge-red {\n border-left: 4px solid #d32f2f !important;\n background-color: #ffebee !important;\n }\n\n .qd-badge-amber {\n border-left: 4px solid #ff9800 !important;\n background-color: #fff3e0 !important;\n }\n\n .qd-badge-green {\n border-left: 4px solid #4caf50 !important;\n background-color: #e8f5e9 !important;\n }\n\n /* Instructor-mode student answers/entries are now rendered by the\n Shadow-DOM / components, which\n own their styles. No global rules are needed here. */\n\n /* Modal error message styles (needed because qd-modal moves to body) */\n .error-message {\n color: #d32f2f;\n font-size: 12px;\n padding: 8px;\n background: #ffebee;\n border-radius: 4px;\n border-left: 3px solid #d32f2f;\n }\n ",document.head.appendChild(t),o("Global styles injected")}(),!t.dbName){const t="FATAL: dbName not provided in bootstrap config. Processing stopped.";throw console.error(t),new Error(t)}const n=Be(t.dbName);await n.init(),await async function(){oe=!1;const t=sessionStorage.getItem(G.INSTRUCTOR);if(null===t)return!1;if(await re(t))return oe=!0,!0;sessionStorage.removeItem(G.INSTRUCTOR);try{const t=JSON.parse(sessionStorage.getItem(G.SESSION)??"null");"INSTRUCTOR"===t?.serviceId&&sessionStorage.removeItem(G.SESSION)}catch{}return!1}();const s=new EventCoordinator;s.initialize(),no.eventCoordinator=s;const r=new SessionCoordinator;r.initialize(),no.sessionCoordinator=r,Vr({statusPanelContainer:t.statusPanelContainer,dbName:t.dbName}),!1!==t.autoEnhanceQuizTables&&ro("table.qd-quiz","quiz",t=>vn(t,{interactive:!1})),!1!==t.autoEnhanceAnalysisTables&&ro("table.qd-analysis","analysis",t=>Nn(t,{interactive:!1})),!1!==t.autoEnhanceHomeBadges&&function(){const t=document.querySelectorAll(".quizPageBtn");if(0===t.length)return void o("No .quizPageBtn links found, skipping badge enhancement");o(`Enhancing home page badges for ${t.length} link(s)...`);try{document.querySelectorAll(".quizPageBtn").forEach(t=>{const n=function(t){const n=t.getAttribute("href");return n&&n.substring(n.lastIndexOf("/")+1).replace(/\.html?$/i,"")||null}(t);n?(t.setAttribute("data-page-id",n),o(`Set data-page-id="${n}" for link: ${t.textContent?.trim()}`)):o(`Failed to extract pageId from href: ${t.getAttribute("href")}`)}),Gr(),document.addEventListener("qd:state-changed",Zr),document.addEventListener("qd:cache-rebuild",eo),document.addEventListener("qd:logout",to),o("Home page badges enhanced with event listeners"),o("Home page badges enhanced")}catch(n){d(`Failed to enhance home badges: ${n.message}`)}}(),await async function(){if(ue())return o("Instructor session detected, revealing answers in non-interactive tables"),void oo();const t=W(G.SESSION);if(!t)return void o("No existing session, tables remain in non-interactive mode");o(`Existing session detected for ${t.serviceId}, upgrading tables to interactive mode`);const n=Be();let s=W(G.CACHE);if(!s){o("Cache not found, rebuilding from IndexedDB...");try{const r=await n.loadStudentRecord(t);s=n.buildCache(r),J(G.CACHE,s),o(`Cache rebuilt from IndexedDB: ${s.totals.total} total questions`)}catch{d("Failed to rebuild cache from IndexedDB, using empty cache"),s={totals:{total:0,answered:0,correct:0},pages:{}},J(G.CACHE,s)}}const r=_n();if(!r)return void o("No pageId found, skipping table upgrade");const a=document.querySelectorAll("table.qd-quiz");a.length>0&&(o(`Upgrading ${a.length} quiz table(s) to interactive mode...`),a.forEach(t=>{vn(t,{interactive:!0,pageId:r})}));const c=document.querySelectorAll("table.qd-analysis");c.length>0&&(o(`Upgrading ${c.length} analysis table(s) to interactive mode...`),c.forEach(t=>{Nn(t,{interactive:!0,pageId:r})}))}(),document.addEventListener("qd:login",t=>{const n=t.detail;"instructor"===n?.role&&(o("Instructor login event received, revealing quiz answers"),oo())}),no.initialized=!0,o("Bootstrap complete")}function ro(t,n,s){const r=document.querySelectorAll(t);if(0===r.length)return void o(`No ${n} tables found to enhance`);o(`Enhancing ${r.length} ${n} table(s) in non-interactive mode...`);let a=0;for(const o of Array.from(r))try{s(o),a++}catch(c){d(`Failed to enhance ${n} table: ${c.message}`)}o(`Enhanced ${a} of ${r.length} ${n} table(s) (non-interactive)`)}function oo(){const t=_n(),n=document.querySelectorAll("table.qd-quiz");0!==n.length?(n.forEach(n=>{const s=wn(n);s&&(s.pageId=t,Dn(n,s,{addInstructorClass:!0}))}),o(`Revealed answers for instructor on ${n.length} quiz table(s)`)):o("No quiz tables found to reveal answers for")}if("undefined"!=typeof window){const t=()=>{o("Auto-initializing Sonar Quiz System");const t=w();so({dbName:t.dbName,statusPanelContainer:t.statusPanelContainer,autoEnhanceQuizTables:!0,autoEnhanceAnalysisTables:!0,autoEnhanceHomeBadges:!0}).catch(t=>{console.error("[FATAL] Bootstrap failed:",t)})};"loading"===document.readyState?document.addEventListener("DOMContentLoaded",()=>{t()}):t()}return t.BUILD_DATE="1/Nov/2026",t.DEFAULT_CONTAINERS=Fr,t.Debouncer=Debouncer,t.OBFUSCATION_PREFIX=Ce,t.SCHEMA_VERSION=3,t.SESSION_TIMEOUT_MS=X,t.STORAGE_KEYS=G,t.VERSION="0.2.0",t.bootstrap=so,t.calculateCompletionState=Re,t.cleanup=function(){no.initialized?(o("Cleaning up bootstrap resources..."),no.eventCoordinator?.cleanup(),no.sessionCoordinator?.cleanup(),no.initialized=!1,no.eventCoordinator=void 0,no.sessionCoordinator=void 0,o("Bootstrap cleanup complete")):d("Bootstrap not initialized, nothing to cleanup")},t.clearQuizData=Y,t.decode=Te,t.deriveKey=Ie,t.encode=Oe,t.enhanceAnalysisTable=Nn,t.enhanceQuizTable=vn,t.error=a,t.generateCellKey=$n,t.generateTableId=Sn,t.getAnalysisTableMetadata=function(t){return Ln.get(t)},t.getJSON=W,t.getQuizTableMetadata=wn,t.info=o,t.injectComponents=Vr,t.isAnalysisTableEnhanced=function(t){return Ln.has(t)},t.isCellEditable=qn,t.isInitialized=function(){return no.initialized},t.isObfuscated=Le,t.isQuizTableEnhanced=function(t){return yn.has(t)},t.migrateObfuscation=Is,t.parseAnalysisTable=En,t.parseQuizTable=O,t.setDebugMode=function(t){n=t},t.setJSON=J,t.validateAnswer=T,t.warn=d,Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),t}({}); //# sourceMappingURL=sonar-quiz.iife.js.map diff --git a/dita-demo/oxygen-webhelp/template/resources/sonar-quiz.iife.js.map b/dita-demo/oxygen-webhelp/template/resources/sonar-quiz.iife.js.map index 57dd335f..29673e7f 100644 --- a/dita-demo/oxygen-webhelp/template/resources/sonar-quiz.iife.js.map +++ b/dita-demo/oxygen-webhelp/template/resources/sonar-quiz.iife.js.map @@ -1 +1 @@ -{"version":3,"file":"sonar-quiz.iife.js","sources":["../src/utils/logger.ts","../src/services/quiz-parser.ts","../src/types/contracts.ts","../src/services/session.ts","../src/utils/calculation-helpers.ts","../src/utils/date-helpers.ts","../src/utils/debouncer.ts","../src/utils/dom-helpers.ts","../src/utils/event-helpers.ts","../src/utils/storage-helpers.ts","../src/services/storage/adapter-utils.ts","../src/config/feature-flags.ts","../src/services/storage/obfuscation.ts","../src/services/storage/indexeddb.ts","../src/services/state-calculator.ts","../src/services/storage-service.ts","../src/enhancers/quiz-table.ts","../src/services/question-input.ts","../src/services/answer-display.ts","../src/services/analysis-parser.ts","../src/enhancers/analysis-table.ts","../src/init/event-coordinator.ts","../src/init/session-coordinator.ts","../node_modules/@lit/reactive-element/css-tag.js","../node_modules/@lit/reactive-element/reactive-element.js","../node_modules/lit-html/lit-html.js","../node_modules/lit-element/lit-element.js","../node_modules/@lit/reactive-element/decorators/custom-element.js","../node_modules/@lit/reactive-element/decorators/property.js","../node_modules/@lit/reactive-element/decorators/state.js","../node_modules/@lit/reactive-element/decorators/query.js","../node_modules/@lit/reactive-element/decorators/base.js","../src/config/dom-config-reader.ts","../src/services/storage/migration.ts","../src/services/auth/pin-service.ts","../src/services/auth/rate-limiter.ts","../src/components/qd-build-info.ts","../src/components/qd-modal.ts","../src/components/qd-password-modal.ts","../node_modules/lit-html/directive.js","../node_modules/lit-html/directives/unsafe-html.js","../src/components/qd-confirm-dialog.ts","../src/components/qd-help-trigger.ts","../src/components/qd-help-popup.ts","../src/services/storage/obfuscation-migration.ts","../src/components/qd-migration-dialog.ts","../src/config/help-content.ts","../src/components/qd-login.ts","../src/utils/validation-helpers.ts","../src/components/qd-status.ts","../src/components/qd-instructor/shared-styles.ts","../src/utils/security.ts","../src/config/instructor-password.ts","../src/components/qd-instructor/qd-instructor-unlock.ts","../src/components/qd-scores-modal.ts","../src/components/qd-instructor/qd-instructor-scores.ts","../src/components/qd-instructor/qd-instructor-export.ts","../src/components/qd-instructor/qd-instructor-manage.ts","../src/components/qd-pin-reset-dialog.ts","../src/components/qd-instructor/qd-instructor.ts","../src/init/component-injector.ts","../src/enhancers/home-badges.ts","../src/init/bootstrap.ts","../src/index.ts"],"sourcesContent":["/**\n * Structured logging with sanitization\n *\n * Provides debug/info/error logging with automatic sanitization of sensitive data.\n * Debug logs are controlled by a runtime flag to prevent production leakage.\n */\n\nimport type { ServiceId } from '../types/contracts.js';\n\n/**\n * Debug mode flag\n *\n * Set to true for development logging, false for production.\n * Can be controlled via data-debug attribute on script tag.\n */\nlet debugEnabled = false;\n\n/**\n * Enable or disable debug logging\n *\n * @param enabled - Whether to enable debug logs\n */\nexport function setDebugMode(enabled: boolean): void {\n debugEnabled = enabled;\n}\n\n/**\n * Check if debug mode is enabled\n */\nexport function isDebugEnabled(): boolean {\n return debugEnabled;\n}\n\n/**\n * Mask sensitive service ID\n *\n * Replaces middle characters with asterisks for privacy.\n *\n * @param serviceId - Service ID to mask\n * @returns Masked service ID (e.g., \"RN2344\" → \"RN****\")\n *\n * @example\n * ```typescript\n * const masked = maskServiceId('RN2344');\n * console.log(masked); // \"RN****\"\n * ```\n */\nexport function maskServiceId(serviceId: ServiceId): string {\n if (serviceId.length < 2) {\n return '**';\n }\n if (serviceId.length === 2) {\n return serviceId; // Keep 2-char IDs unmasked\n }\n const prefix = serviceId.slice(0, 2);\n const suffix = '*'.repeat(serviceId.length - 2);\n return prefix + suffix;\n}\n\n/**\n * Sanitize object by removing or masking sensitive fields\n *\n * Removes: name, passwordHash\n * Masks: serviceId\n *\n * @param obj - Object to sanitize\n * @returns Sanitized copy of object\n *\n * @example\n * ```typescript\n * const data = { serviceId: 'RN2344', name: 'John Doe', score: 95 };\n * const safe = sanitize(data);\n * console.log(safe); // { serviceId: 'RN****', score: 95 }\n * ```\n */\nexport function sanitize(obj: T): Partial {\n if (obj === null || typeof obj !== 'object') {\n return obj;\n }\n\n const sanitized: Record = {};\n\n for (const [key, value] of Object.entries(obj)) {\n // Remove sensitive fields\n if (key === 'name' || key === 'passwordHash') {\n continue;\n }\n\n // Mask service IDs\n if (key === 'serviceId' && typeof value === 'string') {\n sanitized[key] = maskServiceId(value);\n continue;\n }\n\n // Recursively sanitize nested objects\n if (typeof value === 'object' && value !== null) {\n sanitized[key] = sanitize(value);\n continue;\n }\n\n sanitized[key] = value;\n }\n\n return sanitized as Partial;\n}\n\n/**\n * Log debug message (only in debug mode)\n *\n * @param message - Debug message\n * @param data - Optional data to log (will be sanitized)\n */\nexport function debug(message: string, data?: unknown): void {\n if (debugEnabled) {\n if (data !== undefined) {\n // eslint-disable-next-line no-console\n console.log(`[DEBUG] ${message}`, sanitize(data));\n } else {\n // eslint-disable-next-line no-console\n console.log(`[DEBUG] ${message}`);\n }\n }\n}\n\n/**\n * Log info message (only in debug mode)\n *\n * @param message - Info message\n * @param data - Optional data to log (will be sanitized)\n */\nexport function info(message: string, data?: unknown): void {\n if (debugEnabled) {\n if (data !== undefined) {\n // eslint-disable-next-line no-console\n console.log(`[INFO] ${message}`, sanitize(data));\n } else {\n // eslint-disable-next-line no-console\n console.log(`[INFO] ${message}`);\n }\n }\n}\n\n/**\n * Log error message\n *\n * @param message - Error message\n * @param error - Error object or data\n */\nexport function error(message: string, error?: unknown): void {\n if (error instanceof Error) {\n const errorObj: { name: string; message: string; stack?: string } = {\n name: error.name,\n message: error.message,\n };\n if (debugEnabled && error.stack) {\n errorObj.stack = error.stack;\n }\n console.error(`[ERROR] ${message}`, errorObj);\n } else if (error !== undefined) {\n console.error(`[ERROR] ${message}`, sanitize(error));\n } else {\n console.error(`[ERROR] ${message}`);\n }\n}\n\n/**\n * Log warning message\n *\n * @param message - Warning message\n * @param data - Optional data to log (will be sanitized)\n */\nexport function warn(message: string, data?: unknown): void {\n if (data !== undefined) {\n console.warn(`[WARN] ${message}`, sanitize(data));\n } else {\n console.warn(`[WARN] ${message}`);\n }\n}\n\n/**\n * Logger object with all methods\n */\nexport const logger = {\n setDebugMode,\n isDebugEnabled,\n debug,\n info,\n warn,\n error,\n sanitize,\n maskServiceId,\n};\n","/**\n * Quiz Table Parser\n *\n * Parses DITA-generated HTML quiz tables and extracts question data.\n *\n * Table Structure:\n * - Must have class \"qd-quiz\"\n * - Exactly 3 columns: Question | Answer | Detail\n * - MCQ: Detail column contains
                                    with options\n * - Numeric: Detail column contains tolerance number\n */\n\nimport type { ParsedQuizTable, QuizQuestion } from '../types/contracts.js';\n\n/**\n * Parse a quiz table and extract question data\n *\n * @param table - HTMLTableElement with class \"qd-quiz\"\n * @returns ParsedQuizTable with questions and any validation errors\n */\nexport function parseQuizTable(table: HTMLTableElement): ParsedQuizTable {\n const errors: string[] = [];\n const questions: QuizQuestion[] = [];\n\n // Validate table has correct class\n if (!table.classList.contains('qd-quiz')) {\n errors.push('Table must have class \"qd-quiz\"');\n return { element: table, questions, errors };\n }\n\n // Get all rows from tbody (skip thead if present)\n const rows = Array.from(table.querySelectorAll('tbody tr'));\n\n if (rows.length === 0) {\n errors.push('Quiz table has no data rows');\n return { element: table, questions, errors };\n }\n\n // Parse each row\n rows.forEach((row, index) => {\n const cells = Array.from(row.querySelectorAll('td'));\n\n // Validate row has exactly 3 columns\n if (cells.length !== 3) {\n errors.push(\n `Row ${index + 1} has ${cells.length} columns, expected 3 (Question | Answer | Detail)`,\n );\n return;\n }\n\n const questionCell = cells[0];\n const answerCell = cells[1];\n const detailCell = cells[2];\n\n if (!questionCell || !answerCell || !detailCell) {\n return;\n }\n\n // Extract question text\n const questionText = questionCell.textContent?.trim() || '';\n if (!questionText) {\n errors.push(`Row ${index + 1} has empty question text`);\n return;\n }\n\n // Extract correct answer\n const correctAnswer = answerCell.textContent?.trim() || '';\n if (!correctAnswer) {\n errors.push(`Row ${index + 1} has empty answer`);\n return;\n }\n\n // Determine question kind and extract additional data\n const olElement = detailCell.querySelector('ol');\n\n if (olElement) {\n // MCQ question - extract options from ordered list\n const options = extractMcqOptions(olElement);\n\n if (options.length === 0) {\n errors.push(`Row ${index + 1} MCQ has no options in
                                      `);\n return;\n }\n\n questions.push({\n text: questionText,\n kind: 'mcq',\n correctAnswer,\n options,\n });\n } else {\n // Numeric question - extract tolerance\n const toleranceText = detailCell.textContent?.trim() || '';\n const tolerance = parseFloat(toleranceText);\n\n if (isNaN(tolerance)) {\n errors.push(\n `Row ${index + 1} appears to be numeric but has invalid tolerance: \"${toleranceText}\"`,\n );\n return;\n }\n\n questions.push({\n text: questionText,\n kind: 'numeric',\n correctAnswer,\n tolerance,\n });\n }\n });\n\n return {\n element: table,\n questions,\n errors: errors.length > 0 ? errors : undefined,\n };\n}\n\n/**\n * Extract option text from MCQ ordered list\n *\n * @param ol - The
                                        element containing options\n * @returns Array of option strings\n */\nfunction extractMcqOptions(ol: HTMLOListElement): string[] {\n const listItems = Array.from(ol.querySelectorAll('li'));\n return listItems.map((li) => li.textContent?.trim() || '').filter((text) => text.length > 0);\n}\n\n/**\n * Find all quiz tables in the document\n *\n * @param doc - Document to search (defaults to global document)\n * @returns Array of ParsedQuizTable results\n */\nexport function findQuizTables(doc: Document = document): ParsedQuizTable[] {\n const tables = Array.from(doc.querySelectorAll('table.qd-quiz'));\n return tables.map((table) => parseQuizTable(table));\n}\n\n/**\n * Validate answer against question\n *\n * @param question - The quiz question\n * @param answer - The user's answer\n * @returns true if answer is correct\n */\nexport function validateAnswer(question: QuizQuestion, answer: string): boolean {\n if (!answer || answer.trim() === '') {\n return false;\n }\n\n const trimmedAnswer = answer.trim();\n\n if (question.kind === 'mcq') {\n // MCQ: exact match of option number (1-indexed)\n return trimmedAnswer === question.correctAnswer;\n } else {\n // Numeric: within tolerance\n const userValue = parseFloat(trimmedAnswer);\n const correctValue = parseFloat(question.correctAnswer);\n\n if (isNaN(userValue) || isNaN(correctValue)) {\n return false;\n }\n\n const tolerance = question.tolerance ?? 0;\n return Math.abs(userValue - correctValue) <= tolerance;\n }\n}\n","/**\n * Frozen Type Contracts for Sonar Quiz System\n * Version: 1.1.0 (Fixed PageCache with answers field)\n *\n * These types are FROZEN and must not be modified without version bump.\n * Any changes require migration strategy and backwards compatibility.\n *\n * Changelog:\n * - 1.1.0: Added missing `answers` field to PageCache (fixes 78 eslint-disable comments)\n * - 1.0.0: Initial contracts\n */\n\n// ============================================================================\n// CORE IDENTIFIERS\n// ============================================================================\n\n/** Release identifier format: \"MM-YYYY\" */\nexport type ReleaseId = string;\n\n/** Service ID for student identification */\nexport type ServiceId = string;\n\n/** Page identifier from DITA document */\nexport type PageId = string;\n\n/** Table identifier (16-char hash based on table structure: rows x cols + class name) */\nexport type TableId = string;\n\n/** Cell key format: \"R{row}C{col}#f:{hash}\" where hash is 8-char from normalized content */\nexport type CellKey = string;\n\n// ============================================================================\n// ENUMERATIONS\n// ============================================================================\n\n/** Page completion state */\nexport type CompletionState = 'unstarted' | 'incomplete' | 'complete';\n\n/** Question type in quiz */\nexport type QuestionKind = 'mcq' | 'numeric';\n\n// ============================================================================\n// QUIZ ENTITIES\n// ============================================================================\n\n/** Individual quiz answer with correctness */\nexport interface AnswerRecord {\n /** User's answer value */\n answer: string;\n /** Whether the answer is correct */\n success: boolean;\n /** Timestamp when answer was submitted (ISO 8601) */\n timestamp: string;\n}\n\n/** Quiz question definition */\nexport interface QuizQuestion {\n /** Question text */\n text: string;\n /** Question type */\n kind: QuestionKind;\n /** Correct answer */\n correctAnswer: string;\n /** MCQ options (for mcq type) */\n options?: string[];\n /** Numeric tolerance (for numeric type) */\n tolerance?: number;\n}\n\n// ============================================================================\n// ANALYSIS ENTITIES\n// ============================================================================\n\n/** Analysis table data */\nexport interface AnalysisData {\n /** Unique table identifier */\n tableId: TableId;\n /** Cell key to content mapping */\n cells: Record;\n /** First edit timestamp (ISO 8601) */\n firstEdited?: string;\n /** Last edit timestamp (ISO 8601) */\n lastEdited?: string;\n}\n\n// ============================================================================\n// PAGE DATA\n// ============================================================================\n\n/** Student's data for a specific page */\nexport interface PageData {\n /** Array of quiz answers */\n answers: AnswerRecord[];\n /** Calculated completion state */\n state: CompletionState;\n /** First attempt timestamp (ISO 8601) */\n firstAttempted?: string;\n /** Last attempt timestamp (ISO 8601) */\n lastAttempted?: string;\n /** Analysis table data if present */\n analysis?: AnalysisData;\n}\n\n// ============================================================================\n// STUDENT RECORD\n// ============================================================================\n\n/** Complete student progress record */\nexport interface StudentRecord {\n /** Schema version for migrations */\n schema: number;\n /** Document identifier */\n docId: string;\n /** Release version */\n release: ReleaseId;\n /** Student service ID */\n serviceId: ServiceId;\n /** Student name */\n name: string;\n /** Total questions attempted */\n attempted: number;\n /** Total correct answers */\n correct: number;\n /** Last update timestamp (ISO 8601) */\n updated: string;\n /** Page data by page ID */\n pages: Record;\n\n // PIN Authentication (v2)\n /** SHA-256 hash of 4-digit PIN */\n pinHash?: string;\n /** ISO 8601 timestamp when PIN was created */\n pinCreatedAt?: string;\n /** ISO 8601 timestamp when PIN was last reset */\n pinResetAt?: string;\n}\n\n// ============================================================================\n// PIN AUTHENTICATION (v2)\n// ============================================================================\n\n/** Rate limiting state for PIN attempts (stored in sessionStorage) */\nexport interface PinAttemptState {\n /** Student identifier */\n serviceId: ServiceId;\n /** Failed attempt count (0-3) */\n attempts: number;\n /** ISO 8601 timestamp when lockout expires, or null */\n lockoutUntil: string | null;\n /** ISO 8601 timestamp of last attempt */\n lastAttempt: string;\n}\n\n/** Audit trail for instructor PIN resets (stored in IndexedDB) */\nexport interface PinResetEvent {\n /** UUID v4 */\n eventId: string;\n /** Student affected */\n serviceId: ServiceId;\n /** Actor type */\n resetBy: 'instructor';\n /** ISO 8601 timestamp */\n resetAt: string;\n /** Context */\n release: ReleaseId;\n}\n\n// ============================================================================\n// SESSION MANAGEMENT\n// ============================================================================\n\n/**\n * Active session data\n *\n * Note: serviceId and release are duplicated from the storage key\n * for convenient access without requiring a storage lookup\n */\nexport interface SessionData {\n /** Student service ID (duplicated from storage key) */\n serviceId: ServiceId;\n /** Student name */\n name: string;\n /** Current release (duplicated from storage key) */\n release: ReleaseId;\n /** Login timestamp (ISO 8601) */\n loginTime: string;\n /** Last activity timestamp (ISO 8601) */\n lastActivity: string;\n /** Session expiry timestamp (ISO 8601) */\n expiresAt: string;\n /** Whether instructor mode is unlocked */\n instructorUnlocked: boolean;\n /** Instructor unlock timestamp (ISO 8601) */\n unlockTime?: string;\n}\n\n/**\n * Cached page state for performance\n *\n * CRITICAL FIX: Added `answers` field to fix type safety issues\n * This was missing in v1.0.0, causing 78 eslint-disable comments\n */\nexport interface PageCache {\n /** Page completion state */\n state: CompletionState;\n /** Total number of questions registered on this page */\n total: number;\n /** Number of questions answered */\n answered: number;\n /** Number of correct answers */\n correct: number;\n /** Last update timestamp (ISO 8601) */\n last?: string;\n /** Answer records (ADDED in v1.1.0) */\n answers?: AnswerRecord[];\n /** Analysis table data if present (ADDED in v1.2.0) */\n analysis?: AnalysisData;\n}\n\n/** Session cache for quick access */\nexport interface SessionCache {\n /** Aggregated totals */\n totals: {\n total: number;\n answered: number;\n correct: number;\n };\n /** Per-page cache */\n pages: Record;\n}\n\n// ============================================================================\n// INSTRUCTOR FEATURES\n// ============================================================================\n\n/** Student summary for instructor view */\nexport interface StudentSummary {\n /** Student service ID */\n serviceId: ServiceId;\n /** Student name */\n name: string;\n /** Questions attempted */\n attempted: number;\n /** Correct answers */\n correct: number;\n /** Success percentage */\n percentage: number;\n /** Last activity timestamp */\n lastActive: string;\n}\n\n/** Quiz results export format */\nexport interface QuizExport {\n /** Export timestamp */\n timestamp: string;\n /** Release version */\n release: ReleaseId;\n /** Document ID */\n docId: string;\n /** Student results */\n students: StudentSummary[];\n /** Detailed answers by page */\n details?: {\n pageId: PageId;\n studentId: ServiceId;\n answers: AnswerRecord[];\n }[];\n}\n\n// ============================================================================\n// DOM ENHANCEMENT\n// ============================================================================\n\n/** Quiz table parsing result */\nexport interface ParsedQuizTable {\n /** Table element reference */\n element: HTMLTableElement;\n /** Extracted questions */\n questions: QuizQuestion[];\n /** Validation errors if any */\n errors?: string[];\n}\n\n/** Analysis table parsing result */\nexport interface ParsedAnalysisTable {\n /** Table element reference */\n element: HTMLTableElement;\n /** Table identifier */\n tableId: TableId;\n /** Editable cell positions */\n editableCells: Array<{\n row: number;\n col: number;\n key: CellKey;\n }>;\n /** Validation errors if any */\n errors?: string[];\n}\n\n// ============================================================================\n// STORAGE ADAPTER\n// ============================================================================\n\n/** Storage adapter interface for data persistence */\nexport interface StorageAdapter {\n /** Initialize storage */\n init(): Promise;\n\n /** Get student record */\n getStudent(release: ReleaseId, serviceId: ServiceId): Promise;\n\n /** Save student record */\n saveStudent(record: StudentRecord): Promise;\n\n /** Get all students for a release */\n getStudentsByRelease(release: ReleaseId): Promise;\n\n /** Delete all data */\n clearAll(): Promise;\n\n /** Create backup */\n backup(record: StudentRecord): Promise;\n}\n\n// ============================================================================\n// EVENTS\n// ============================================================================\n\n/** Custom event namespace */\nexport const EVENT_NAMESPACE = 'qd';\n\n/** Event type definitions */\nexport interface QuizEvents {\n 'qd:login': { detail: SessionData };\n 'qd:logout': { detail: { serviceId: ServiceId } };\n 'qd:answer-saved': { detail: { pageId: PageId; answer: AnswerRecord } };\n 'qd:state-changed': { detail: { pageId: PageId; state: CompletionState } };\n 'qd:analysis-saved': {\n detail: { pageId: PageId; tableId: TableId; cellKey: CellKey; content: string };\n };\n 'qd:instructor-unlock': { detail: { timestamp: string } };\n 'qd:instructor-lock': { detail: { timestamp: string } };\n 'qd:data-cleared': { detail: { timestamp: string } };\n 'qd:session-expired': { detail: { timestamp: string } };\n 'qd:storage-error': { detail: { error: Error; operation: string } };\n // PIN Authentication events (v2)\n 'qd:pin-created': { detail: { serviceId: ServiceId; timestamp: string } };\n 'qd:pin-verified': { detail: { serviceId: ServiceId; timestamp: string } };\n 'qd:pin-reset': { detail: { serviceId: ServiceId; resetBy: 'instructor'; timestamp: string } };\n}\n\n// ============================================================================\n// CONSTANTS\n// ============================================================================\n\n/** Current schema version */\nexport const SCHEMA_VERSION = 2;\n\n/** Session timeout in milliseconds (30 minutes) */\nexport const SESSION_TIMEOUT_MS = 30 * 60 * 1000;\n\n/** Storage keys */\nexport const STORAGE_KEYS = {\n SESSION: 'qd/session',\n CACHE: 'qd/state',\n INSTRUCTOR: 'qd/instructor',\n PIN_ATTEMPTS: 'qd:pin-attempts',\n} as const;\n\n/** PIN authentication constants */\nexport const PIN_CONSTANTS = {\n /** Maximum failed attempts before lockout */\n MAX_ATTEMPTS: 3,\n /** Lockout duration in milliseconds (30 seconds) */\n LOCKOUT_MS: 30 * 1000,\n /** PIN length (must be exactly 4 digits) */\n PIN_LENGTH: 4,\n} as const;\n\n/** CSS classes for DOM selection */\nexport const CSS_CLASSES = {\n QUIZ_TABLE: 'qd-quiz',\n ANALYSIS_TABLE: 'qd-analysis',\n TEST_LINK: 'quizPageBtn',\n} as const;\n\n/** Element IDs */\nexport const ELEMENT_IDS = {\n STATUS_PANEL: 'qd-status',\n} as const;\n\n/**\n * CSS selectors for DOM injection points\n *\n * These are default/reference values. Actual selectors are configurable\n * via SonarQuizConfig.statusPanelContainer option.\n *\n * @see SonarQuizConfig in src/index.ts\n */\nexport const INJECTION_SELECTORS = {\n /** Default navbar container for Oxygen WebHelp templates */\n NAVBAR_CONTAINER: '.wh_top_menu_and_indexterms_link',\n} as const;\n\n/** Validation limits */\nexport const LIMITS = {\n MAX_QUESTIONS_PER_PAGE: 100,\n MAX_CELL_CONTENT_LENGTH: 500,\n MAX_NAME_LENGTH: 100,\n MAX_SERVICE_ID_LENGTH: 10,\n} as const;\n","/**\n * Session Management Service\n *\n * Handles user session lifecycle, timeout management, and instructor mode.\n * Integrates with encrypted session storage for secure session data.\n */\n\nimport type {\n SessionData,\n SessionCache,\n ServiceId,\n ReleaseId,\n StudentRecord,\n PageCache,\n PageData,\n CompletionState,\n} from '../types/contracts.js';\nimport { STORAGE_KEYS, SESSION_TIMEOUT_MS } from '../types/contracts.js';\nimport { info, warn, error } from '../utils/logger.js';\nimport { isSessionExpired } from '../utils/calculation-helpers.js';\n\n/**\n * Session Service for managing user sessions\n */\nexport class SessionService {\n /**\n * Create a new session\n *\n * @param serviceId - Student service ID\n * @param name - Student name\n * @param release - Current release ID\n * @returns Created session data\n */\n createSession(serviceId: ServiceId, name: string, release: ReleaseId): SessionData {\n const now = new Date();\n const loginTime = now.toISOString();\n const expiresAt = new Date(now.getTime() + SESSION_TIMEOUT_MS).toISOString();\n\n const session: SessionData = {\n serviceId,\n name,\n release,\n loginTime,\n lastActivity: loginTime,\n expiresAt,\n instructorUnlocked: false,\n };\n\n this.saveSession(session);\n info(`Session created for ${serviceId} (${name})`);\n\n // Emit login event\n this.emitEvent('qd:login', { serviceId, name, release, loginTime });\n\n return session;\n }\n\n /**\n * Get the current session\n *\n * @returns Session data or null if no session exists\n */\n getSession(): SessionData | null {\n try {\n const sessionData = sessionStorage.getItem(STORAGE_KEYS.SESSION);\n if (!sessionData) {\n return null;\n }\n\n const session = JSON.parse(sessionData) as SessionData;\n\n // Validate required fields\n if (!session.serviceId || !session.release || !session.expiresAt) {\n warn('Invalid session data, missing required fields');\n return null;\n }\n\n return session;\n } catch (err) {\n error('Failed to parse session data', err as Error);\n return null;\n }\n }\n\n /**\n * Update last activity time and extend session expiry\n */\n updateActivity(): void {\n const session = this.getSession();\n if (!session) {\n return;\n }\n\n const now = new Date();\n session.lastActivity = now.toISOString();\n session.expiresAt = new Date(now.getTime() + SESSION_TIMEOUT_MS).toISOString();\n\n this.saveSession(session);\n }\n\n /**\n * Check if the current session is expired\n *\n * @returns True if session is expired or doesn't exist\n */\n isExpired(): boolean {\n const session = this.getSession();\n if (!session) {\n return true;\n }\n\n return isSessionExpired(session.expiresAt);\n }\n\n /**\n * Clear the current session\n */\n clearSession(): void {\n const session = this.getSession();\n sessionStorage.removeItem(STORAGE_KEYS.SESSION);\n sessionStorage.removeItem(STORAGE_KEYS.CACHE);\n sessionStorage.removeItem(STORAGE_KEYS.INSTRUCTOR);\n\n // Clear instructor-specific state (FR-001)\n sessionStorage.removeItem('qd/instructor/showAnswers');\n\n if (session) {\n info(`Session cleared for ${session.serviceId}`);\n\n // Emit logout event\n this.emitEvent('qd:logout', {\n serviceId: session.serviceId,\n timestamp: new Date().toISOString(),\n });\n }\n }\n\n /**\n * Unlock instructor mode\n */\n unlockInstructor(): void {\n const session = this.getSession();\n if (!session) {\n return;\n }\n\n session.instructorUnlocked = true;\n session.unlockTime = new Date().toISOString();\n\n this.saveSession(session);\n\n info('Instructor mode unlocked');\n\n // Emit custom event\n this.emitEvent('qd:instructor-unlock', { timestamp: session.unlockTime });\n }\n\n /**\n * Lock instructor mode\n */\n lockInstructor(): void {\n const session = this.getSession();\n if (!session) {\n return;\n }\n\n session.instructorUnlocked = false;\n delete session.unlockTime;\n\n this.saveSession(session);\n\n info('Instructor mode locked');\n\n // Emit custom event\n this.emitEvent('qd:instructor-lock', { timestamp: new Date().toISOString() });\n }\n\n /**\n * Check if instructor mode is unlocked\n *\n * @returns True if instructor mode is unlocked\n */\n isInstructorUnlocked(): boolean {\n const session = this.getSession();\n return session?.instructorUnlocked === true;\n }\n\n /**\n * Get session cache from sessionStorage\n *\n * @returns Session cache or null if not found\n */\n getCache(): SessionCache | null {\n try {\n const cacheData = sessionStorage.getItem(STORAGE_KEYS.CACHE);\n if (!cacheData) {\n return null;\n }\n\n return JSON.parse(cacheData) as SessionCache;\n } catch (err) {\n error('Failed to parse cache data', err);\n return null;\n }\n }\n\n /**\n * Save session cache to sessionStorage\n *\n * @param cache - Cache data to save\n */\n saveCache(cache: SessionCache): void {\n try {\n sessionStorage.setItem(STORAGE_KEYS.CACHE, JSON.stringify(cache));\n } catch (err) {\n error('Failed to save cache', err);\n }\n }\n\n /**\n * Clear the session cache\n */\n clearCache(): void {\n sessionStorage.removeItem(STORAGE_KEYS.CACHE);\n }\n\n /**\n * Save session to sessionStorage\n *\n * @param session - Session data to save\n */\n private saveSession(session: SessionData): void {\n try {\n sessionStorage.setItem(STORAGE_KEYS.SESSION, JSON.stringify(session));\n } catch (err) {\n error('Failed to save session', err);\n }\n }\n\n /**\n * Emit a custom event\n *\n * @param eventName - Name of the event\n * @param detail - Event detail data\n */\n private emitEvent(eventName: string, detail: unknown): void {\n try {\n const event = new CustomEvent(eventName, { detail, bubbles: true });\n document.dispatchEvent(event);\n } catch (err) {\n error(`Failed to emit event ${eventName}`, err);\n }\n }\n}\n\n// ============================================================================\n// CACHE BUILDING UTILITIES\n// ============================================================================\n\n/**\n * Build session cache from a student record\n *\n * This creates a SessionCache structure that provides quick access to\n * page states and totals without querying IndexedDB.\n *\n * @param record - Student record to build cache from\n * @returns Session cache with totals and page entries\n */\nexport function buildCacheFromRecord(record: StudentRecord): SessionCache {\n const cache: SessionCache = {\n totals: {\n total: 0,\n answered: 0,\n correct: 0,\n },\n pages: {},\n };\n\n // Build cache entry for each page\n for (const [pageId, pageData] of Object.entries(record.pages)) {\n const pageCache = buildPageCache(pageId, pageData);\n cache.pages[pageId] = pageCache;\n\n // Accumulate totals\n cache.totals.total += pageCache.total;\n cache.totals.answered += pageCache.answered;\n cache.totals.correct += pageCache.correct;\n }\n\n return cache;\n}\n\n/**\n * Build a page cache entry from page data\n *\n * @param _pageId - Page identifier (unused, kept for API consistency)\n * @param pageData - Page data from student record\n * @returns Page cache entry\n */\nexport function buildPageCache(_pageId: string, pageData: PageData): PageCache {\n // Total is the length of answers array (includes empty/placeholder answers)\n const total = pageData.answers.length;\n const answered = pageData.answers.filter((a) => a.answer.trim() !== '').length;\n const correct = pageData.answers.filter((a) => a.success).length;\n\n return {\n state: pageData.state,\n total,\n answered,\n correct,\n last: pageData.lastAttempted,\n answers: pageData.answers,\n analysis: pageData.analysis, // Preserve analysis data from analysis tables\n };\n}\n\n/**\n * Register page questions in cache\n *\n * Called when a quiz page loads to register the total number of questions.\n * This ensures the status panel shows total registered questions, not just answered.\n *\n * @param cache - Current cache to update\n * @param pageId - Page identifier\n * @param totalQuestions - Total number of questions on the page\n * @returns Updated cache\n */\nexport function registerPageQuestions(\n cache: SessionCache,\n pageId: string,\n totalQuestions: number,\n): SessionCache {\n // Get existing page cache or create new one\n const existingPage = cache.pages[pageId];\n\n // If page already registered with same or higher total, don't update\n if (existingPage && existingPage.total >= totalQuestions) {\n return cache;\n }\n\n // Calculate delta for totals update\n const oldTotal = existingPage?.total || 0;\n const delta = totalQuestions - oldTotal;\n\n // Create/update page entry\n const updatedPage: PageCache = {\n state: existingPage?.state || ('unstarted' as const),\n total: totalQuestions,\n answered: existingPage?.answered || 0,\n correct: existingPage?.correct || 0,\n last: existingPage?.last,\n answers: existingPage?.answers,\n analysis: existingPage?.analysis,\n };\n\n return {\n totals: {\n total: cache.totals.total + delta,\n answered: cache.totals.answered,\n correct: cache.totals.correct,\n },\n pages: {\n ...cache.pages,\n [pageId]: updatedPage,\n },\n };\n}\n\n/**\n * Update cache with a new answer\n *\n * This incrementally updates the cache when a new answer is submitted,\n * avoiding the need to rebuild the entire cache.\n *\n * @param cache - Current cache to update\n * @param pageId - Page where answer was submitted\n * @param isCorrect - Whether the answer is correct\n * @param newState - New completion state for the page\n * @returns Updated cache\n */\nexport function updateCacheWithAnswer(\n cache: SessionCache,\n pageId: string,\n isCorrect: boolean,\n newState: CompletionState,\n): SessionCache {\n const now = new Date().toISOString();\n\n // Get or create page entry\n const pageCache = cache.pages[pageId] || {\n state: 'incomplete' as const,\n total: 0,\n answered: 0,\n correct: 0,\n };\n\n // Update page counts\n const updatedPage: PageCache = {\n ...pageCache,\n state: newState,\n answered: pageCache.answered + 1,\n correct: pageCache.correct + (isCorrect ? 1 : 0),\n last: now,\n };\n\n // Update totals\n const updatedTotals = {\n total: cache.totals.total,\n answered: cache.totals.answered + 1,\n correct: cache.totals.correct + (isCorrect ? 1 : 0),\n };\n\n return {\n totals: updatedTotals,\n pages: {\n ...cache.pages,\n [pageId]: updatedPage,\n },\n };\n}\n\n// ============================================================================\n// SINGLETON PATTERN\n// ============================================================================\n\n/**\n * Create and return a singleton instance of the session service\n */\nlet sessionInstance: SessionService | null = null;\n\nexport function getSessionService(): SessionService {\n if (!sessionInstance) {\n sessionInstance = new SessionService();\n }\n return sessionInstance;\n}\n\n/**\n * Reset the singleton instance (useful for testing)\n */\nexport function resetSessionService(): void {\n sessionInstance = null;\n}\n","/**\n * Calculation Helpers\n *\n * Pure functions for status indicators, percentages, and totals.\n * Feature: 007-lit-component-refactor\n *\n * These functions have no side effects and no DOM dependencies,\n * making them easy to unit test.\n */\n\nimport type { PageData, PageId } from '../types/contracts';\n\n/**\n * Status indicator values for R/A/G progress display.\n */\nexport type StatusIndicator = 'red' | 'amber' | 'green';\n\n/**\n * Calculates R/A/G status indicator from quiz totals.\n *\n * @param total - Total number of questions\n * @param correct - Number of correct answers\n * @returns 'green' if all correct, 'red' if none, 'amber' otherwise\n */\nexport function calculateStatusIndicator(total: number, correct: number): StatusIndicator {\n if (total === 0 || correct === 0) {\n return 'red';\n }\n if (correct === total) {\n return 'green';\n }\n return 'amber';\n}\n\n/**\n * Calculates percentage with safe division.\n *\n * @param correct - Numerator (correct count)\n * @param attempted - Denominator (attempted count)\n * @returns Rounded percentage (0 if attempted is 0)\n */\nexport function calculatePercentage(correct: number, attempted: number): number {\n if (attempted === 0) {\n return 0;\n }\n return Math.round((correct / attempted) * 100);\n}\n\n/**\n * Totals calculated from page data.\n */\nexport interface RecalculatedTotals {\n attempted: number;\n correct: number;\n}\n\n/**\n * Recalculates totals from all pages in a student record.\n * Only counts answers with non-empty answer strings (excludes placeholder entries).\n *\n * @param pages - Record of page ID to page data\n * @returns Aggregated attempted and correct counts\n */\nexport function recalculateTotalsFromPages(pages: Record): RecalculatedTotals {\n let attempted = 0;\n let correct = 0;\n\n for (const pageId in pages) {\n const pageData = pages[pageId];\n if (pageData && pageData.answers && Array.isArray(pageData.answers)) {\n // Filter to only non-empty answers (matches storage-service.ts behavior)\n const answered = pageData.answers.filter((a) => a.answer.trim() !== '');\n attempted += answered.length;\n correct += answered.filter((a) => a.success).length;\n }\n }\n\n return { attempted, correct };\n}\n\n/**\n * Checks if a session has expired.\n *\n * @param expiresAt - ISO 8601 expiration timestamp\n * @param now - Current time (defaults to new Date())\n * @returns True if session has expired\n */\nexport function isSessionExpired(expiresAt: string, now: Date = new Date()): boolean {\n const expiryDate = new Date(expiresAt);\n // Invalid date -> treat as expired\n if (isNaN(expiryDate.getTime())) {\n return true;\n }\n return now >= expiryDate;\n}\n\n/**\n * Masks a service ID for display (shows last N digits).\n *\n * @param serviceId - Full service ID\n * @param visibleDigits - Number of digits to show (default 4)\n * @returns Masked string like \"...1234\"\n */\nexport function maskServiceId(serviceId: string, visibleDigits: number = 4): string {\n if (!serviceId) {\n return '';\n }\n if (serviceId.length <= visibleDigits) {\n return serviceId;\n }\n if (visibleDigits === 0) {\n return '...';\n }\n return '...' + serviceId.slice(-visibleDigits);\n}\n","/**\n * Date formatting utilities for consistent timestamp display across the application.\n * Provides both display formatting (24-hour, month/date/time) and CSV export formatting (ISO 8601).\n */\n\n/**\n * Format options for timestamp display\n */\nexport type TimestampFormat = 'display' | 'csv';\n\n/**\n * Format a date for display in the instructor interface\n * @param date - Date to format\n * @returns Formatted string in \"Nov 19 14:23\" or \"11/19 14:23:45\" format (24-hour time)\n */\nfunction formatDisplayTimestamp(date: Date): string {\n // Use short month name format: \"Nov 19 14:23\"\n const month = date.toLocaleDateString('en-US', { month: 'short' });\n const day = date.getDate();\n const hours = date.getHours().toString().padStart(2, '0');\n const minutes = date.getMinutes().toString().padStart(2, '0');\n\n return `${month} ${day} ${hours}:${minutes}`;\n}\n\n/**\n * Format a date for CSV export\n * @param date - Date to format\n * @returns ISO 8601 formatted string for spreadsheet compatibility\n */\nfunction formatCSVTimestamp(date: Date): string {\n return date.toISOString();\n}\n\n/**\n * Main timestamp formatting function\n * @param date - Date to format (can be Date object or ISO string)\n * @param format - Format type ('display' for UI, 'csv' for export)\n * @returns Formatted timestamp string\n */\nexport function formatTimestamp(date: Date | string, format: TimestampFormat = 'display'): string {\n // Handle null/undefined\n if (date == null) {\n console.warn('Invalid date provided to formatTimestamp:', date);\n return 'Invalid Date';\n }\n\n const dateObj = typeof date === 'string' ? new Date(date) : date;\n\n // Validate date\n if (isNaN(dateObj.getTime())) {\n console.warn('Invalid date provided to formatTimestamp:', date);\n return 'Invalid Date';\n }\n\n return format === 'csv' ? formatCSVTimestamp(dateObj) : formatDisplayTimestamp(dateObj);\n}\n\n/**\n * Parse an ISO 8601 timestamp from storage and format for display\n * @param isoString - ISO 8601 timestamp string from IndexedDB\n * @returns Formatted display string\n */\nexport function formatStoredTimestamp(isoString: string): string {\n return formatTimestamp(isoString, 'display');\n}\n\n/**\n * Get current timestamp in ISO 8601 format for storage\n * @returns Current time as ISO 8601 string\n */\nexport function getCurrentTimestamp(): string {\n return new Date().toISOString();\n}\n","/**\n * Debouncer utility for delaying function execution\n *\n * Provides centralized debounce timer management, replacing the WeakMap pattern\n * used in the original implementation. Saves ~22 lines of duplicated code.\n *\n * @example\n * ```typescript\n * const debouncer = new Debouncer();\n *\n * // Debounce save operation\n * function handleInput(value: string) {\n * debouncer.debounce('save-answer', () => {\n * saveToDatabase(value);\n * }, 200);\n * }\n * ```\n */\n\n/**\n * Debouncer class for managing delayed function calls\n *\n * Maintains a map of timers indexed by key, allowing multiple independent\n * debounced operations.\n */\nexport class Debouncer {\n private timers = new Map>();\n\n /**\n * Debounce a function call\n *\n * If called multiple times with the same key, only the last call will execute\n * after the delay period.\n *\n * @param key - Unique identifier for this debounced operation\n * @param fn - Function to execute after delay\n * @param delay - Delay in milliseconds (default: 200ms)\n *\n * @example\n * ```typescript\n * const debouncer = new Debouncer();\n *\n * // Called multiple times rapidly\n * debouncer.debounce('auto-save', () => console.log('Saved!'), 500);\n * debouncer.debounce('auto-save', () => console.log('Saved!'), 500);\n * debouncer.debounce('auto-save', () => console.log('Saved!'), 500);\n * // Only logs \"Saved!\" once after 500ms\n * ```\n */\n debounce(key: string, fn: () => void, delay = 200): void {\n // Cancel existing timer if present\n const existing = this.timers.get(key);\n if (existing !== undefined) {\n clearTimeout(existing);\n }\n\n // Set new timer\n const timer = setTimeout(() => {\n this.timers.delete(key);\n fn();\n }, delay);\n\n this.timers.set(key, timer);\n }\n\n /**\n * Cancel a specific debounced operation\n *\n * @param key - Key of the operation to cancel\n * @returns true if a timer was cancelled, false if no timer existed\n */\n cancel(key: string): boolean {\n const timer = this.timers.get(key);\n if (timer !== undefined) {\n clearTimeout(timer);\n this.timers.delete(key);\n return true;\n }\n return false;\n }\n\n /**\n * Cancel all pending debounced operations\n *\n * @returns Number of timers that were cancelled\n */\n cancelAll(): number {\n let count = 0;\n for (const timer of this.timers.values()) {\n clearTimeout(timer);\n count++;\n }\n this.timers.clear();\n return count;\n }\n\n /**\n * Check if a debounced operation is pending\n *\n * @param key - Key to check\n * @returns true if a timer is active for this key\n */\n isPending(key: string): boolean {\n return this.timers.has(key);\n }\n\n /**\n * Get count of pending operations\n *\n * @returns Number of active timers\n */\n getPendingCount(): number {\n return this.timers.size;\n }\n}\n","/**\n * DOM helper utilities\n *\n * Provides type-safe DOM query and manipulation helpers, eliminating\n * repetitive querySelector patterns. Saves ~80 lines of duplicated code.\n *\n * All functions use textContent instead of innerHTML to prevent XSS vulnerabilities.\n */\n\n/**\n * Get all rows from a table body\n *\n * @param table - Table element\n * @returns Array of table row elements\n *\n * @example\n * ```typescript\n * const table = document.querySelector('table.qd-quiz');\n * if (table instanceof HTMLTableElement) {\n * const rows = getTableRows(table);\n * console.log(`Table has ${rows.length} rows`);\n * }\n * ```\n */\nexport function getTableRows(table: HTMLTableElement): HTMLTableRowElement[] {\n const tbody = table.querySelector('tbody');\n if (!tbody) {\n return [];\n }\n return Array.from(tbody.querySelectorAll('tr'));\n}\n\n/**\n * Get all cells from a table row\n *\n * @param row - Table row element\n * @returns Array of table cell elements\n *\n * @example\n * ```typescript\n * const row = table.querySelector('tr');\n * if (row instanceof HTMLTableRowElement) {\n * const cells = getRowCells(row);\n * console.log(`Row has ${cells.length} cells`);\n * }\n * ```\n */\nexport function getRowCells(row: HTMLTableRowElement): HTMLTableCellElement[] {\n return Array.from(row.cells);\n}\n\n/**\n * Get trimmed text content from an element\n *\n * Returns empty string if element is null or has no text content.\n *\n * @param element - Element to get text from\n * @returns Trimmed text content\n *\n * @example\n * ```typescript\n * const cell = row.cells[0];\n * const text = getTextContent(cell);\n * console.log('Cell text:', text);\n * ```\n */\nexport function getTextContent(element: Element | null): string {\n if (!element) {\n return '';\n }\n return element.textContent?.trim() || '';\n}\n\n/**\n * Set text content on an element (XSS-safe)\n *\n * Uses textContent instead of innerHTML to prevent XSS attacks.\n *\n * @param element - Element to set text on\n * @param text - Text content to set\n *\n * @example\n * ```typescript\n * const div = document.createElement('div');\n * setTextContent(div, 'Safe text content');\n * ```\n */\nexport function setTextContent(element: Element, text: string): void {\n element.textContent = text;\n}\n\n/**\n * Create an element with optional text and class name (XSS-safe)\n *\n * Uses textContent instead of innerHTML for XSS protection.\n *\n * @param tag - HTML tag name\n * @param text - Optional text content\n * @param className - Optional class name\n * @returns Created element\n *\n * @example\n * ```typescript\n * const div = createElement('div', 'Hello, World!', 'greeting');\n * document.body.appendChild(div);\n * ```\n */\nexport function createElement(\n tag: K,\n text?: string,\n className?: string,\n): HTMLElementTagNameMap[K] {\n const element = document.createElement(tag);\n\n if (text !== undefined) {\n element.textContent = text;\n }\n\n if (className !== undefined) {\n element.className = className;\n }\n\n return element;\n}\n\n/**\n * Create multiple child elements and append to parent (XSS-safe)\n *\n * @param parent - Parent element\n * @param children - Array of child elements to append\n *\n * @example\n * ```typescript\n * const div = createElement('div');\n * appendChildren(div, [\n * createElement('span', 'First'),\n * createElement('span', 'Second'),\n * ]);\n * ```\n */\nexport function appendChildren(parent: Element, children: Element[]): void {\n for (const child of children) {\n parent.appendChild(child);\n }\n}\n\n/**\n * Query selector with type safety\n *\n * @param selector - CSS selector\n * @param parent - Parent element (default: document)\n * @returns Element or null\n *\n * @example\n * ```typescript\n * const table = querySelector('table.qd-quiz');\n * if (table) {\n * const rows = getTableRows(table);\n * }\n * ```\n */\nexport function querySelector(\n selector: string,\n parent: ParentNode = document,\n): T | null {\n return parent.querySelector(selector);\n}\n\n/**\n * Query selector all with type safety\n *\n * @param selector - CSS selector\n * @param parent - Parent element (default: document)\n * @returns Array of elements\n *\n * @example\n * ```typescript\n * const tables = querySelectorAll('table.qd-quiz');\n * console.log(`Found ${tables.length} quiz tables`);\n * ```\n */\nexport function querySelectorAll(\n selector: string,\n parent: ParentNode = document,\n): T[] {\n return Array.from(parent.querySelectorAll(selector));\n}\n\n/**\n * Get element by ID with type safety\n *\n * @param id - Element ID\n * @returns Element or null\n *\n * @example\n * ```typescript\n * const status = getElementById('qd-status');\n * if (status) {\n * status.style.display = 'block';\n * }\n * ```\n */\nexport function getElementById(id: string): T | null {\n const element = document.getElementById(id);\n return element as T | null;\n}\n\n/**\n * Remove all children from an element\n *\n * @param element - Element to clear\n *\n * @example\n * ```typescript\n * const container = getElementById('results');\n * if (container) {\n * removeAllChildren(container);\n * }\n * ```\n */\nexport function removeAllChildren(element: Element): void {\n while (element.firstChild) {\n element.removeChild(element.firstChild);\n }\n}\n\n/**\n * Replace all children of an element with new children\n *\n * @param element - Element to update\n * @param children - New children to add\n *\n * @example\n * ```typescript\n * const container = getElementById('results');\n * if (container) {\n * replaceChildren(container, [\n * createElement('div', 'Result 1'),\n * createElement('div', 'Result 2'),\n * ]);\n * }\n * ```\n */\nexport function replaceChildren(element: Element, children: Element[]): void {\n removeAllChildren(element);\n appendChildren(element, children);\n}\n\n/**\n * Check if element has a specific class\n *\n * @param element - Element to check\n * @param className - Class name to look for\n * @returns true if element has the class\n */\nexport function hasClass(element: Element, className: string): boolean {\n return element.classList.contains(className);\n}\n\n/**\n * Add one or more classes to an element\n *\n * @param element - Element to modify\n * @param classNames - Class names to add\n */\nexport function addClass(element: Element, ...classNames: string[]): void {\n element.classList.add(...classNames);\n}\n\n/**\n * Remove one or more classes from an element\n *\n * @param element - Element to modify\n * @param classNames - Class names to remove\n */\nexport function removeClass(element: Element, ...classNames: string[]): void {\n element.classList.remove(...classNames);\n}\n\n/**\n * Toggle a class on an element\n *\n * @param element - Element to modify\n * @param className - Class name to toggle\n * @returns true if class was added, false if removed\n */\nexport function toggleClass(element: Element, className: string): boolean {\n return element.classList.toggle(className);\n}\n","/**\n * Event helper utilities\n *\n * Provides type-safe custom event emission and handling, with consistent\n * configuration for bubbling and composition. Saves ~8 lines per event emission.\n */\n\nimport type { QuizEvents } from '../types/contracts.js';\n\n/**\n * Emit a custom event on the document\n *\n * Events bubble by default and are composed (cross shadow DOM boundaries).\n *\n * @param name - Event name (should use 'qd:' namespace)\n * @param detail - Event detail data\n * @param options - Optional event configuration\n *\n * @example\n * ```typescript\n * // Emit login event\n * emitCustomEvent('qd:login', {\n * serviceId: 'RN2344',\n * name: 'John Doe',\n * loginTime: new Date().toISOString(),\n * });\n * ```\n */\nexport function emitCustomEvent(\n name: K,\n detail: QuizEvents[K]['detail'],\n options?: {\n bubbles?: boolean;\n composed?: boolean;\n cancelable?: boolean;\n },\n): boolean {\n const event = new CustomEvent(name, {\n detail,\n bubbles: options?.bubbles ?? true,\n composed: options?.composed ?? true,\n cancelable: options?.cancelable ?? false,\n });\n\n return document.dispatchEvent(event);\n}\n\n/**\n * Add event listener for custom event\n *\n * @param name - Event name\n * @param handler - Event handler function\n * @param options - Optional event listener options\n *\n * @example\n * ```typescript\n * // Listen for login events\n * const unsubscribe = addEventListener('qd:login', (event) => {\n * console.log('User logged in:', event.detail.serviceId);\n * });\n *\n * // Later: remove listener\n * unsubscribe();\n * ```\n */\nexport function addEventListener(\n name: K,\n handler: (event: CustomEvent) => void,\n options?: AddEventListenerOptions,\n): () => void {\n const listener = handler as EventListener;\n document.addEventListener(name, listener, options);\n\n // Return unsubscribe function\n return () => {\n document.removeEventListener(name, listener, options);\n };\n}\n\n/**\n * Remove event listener for custom event\n *\n * @param name - Event name\n * @param handler - Event handler function\n * @param options - Optional event listener options\n *\n * @example\n * ```typescript\n * function handleLogin(event) {\n * console.log('Logged in:', event.detail.serviceId);\n * }\n *\n * addEventListener('qd:login', handleLogin);\n * // Later...\n * removeEventListener('qd:login', handleLogin);\n * ```\n */\nexport function removeEventListener(\n name: K,\n handler: (event: CustomEvent) => void,\n options?: EventListenerOptions,\n): void {\n const listener = handler as EventListener;\n document.removeEventListener(name, listener, options);\n}\n\n/**\n * Add one-time event listener that auto-removes after first trigger\n *\n * @param name - Event name\n * @param handler - Event handler function\n *\n * @example\n * ```typescript\n * // Wait for login, then perform action once\n * addEventListenerOnce('qd:login', (event) => {\n * console.log('First login detected');\n * });\n * ```\n */\nexport function addEventListenerOnce(\n name: K,\n handler: (event: CustomEvent) => void,\n): void {\n const listener = handler as EventListener;\n document.addEventListener(name, listener, { once: true });\n}\n\n/**\n * Wait for a specific event to occur\n *\n * Returns a promise that resolves when the event is emitted.\n *\n * @param name - Event name to wait for\n * @param timeout - Optional timeout in milliseconds\n * @returns Promise that resolves with event detail\n *\n * @example\n * ```typescript\n * // Wait for login\n * const session = await waitForEvent('qd:login', 5000);\n * console.log('User logged in:', session.serviceId);\n * ```\n */\nexport function waitForEvent(\n name: K,\n timeout?: number,\n): Promise {\n return new Promise((resolve, reject) => {\n let timeoutId: ReturnType | undefined;\n\n const handler = (event: Event) => {\n if (timeoutId !== undefined) {\n clearTimeout(timeoutId);\n }\n const customEvent = event as CustomEvent;\n resolve(customEvent.detail);\n };\n\n document.addEventListener(name, handler, { once: true });\n\n if (timeout !== undefined) {\n timeoutId = setTimeout(() => {\n document.removeEventListener(name, handler);\n reject(new Error(`Timeout waiting for event: ${name}`));\n }, timeout);\n }\n });\n}\n\n/**\n * Dispatch event on a specific element\n *\n * @param element - Element to dispatch event on\n * @param name - Event name\n * @param detail - Event detail\n * @param options - Optional event configuration\n *\n * @example\n * ```typescript\n * const button = document.querySelector('button');\n * if (button) {\n * dispatchEventOn(button, 'qd:custom', { data: 'test' });\n * }\n * ```\n */\nexport function dispatchEventOn(\n element: Element,\n name: string,\n detail: T,\n options?: {\n bubbles?: boolean;\n composed?: boolean;\n cancelable?: boolean;\n },\n): boolean {\n const event = new CustomEvent(name, {\n detail,\n bubbles: options?.bubbles ?? true,\n composed: options?.composed ?? true,\n cancelable: options?.cancelable ?? false,\n });\n\n return element.dispatchEvent(event);\n}\n","/**\n * Storage helper utilities\n *\n * Provides type-safe JSON storage operations for sessionStorage,\n * replacing repetitive try-catch JSON.parse patterns. Saves ~54 lines\n * of duplicated code.\n */\n\nimport { warn } from './logger.js';\n\n/**\n * Get and parse JSON data from sessionStorage\n *\n * @param key - Storage key\n * @returns Parsed object of type T, or null if not found or invalid\n *\n * @example\n * ```typescript\n * interface SessionData {\n * userId: string;\n * loginTime: string;\n * }\n *\n * const session = getJSON('qd/session');\n * if (session) {\n * console.log('User ID:', session.userId);\n * }\n * ```\n */\nexport function getJSON(key: string): T | null {\n try {\n const data = sessionStorage.getItem(key);\n if (!data) {\n return null;\n }\n return JSON.parse(data) as T;\n } catch (error) {\n warn(`Failed to parse JSON from sessionStorage key: ${key}`, error);\n return null;\n }\n}\n\n/**\n * Stringify and store JSON data in sessionStorage\n *\n * @param key - Storage key\n * @param value - Data to store\n * @returns true if successful, false if failed\n *\n * @example\n * ```typescript\n * const session = {\n * userId: 'RN2344',\n * loginTime: new Date().toISOString(),\n * };\n *\n * setJSON('qd/session', session);\n * ```\n */\nexport function setJSON(key: string, value: T): boolean {\n try {\n const json = JSON.stringify(value);\n sessionStorage.setItem(key, json);\n return true;\n } catch (error) {\n warn(`Failed to store JSON in sessionStorage key: ${key}`, error);\n return false;\n }\n}\n\n/**\n * Remove item from sessionStorage\n *\n * @param key - Storage key to remove\n */\nexport function removeItem(key: string): void {\n sessionStorage.removeItem(key);\n}\n\n/**\n * Check if key exists in sessionStorage\n *\n * @param key - Storage key to check\n * @returns true if key exists\n */\nexport function hasItem(key: string): boolean {\n return sessionStorage.getItem(key) !== null;\n}\n\n/**\n * Clear all quiz data from sessionStorage\n *\n * Only removes keys with 'qd/' prefix, leaving other data intact.\n *\n * @returns Number of items cleared\n *\n * @example\n * ```typescript\n * // Clear all quiz-related session data\n * const cleared = clearQuizData();\n * console.log(`Cleared ${cleared} items`);\n * ```\n */\nexport function clearQuizData(): number {\n const keysToRemove: string[] = [];\n\n // Find all keys with 'qd/' prefix\n for (let i = 0; i < sessionStorage.length; i++) {\n const key = sessionStorage.key(i);\n if (key && key.startsWith('qd/')) {\n keysToRemove.push(key);\n }\n }\n\n // Remove found keys\n for (const key of keysToRemove) {\n sessionStorage.removeItem(key);\n }\n\n return keysToRemove.length;\n}\n\n/**\n * Get all quiz data keys from sessionStorage\n *\n * @returns Array of keys with 'qd/' prefix\n */\nexport function getQuizDataKeys(): string[] {\n const keys: string[] = [];\n\n for (let i = 0; i < sessionStorage.length; i++) {\n const key = sessionStorage.key(i);\n if (key && key.startsWith('qd/')) {\n keys.push(key);\n }\n }\n\n return keys;\n}\n\n/**\n * Clear all sessionStorage data\n *\n * Use with caution - clears everything, not just quiz data.\n */\nexport function clearAll(): void {\n sessionStorage.clear();\n}\n","/**\n * Storage Adapter Utilities\n *\n * Provides utility functions for working with storage keys, validation,\n * and error types for the storage layer.\n *\n * Storage Key Format: qd/{release}/u{serviceId}\n * Example: qd/11-2024/uRN2344\n */\n\nimport type { StudentRecord, ReleaseId, ServiceId } from '../../types/contracts.js';\nimport { error as logError } from '../../utils/logger.js';\n\n/**\n * Generate storage key for a student record\n *\n * Format: qd/{release}/u{serviceId}\n *\n * @param release - Release identifier (e.g., \"01-2025\")\n * @param serviceId - Service ID (e.g., \"RN2344\")\n * @returns Storage key string\n *\n * @example\n * ```typescript\n * const key = getStorageKey('11-2024', 'RN2344');\n * // Returns: \"qd/11-2024/uRN2344\"\n * ```\n */\nexport function getStorageKey(release: ReleaseId, serviceId: ServiceId): string {\n return `qd/${release}/u${serviceId}`;\n}\n\n/**\n * Parse a storage key back into its components\n *\n * @param key - Storage key to parse\n * @returns Object with release and serviceId, or null if invalid\n *\n * @example\n * ```typescript\n * const parts = parseStorageKey('qd/11-2024/uRN2344');\n * // Returns: { release: '11-2024', serviceId: 'RN2344' }\n * ```\n */\nexport function parseStorageKey(key: string): { release: ReleaseId; serviceId: ServiceId } | null {\n const match = key.match(/^qd\\/([^/]+)\\/u(.+)$/);\n if (!match || !match[1] || !match[2]) {\n return null;\n }\n return {\n release: match[1],\n serviceId: match[2],\n };\n}\n\n/**\n * Validate release ID format (MM-YYYY)\n *\n * @param release - Release ID to validate\n * @returns True if valid, false otherwise\n *\n * @example\n * ```typescript\n * isValidReleaseId('11-2024'); // true\n * isValidReleaseId('2024-11'); // false\n * isValidReleaseId('13-2024'); // false (month > 12)\n * ```\n */\nexport function isValidReleaseId(release: string): boolean {\n const match = release.match(/^(\\d{2})-(\\d{4})$/);\n if (!match || !match[1] || !match[2]) {\n return false;\n }\n\n // Validate month range (01-12)\n const month = parseInt(match[1], 10);\n return month >= 1 && month <= 12;\n}\n\n/**\n * Validate service ID format (2-10 alphanumeric characters)\n *\n * @param serviceId - Service ID to validate\n * @returns True if valid, false otherwise\n *\n * @example\n * ```typescript\n * isValidServiceId('RN2344'); // true\n * isValidServiceId('AB'); // true (minimum 2 chars)\n * isValidServiceId('A'); // false (too short)\n * isValidServiceId('ABCDEFGHIJK'); // false (too long)\n * ```\n */\nexport function isValidServiceId(serviceId: string): boolean {\n return /^[A-Za-z0-9]{2,10}$/.test(serviceId);\n}\n\n/**\n * Create a default empty StudentRecord\n *\n * @param release - Release identifier\n * @param serviceId - Service identifier\n * @param name - Student name\n * @param docId - Document identifier\n * @returns New StudentRecord with default values\n *\n * @example\n * ```typescript\n * const record = createEmptyStudentRecord('11-2024', 'RN2344', 'Alice Student', 'doc-123');\n * // Returns StudentRecord with empty pages, 0 scores, current timestamp\n * ```\n */\nexport function createEmptyStudentRecord(\n release: ReleaseId,\n serviceId: ServiceId,\n name: string,\n docId: string,\n): StudentRecord {\n return {\n schema: 1,\n docId,\n release,\n serviceId,\n name,\n attempted: 0,\n correct: 0,\n updated: new Date().toISOString(),\n pages: {},\n };\n}\n\n/**\n * Storage adapter error types\n */\nexport class StorageError extends Error {\n constructor(\n message: string,\n public readonly operation: string,\n public readonly cause?: Error,\n ) {\n super(message);\n this.name = 'StorageError';\n\n // Log error for debugging\n if (cause) {\n logError(`Storage error in ${operation}: ${message}`, cause);\n } else {\n logError(`Storage error in ${operation}: ${message}`);\n }\n }\n}\n\n/**\n * Error thrown when storage is not initialized\n */\nexport class StorageNotInitializedError extends StorageError {\n constructor(operation: string) {\n super('Storage adapter not initialized. Call init() first.', operation);\n this.name = 'StorageNotInitializedError';\n }\n}\n\n/**\n * Error thrown when a storage operation times out\n */\nexport class StorageTimeoutError extends StorageError {\n constructor(operation: string, timeout: number) {\n super(`Storage operation timed out after ${timeout}ms`, operation);\n this.name = 'StorageTimeoutError';\n }\n}\n\n/**\n * Error thrown when storage quota is exceeded\n */\nexport class StorageQuotaError extends StorageError {\n constructor(operation: string) {\n super('Storage quota exceeded. Please clear old data or free up space.', operation);\n this.name = 'StorageQuotaError';\n }\n}\n\n/**\n * Error thrown when storage format doesn't match ENCRYPT_STORAGE setting\n *\n * This indicates a mismatch between the stored data format and the\n * current obfuscation configuration. Run migration to resolve.\n */\nexport class StorageFormatError extends StorageError {\n /** What format was expected based on ENCRYPT_STORAGE */\n public readonly expected: 'obfuscated' | 'plain';\n /** What format was actually found in storage */\n public readonly found: 'obfuscated' | 'plain';\n /** Storage key where mismatch occurred */\n public readonly storageKey: string;\n\n constructor(\n message: string,\n expected: 'obfuscated' | 'plain',\n found: 'obfuscated' | 'plain',\n storageKey: string,\n ) {\n super(message, 'formatCheck');\n this.name = 'StorageFormatError';\n this.expected = expected;\n this.found = found;\n this.storageKey = storageKey;\n }\n}\n","/**\n * Feature Flags\n *\n * Compile-time feature toggles for the Sonar Quiz System.\n * Set via environment variables at build time.\n */\n\n// Vite injects this at build time from ENCRYPT_STORAGE env var\ndeclare const __ENCRYPT_STORAGE__: boolean;\n\n/**\n * Enable storage obfuscation for IndexedDB data\n *\n * When true: StudentRecord values are XOR-encoded with base64 before storage.\n * When false: Data stored as plain JSON objects (readable in DevTools).\n *\n * IMPORTANT: Changing this flag requires data migration.\n * Use `window.SonarQuiz.migrateStorage()` to convert existing data.\n *\n * Set via environment variable at build time:\n * ENCRYPT_STORAGE=true npm run build\n *\n * @default false - Development mode (readable data)\n */\nexport const ENCRYPT_STORAGE: boolean =\n typeof __ENCRYPT_STORAGE__ !== 'undefined' ? __ENCRYPT_STORAGE__ : false;\n","/**\n * Obfuscation Utilities for IndexedDB Storage\n *\n * Provides deterrence-level obfuscation for student records.\n * Uses XOR cipher with base64 encoding - NOT cryptographic security.\n *\n * Purpose: Prevent casual inspection via browser DevTools.\n */\n\n/**\n * Magic prefix for obfuscated data\n * Used for format detection and fail-fast behavior\n */\nexport const OBFUSCATION_PREFIX = 'OBF:' as const;\n\n/**\n * Type representing obfuscated data format\n * Always starts with \"OBF:\" followed by base64 payload\n */\nexport type ObfuscatedString = `${typeof OBFUSCATION_PREFIX}${string}`;\n\n/**\n * Derive obfuscation key from Release ID\n *\n * Creates a repeating byte pattern for XOR cipher.\n * Converts each character to its char code and joins with separator.\n *\n * @param releaseId - Release ID from DOM (.wh_publication_title .title)\n * @returns Obfuscation key string\n */\nexport function deriveKey(releaseId: string): string {\n if (!releaseId) {\n return '';\n }\n // Convert each character to char code and join\n // This creates a repeating numeric pattern for XOR\n return releaseId\n .split('')\n .map((c) => c.charCodeAt(0).toString())\n .join('');\n}\n\n/**\n * XOR a string with a key\n *\n * Symmetric operation - same function encodes and decodes.\n * Key cycles if shorter than input.\n *\n * @param input - String to XOR\n * @param key - Key for XOR operation\n * @returns XOR'd string\n */\nexport function xorString(input: string, key: string): string {\n if (!input || !key) {\n return input || '';\n }\n\n const result: string[] = [];\n for (let i = 0; i < input.length; i++) {\n const inputChar = input.charCodeAt(i);\n const keyChar = key.charCodeAt(i % key.length);\n result.push(String.fromCharCode(inputChar ^ keyChar));\n }\n return result.join('');\n}\n\n/**\n * Convert string to UTF-8 bytes for safe base64 encoding\n * Handles Unicode characters correctly\n */\nfunction stringToUtf8Bytes(str: string): Uint8Array {\n return new TextEncoder().encode(str);\n}\n\n/**\n * Convert UTF-8 bytes back to string\n */\nfunction utf8BytesToString(bytes: Uint8Array): string {\n return new TextDecoder().decode(bytes);\n}\n\n/**\n * XOR bytes with key bytes (for UTF-8 safe operation)\n */\nfunction xorBytes(data: Uint8Array, key: Uint8Array): Uint8Array {\n if (key.length === 0) {\n return data;\n }\n const result = new Uint8Array(data.length);\n for (let i = 0; i < data.length; i++) {\n const dataByte = data[i];\n const keyByte = key[i % key.length];\n if (dataByte !== undefined && keyByte !== undefined) {\n result[i] = dataByte ^ keyByte;\n }\n }\n return result;\n}\n\n/**\n * Convert Uint8Array to base64 string\n */\nfunction bytesToBase64(bytes: Uint8Array): string {\n // Convert bytes to binary string\n let binary = '';\n for (let i = 0; i < bytes.length; i++) {\n const byte = bytes[i];\n if (byte !== undefined) {\n binary += String.fromCharCode(byte);\n }\n }\n return btoa(binary);\n}\n\n/**\n * Convert base64 string to Uint8Array\n */\nfunction base64ToBytes(base64: string): Uint8Array {\n const binary = atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return bytes;\n}\n\n/**\n * Encode data with obfuscation\n *\n * Process: JSON.stringify → UTF-8 bytes → XOR with key → base64 → add prefix\n *\n * @param data - Plain object to obfuscate\n * @param key - Obfuscation key (from deriveKey)\n * @returns Obfuscated string with OBF: prefix\n */\nexport function encode(data: T, key: string): ObfuscatedString {\n const json = JSON.stringify(data);\n const jsonBytes = stringToUtf8Bytes(json);\n const keyBytes = stringToUtf8Bytes(key || 'default');\n const xoredBytes = xorBytes(jsonBytes, keyBytes);\n const base64 = bytesToBase64(xoredBytes);\n return `${OBFUSCATION_PREFIX}${base64}`;\n}\n\n/**\n * Decode obfuscated data\n *\n * Process: remove prefix → base64 decode → XOR with key → UTF-8 to string → JSON.parse\n *\n * @param encoded - Obfuscated string (must start with OBF:)\n * @param key - Obfuscation key (same as used for encoding)\n * @returns Decoded object\n * @throws Error if decode fails (corrupted/tampered data)\n */\nexport function decode(encoded: ObfuscatedString, key: string): T {\n // Remove prefix\n const base64 = encoded.slice(OBFUSCATION_PREFIX.length);\n\n if (!base64) {\n throw new Error('Empty obfuscated payload');\n }\n\n // Base64 decode - throws if invalid\n let xoredBytes: Uint8Array;\n try {\n xoredBytes = base64ToBytes(base64);\n } catch {\n throw new Error('Invalid base64 in obfuscated data');\n }\n\n // XOR to get original JSON bytes\n const keyBytes = stringToUtf8Bytes(key || 'default');\n const jsonBytes = xorBytes(xoredBytes, keyBytes);\n\n // Convert to string\n let json: string;\n try {\n json = utf8BytesToString(jsonBytes);\n } catch {\n throw new Error('Failed to decode UTF-8 data - possibly corrupted');\n }\n\n // Parse JSON - throws if invalid\n try {\n return JSON.parse(json) as T;\n } catch {\n throw new Error('Failed to parse JSON - data may be corrupted or tampered');\n }\n}\n\n/**\n * Check if a value is obfuscated\n *\n * @param value - Value to check\n * @returns True if value starts with OBF: prefix\n */\nexport function isObfuscated(value: unknown): value is ObfuscatedString {\n return typeof value === 'string' && value.startsWith(OBFUSCATION_PREFIX);\n}\n","/**\n * IndexedDB Storage Adapter Implementation\n *\n * Provides persistent storage for student records using browser IndexedDB.\n * Implements atomic transactions and proper error handling.\n *\n * Database: Configured via #qd-db-name element (REQUIRED)\n * Stores: students (main data), backups (backup copies)\n * Keys: qd/{release}/u{serviceId}\n */\n\nimport type {\n StorageAdapter,\n StudentRecord,\n ReleaseId,\n ServiceId,\n PinResetEvent,\n} from '../../types/contracts.js';\nimport {\n getStorageKey,\n StorageNotInitializedError,\n StorageError,\n StorageQuotaError,\n StorageFormatError,\n} from './adapter-utils.js';\nimport { warn as logWarn, error as logError } from '../../utils/logger.js';\nimport { ENCRYPT_STORAGE } from '../../config/feature-flags.js';\nimport { encode, decode, isObfuscated, deriveKey } from './obfuscation.js';\n\n// NOTE: No default database name - must be provided by caller\n\n/** Database version - increment to force schema upgrade */\nconst DB_VERSION = 3;\n\n/** Object store names */\nconst STORE_STUDENTS = 'students';\nconst STORE_BACKUPS = 'backups';\nconst STORE_AUDIT_LOG = 'auditLog';\n\n/**\n * Backup record with metadata\n */\ninterface BackupRecord extends StudentRecord {\n /** Original storage key */\n originalKey: string;\n /** Backup timestamp */\n timestamp: string;\n}\n\n/**\n * IndexedDB implementation of StorageAdapter\n *\n * Features:\n * - Automatic schema creation with indexes\n * - Atomic transactions\n * - Quota error handling\n * - Backup functionality\n */\nexport class IndexedDBStorageAdapter implements StorageAdapter {\n private db: IDBDatabase | null = null;\n private initPromise: Promise | null = null;\n private dbName: string;\n\n /**\n * Create a new IndexedDB storage adapter\n *\n * @param dbName - Database name (REQUIRED - no default)\n */\n constructor(dbName: string) {\n if (!dbName) {\n throw new Error('FATAL: dbName is required for IndexedDBStorageAdapter');\n }\n this.dbName = dbName;\n }\n\n /**\n * Initialize the IndexedDB database\n *\n * Creates object stores and indexes on first run.\n * Safe to call multiple times - will reuse existing connection.\n *\n * @returns Promise that resolves when database is ready\n */\n async init(): Promise {\n // Return existing initialization promise if already in progress\n if (this.initPromise) {\n return this.initPromise;\n }\n\n // If already initialized, return immediately\n if (this.db) {\n return Promise.resolve();\n }\n\n this.initPromise = new Promise((resolve, reject) => {\n // Timeout for hung database operations\n const OPEN_TIMEOUT_MS = 5000;\n let timeoutId: number | undefined;\n let resolved = false;\n\n const cleanup = () => {\n if (timeoutId) {\n clearTimeout(timeoutId);\n timeoutId = undefined;\n }\n };\n\n timeoutId = window.setTimeout(() => {\n if (resolved) return;\n resolved = true;\n this.initPromise = null;\n\n logWarn(`IndexedDB open timed out after ${OPEN_TIMEOUT_MS}ms - attempting recovery`);\n\n // Try to delete and recreate\n const deleteReq = indexedDB.deleteDatabase(this.dbName);\n deleteReq.onsuccess = () => {\n this.init().then(resolve).catch(reject);\n };\n deleteReq.onerror = () => {\n reject(\n new StorageError(\n `Database \"${this.dbName}\" appears corrupted. Please clear site data in browser settings.`,\n 'init',\n ),\n );\n };\n deleteReq.onblocked = () => {\n reject(\n new StorageError(\n `Cannot recover database - close all other tabs with this site and reload.`,\n 'init',\n ),\n );\n };\n }, OPEN_TIMEOUT_MS);\n\n const request = indexedDB.open(this.dbName, DB_VERSION);\n\n request.onerror = () => {\n if (resolved) return;\n resolved = true;\n cleanup();\n logError(`IndexedDB open error: ${request.error?.message || 'unknown'}`);\n this.initPromise = null;\n reject(new StorageError('Failed to open database', 'init', request.error as Error));\n };\n\n request.onblocked = () => {\n logWarn('IndexedDB open blocked - close other tabs with this database');\n };\n\n request.onsuccess = () => {\n if (resolved) return;\n resolved = true;\n cleanup();\n\n this.db = request.result;\n\n // Verify object stores exist - if not, database is corrupted\n if (\n !this.db.objectStoreNames.contains(STORE_STUDENTS) ||\n !this.db.objectStoreNames.contains(STORE_BACKUPS) ||\n !this.db.objectStoreNames.contains(STORE_AUDIT_LOG)\n ) {\n // Database exists but stores missing - delete and recreate\n logWarn(\n `Database corrupted (missing stores). Found: [${Array.from(this.db.objectStoreNames).join(', ')}]`,\n );\n this.db.close();\n this.db = null;\n\n // Delete corrupted database\n const deleteRequest = indexedDB.deleteDatabase(this.dbName);\n deleteRequest.onsuccess = () => {\n // Retry initialization\n this.initPromise = null;\n this.init().then(resolve).catch(reject);\n };\n deleteRequest.onerror = () => {\n this.initPromise = null;\n reject(\n new StorageError(\n 'Failed to delete corrupted database',\n 'init',\n deleteRequest.error as Error,\n ),\n );\n };\n return;\n }\n\n this.initPromise = null;\n resolve();\n };\n\n request.onupgradeneeded = (event) => {\n const db = (event.target as IDBOpenDBRequest).result;\n const transaction = (event.target as IDBOpenDBRequest).transaction;\n\n if (transaction) {\n transaction.onerror = () => {\n logError(`Upgrade transaction error: ${transaction.error?.message || 'unknown'}`);\n };\n transaction.onabort = () => {\n logError(`Upgrade transaction aborted: ${transaction.error?.message || 'unknown'}`);\n };\n }\n\n try {\n // Create students object store\n if (!db.objectStoreNames.contains(STORE_STUDENTS)) {\n const studentsStore = db.createObjectStore(STORE_STUDENTS, { keyPath: null });\n studentsStore.createIndex('by-release', 'release', { unique: false });\n studentsStore.createIndex('by-service-id', 'serviceId', { unique: false });\n }\n\n // Create backups object store\n if (!db.objectStoreNames.contains(STORE_BACKUPS)) {\n const backupsStore = db.createObjectStore(STORE_BACKUPS, { keyPath: null });\n backupsStore.createIndex('by-original-key', 'originalKey', { unique: false });\n backupsStore.createIndex('by-timestamp', 'timestamp', { unique: false });\n }\n\n // Create audit log object store (v3 - PIN reset events)\n if (!db.objectStoreNames.contains(STORE_AUDIT_LOG)) {\n const auditStore = db.createObjectStore(STORE_AUDIT_LOG, {\n keyPath: 'eventId',\n });\n auditStore.createIndex('by-service-id', 'serviceId', { unique: false });\n auditStore.createIndex('by-reset-at', 'resetAt', { unique: false });\n }\n } catch (err) {\n logError('Error during database upgrade', err as Error);\n throw err;\n }\n };\n });\n\n return this.initPromise;\n }\n\n /**\n * Ensure database is initialized before operations\n *\n * @throws StorageNotInitializedError if not initialized\n * @returns Database instance\n */\n private ensureInitialized(): IDBDatabase {\n if (!this.db) {\n throw new StorageNotInitializedError('ensureInitialized');\n }\n return this.db;\n }\n\n /**\n * Get a student record by release and service ID\n *\n * Handles obfuscation based on ENCRYPT_STORAGE flag.\n * Throws StorageFormatError if data format doesn't match flag setting.\n *\n * @param release - Release identifier\n * @param serviceId - Service identifier\n * @returns Student record or null if not found\n * @throws StorageFormatError if format mismatch detected\n */\n async getStudent(release: ReleaseId, serviceId: ServiceId): Promise {\n const db = this.ensureInitialized();\n const key = getStorageKey(release, serviceId);\n\n return new Promise((resolve, reject) => {\n try {\n const transaction = db.transaction(STORE_STUDENTS, 'readonly');\n const store = transaction.objectStore(STORE_STUDENTS);\n const request = store.get(key);\n\n request.onsuccess = () => {\n const rawValue: unknown = request.result;\n\n if (rawValue === undefined || rawValue === null) {\n resolve(null);\n return;\n }\n\n // Check format and decode if needed\n try {\n const record = this.decodeStoredValue(rawValue, release, key);\n resolve(record);\n } catch (error) {\n reject(error instanceof Error ? error : new Error(String(error)));\n }\n };\n\n request.onerror = () => {\n reject(\n new StorageError('Failed to get student record', 'getStudent', request.error as Error),\n );\n };\n } catch (error) {\n reject(new StorageError('Failed to get student record', 'getStudent', error as Error));\n }\n });\n }\n\n /**\n * Decode stored value, checking format matches ENCRYPT_STORAGE setting\n *\n * @param rawValue - Value from IndexedDB (may be obfuscated string or plain object)\n * @param release - Release ID for key derivation\n * @param storageKey - Storage key for error reporting\n * @returns Decoded StudentRecord\n * @throws StorageFormatError if format doesn't match setting\n */\n private decodeStoredValue(\n rawValue: unknown,\n release: ReleaseId,\n storageKey: string,\n ): StudentRecord {\n const storedIsObfuscated = isObfuscated(rawValue);\n\n // Check for format mismatch\n if (ENCRYPT_STORAGE && !storedIsObfuscated) {\n throw new StorageFormatError(\n 'Unobfuscated data found with ENCRYPT_STORAGE enabled. Run migration to encrypt existing data.',\n 'obfuscated',\n 'plain',\n storageKey,\n );\n }\n\n if (!ENCRYPT_STORAGE && storedIsObfuscated) {\n throw new StorageFormatError(\n 'Obfuscated data found with ENCRYPT_STORAGE disabled. Run migration to decrypt or re-enable encryption.',\n 'plain',\n 'obfuscated',\n storageKey,\n );\n }\n\n // Decode if encrypted, otherwise return as-is\n if (ENCRYPT_STORAGE && storedIsObfuscated) {\n const obfKey = deriveKey(release);\n try {\n return decode(rawValue, obfKey);\n } catch (error) {\n throw new StorageError(\n 'Failed to decode obfuscated data - data may be corrupted or tampered',\n 'getStudent',\n error instanceof Error ? error : new Error(String(error)),\n );\n }\n }\n\n return rawValue as StudentRecord;\n }\n\n /**\n * Save a student record\n *\n * Encodes data if ENCRYPT_STORAGE is enabled.\n *\n * @param record - Student record to save\n * @throws StorageQuotaError if storage quota exceeded\n */\n async saveStudent(record: StudentRecord): Promise {\n const db = this.ensureInitialized();\n const key = getStorageKey(record.release, record.serviceId);\n\n // Encode if encryption enabled\n const valueToStore = ENCRYPT_STORAGE ? encode(record, deriveKey(record.release)) : record;\n\n return new Promise((resolve, reject) => {\n try {\n const transaction = db.transaction(STORE_STUDENTS, 'readwrite');\n const store = transaction.objectStore(STORE_STUDENTS);\n const request = store.put(valueToStore, key);\n\n request.onsuccess = () => {\n resolve();\n };\n\n request.onerror = () => {\n // Check for quota errors\n if (request.error?.name === 'QuotaExceededError') {\n reject(new StorageQuotaError('saveStudent'));\n } else {\n reject(\n new StorageError(\n 'Failed to save student record',\n 'saveStudent',\n request.error as Error,\n ),\n );\n }\n };\n\n transaction.onerror = () => {\n reject(\n new StorageError(\n 'Transaction failed while saving student',\n 'saveStudent',\n transaction.error as Error,\n ),\n );\n };\n } catch (error) {\n reject(new StorageError('Failed to save student record', 'saveStudent', error as Error));\n }\n });\n }\n\n /**\n * Get all students for a specific release\n *\n * When ENCRYPT_STORAGE is disabled, uses the by-release index for efficient queries.\n * When ENCRYPT_STORAGE is enabled, performs full scan and decodes all records.\n *\n * @param release - Release identifier\n * @returns Array of student records (empty if none found)\n */\n async getStudentsByRelease(release: ReleaseId): Promise {\n const db = this.ensureInitialized();\n\n // When encrypted, index won't work - need full scan\n if (ENCRYPT_STORAGE) {\n return this.getStudentsByReleaseEncrypted(release);\n }\n\n return new Promise((resolve, reject) => {\n try {\n const transaction = db.transaction(STORE_STUDENTS, 'readonly');\n const store = transaction.objectStore(STORE_STUDENTS);\n const index = store.index('by-release');\n const request = index.getAll(release);\n\n request.onsuccess = () => {\n resolve(request.result || []);\n };\n\n request.onerror = () => {\n reject(\n new StorageError(\n 'Failed to get students by release',\n 'getStudentsByRelease',\n request.error as Error,\n ),\n );\n };\n } catch (error) {\n reject(\n new StorageError(\n 'Failed to get students by release',\n 'getStudentsByRelease',\n error as Error,\n ),\n );\n }\n });\n }\n\n /**\n * Get all students for a release when encryption is enabled\n *\n * Performs full scan, decodes each record, and filters by release.\n */\n private async getStudentsByReleaseEncrypted(release: ReleaseId): Promise {\n const db = this.ensureInitialized();\n const obfKey = deriveKey(release);\n\n return new Promise((resolve, reject) => {\n try {\n const transaction = db.transaction(STORE_STUDENTS, 'readonly');\n const store = transaction.objectStore(STORE_STUDENTS);\n const request = store.openCursor();\n const results: StudentRecord[] = [];\n\n request.onsuccess = () => {\n const cursor = request.result;\n if (cursor) {\n const rawValue: unknown = cursor.value;\n if (isObfuscated(rawValue)) {\n try {\n const record = decode(rawValue, obfKey);\n if (record.release === release) {\n results.push(record);\n }\n } catch {\n // Skip corrupted records, log warning\n const keyStr =\n typeof cursor.key === 'string' ? cursor.key : JSON.stringify(cursor.key);\n logWarn(`Skipping corrupted record at key: ${keyStr}`);\n }\n }\n cursor.continue();\n } else {\n resolve(results);\n }\n };\n\n request.onerror = () => {\n reject(\n new StorageError(\n 'Failed to get students by release',\n 'getStudentsByRelease',\n request.error as Error,\n ),\n );\n };\n } catch (error) {\n reject(\n new StorageError(\n 'Failed to get students by release',\n 'getStudentsByRelease',\n error as Error,\n ),\n );\n }\n });\n }\n\n /**\n * Clear all data from the database\n *\n * Removes both students and backups in a single atomic transaction.\n */\n async clearAll(): Promise {\n const db = this.ensureInitialized();\n\n return new Promise((resolve, reject) => {\n try {\n const transaction = db.transaction(\n [STORE_STUDENTS, STORE_BACKUPS, STORE_AUDIT_LOG],\n 'readwrite',\n );\n\n const studentsStore = transaction.objectStore(STORE_STUDENTS);\n const backupsStore = transaction.objectStore(STORE_BACKUPS);\n const auditStore = transaction.objectStore(STORE_AUDIT_LOG);\n\n const clearStudentsRequest = studentsStore.clear();\n const clearBackupsRequest = backupsStore.clear();\n const clearAuditRequest = auditStore.clear();\n\n let studentsCleared = false;\n let backupsCleared = false;\n let auditCleared = false;\n\n clearStudentsRequest.onsuccess = () => {\n studentsCleared = true;\n if (backupsCleared && auditCleared) {\n resolve();\n }\n };\n\n clearBackupsRequest.onsuccess = () => {\n backupsCleared = true;\n if (studentsCleared && auditCleared) {\n resolve();\n }\n };\n\n clearAuditRequest.onsuccess = () => {\n auditCleared = true;\n if (studentsCleared && backupsCleared) {\n resolve();\n }\n };\n\n clearStudentsRequest.onerror = () => {\n reject(\n new StorageError(\n 'Failed to clear students',\n 'clearAll',\n clearStudentsRequest.error as Error,\n ),\n );\n };\n\n clearBackupsRequest.onerror = () => {\n reject(\n new StorageError(\n 'Failed to clear backups',\n 'clearAll',\n clearBackupsRequest.error as Error,\n ),\n );\n };\n\n clearAuditRequest.onerror = () => {\n reject(\n new StorageError(\n 'Failed to clear audit log',\n 'clearAll',\n clearAuditRequest.error as Error,\n ),\n );\n };\n\n transaction.onerror = () => {\n reject(\n new StorageError(\n 'Transaction failed during clearAll',\n 'clearAll',\n transaction.error as Error,\n ),\n );\n };\n } catch (error) {\n reject(new StorageError('Failed to clear all data', 'clearAll', error as Error));\n }\n });\n }\n\n /**\n * Create a backup of a student record\n *\n * Backup key format: backup_{timestamp}_{serviceId}\n *\n * @param record - Student record to backup\n * @throws StorageQuotaError if storage quota exceeded\n */\n async backup(record: StudentRecord): Promise {\n const db = this.ensureInitialized();\n const timestamp = new Date().toISOString();\n const backupKey = `backup_${timestamp}_${record.serviceId}`;\n const originalKey = getStorageKey(record.release, record.serviceId);\n\n const backupRecord: BackupRecord = {\n ...record,\n originalKey,\n timestamp,\n };\n\n return new Promise((resolve, reject) => {\n try {\n const transaction = db.transaction(STORE_BACKUPS, 'readwrite');\n const store = transaction.objectStore(STORE_BACKUPS);\n const request = store.put(backupRecord, backupKey);\n\n request.onsuccess = () => {\n resolve();\n };\n\n request.onerror = () => {\n // Check for quota errors\n if (request.error?.name === 'QuotaExceededError') {\n reject(new StorageQuotaError('backup'));\n } else {\n reject(new StorageError('Failed to create backup', 'backup', request.error as Error));\n }\n };\n\n transaction.onerror = () => {\n reject(\n new StorageError(\n 'Transaction failed during backup',\n 'backup',\n transaction.error as Error,\n ),\n );\n };\n } catch (error) {\n reject(new StorageError('Failed to create backup', 'backup', error as Error));\n }\n });\n }\n\n /**\n * Save a PIN reset event to the audit log\n *\n * @param event - PIN reset event to log\n */\n async saveAuditEvent(event: PinResetEvent): Promise {\n const db = this.ensureInitialized();\n\n return new Promise((resolve, reject) => {\n try {\n const transaction = db.transaction(STORE_AUDIT_LOG, 'readwrite');\n const store = transaction.objectStore(STORE_AUDIT_LOG);\n const request = store.add(event);\n\n request.onsuccess = () => {\n resolve();\n };\n\n request.onerror = () => {\n reject(\n new StorageError(\n 'Failed to save audit event',\n 'saveAuditEvent',\n request.error as Error,\n ),\n );\n };\n } catch (error) {\n reject(new StorageError('Failed to save audit event', 'saveAuditEvent', error as Error));\n }\n });\n }\n\n /**\n * Close the database connection\n *\n * Useful for cleanup in tests and application shutdown.\n */\n close(): void {\n if (this.db) {\n this.db.close();\n this.db = null;\n this.initPromise = null;\n }\n }\n}\n\n/**\n * Singleton storage adapter instance\n */\nlet storageInstance: IndexedDBStorageAdapter | null = null;\nlet currentDbName: string | null = null;\n\n/**\n * Get the singleton storage adapter instance\n *\n * Creates a new instance on first call, reuses it thereafter.\n * If dbName changes, closes old instance and creates new one.\n *\n * @param dbName - Database name (REQUIRED - no default)\n * @returns IndexedDB storage adapter\n */\nexport function getStorageAdapter(dbName: string): IndexedDBStorageAdapter {\n if (!dbName) {\n throw new Error('FATAL: dbName is required for getStorageAdapter()');\n }\n\n // If dbName changed, close old instance and create new one\n if (storageInstance && currentDbName !== dbName) {\n storageInstance.close();\n storageInstance = null;\n }\n\n if (!storageInstance) {\n storageInstance = new IndexedDBStorageAdapter(dbName);\n currentDbName = dbName;\n }\n return storageInstance;\n}\n\n/**\n * Reset the singleton instance\n *\n * Useful for testing to ensure clean state between tests.\n */\nexport function resetStorageAdapter(): void {\n if (storageInstance) {\n storageInstance.close();\n storageInstance = null;\n currentDbName = null;\n }\n}\n","/**\n * Completion State Calculator\n *\n * Functions for calculating page completion states based on answer data.\n *\n * State Rules (from CLAUDE.md):\n * - unstarted: No answers provided\n * - incomplete: Some answered OR any incorrect\n * - complete: All answered AND all correct\n */\n\nimport type { AnswerRecord, CompletionState } from '../types/contracts.js';\n\n/**\n * Calculate the completion state for a page\n *\n * @param answers - Array of answer records for the page\n * @param totalQuestions - Total number of questions on the page\n * @returns Completion state (unstarted | incomplete | complete)\n *\n * @example\n * ```typescript\n * const answers = [\n * { answer: 'a', success: true, timestamp: '2024-11-16T10:00:00Z' },\n * { answer: 'b', success: false, timestamp: '2024-11-16T10:01:00Z' },\n * ];\n * const state = calculateCompletionState(answers, 3); // 'incomplete' (not all answered)\n * ```\n */\nexport function calculateCompletionState(\n answers: AnswerRecord[],\n totalQuestions: number,\n): CompletionState {\n // Handle edge case: no questions\n if (totalQuestions === 0) {\n return 'unstarted';\n }\n\n // Check if unstarted\n if (isPageUnstarted(answers)) {\n return 'unstarted';\n }\n\n // Check if complete\n if (isPageComplete(answers, totalQuestions)) {\n return 'complete';\n }\n\n // Otherwise, it's incomplete\n return 'incomplete';\n}\n\n/**\n * Check if a page is complete\n *\n * A page is complete when:\n * - All questions are answered\n * - All answered questions are correct\n *\n * @param answers - Array of answer records\n * @param totalQuestions - Total number of questions\n * @returns True if page is complete\n */\nexport function isPageComplete(answers: AnswerRecord[], totalQuestions: number): boolean {\n // Must have answered all questions\n if (answers.length !== totalQuestions) {\n return false;\n }\n\n // All answers must be correct\n return answers.every((answer) => answer.success === true);\n}\n\n/**\n * Check if a page is unstarted\n *\n * A page is unstarted when no answers have been provided.\n *\n * @param answers - Array of answer records\n * @returns True if page is unstarted\n */\nexport function isPageUnstarted(answers: AnswerRecord[]): boolean {\n return answers.length === 0;\n}\n\n/**\n * Count the number of correct answers\n *\n * @param answers - Array of answer records\n * @returns Number of correct answers\n */\nexport function countCorrectAnswers(answers: AnswerRecord[]): number {\n return answers.filter((answer) => answer.success === true).length;\n}\n\n/**\n * Calculate success percentage\n *\n * @param answers - Array of answer records\n * @param totalQuestions - Total number of questions\n * @returns Percentage of correct answers (0-100)\n *\n * @example\n * ```typescript\n * const answers = [\n * { answer: 'a', success: true, timestamp: '...' },\n * { answer: 'b', success: false, timestamp: '...' },\n * { answer: 'c', success: true, timestamp: '...' },\n * ];\n * const percentage = calculateSuccessPercentage(answers, 3); // 67 (2 out of 3 correct)\n * ```\n */\nexport function calculateSuccessPercentage(\n answers: AnswerRecord[],\n totalQuestions: number,\n): number {\n if (totalQuestions === 0) {\n return 0;\n }\n\n const correct = countCorrectAnswers(answers);\n return Math.round((correct / totalQuestions) * 100);\n}\n","/**\n * Storage Service\n *\n * Coordinates between IndexedDB persistence and sessionStorage cache.\n * Provides high-level operations for loading/saving student records.\n */\n\nimport type {\n StudentRecord,\n SessionData,\n SessionCache,\n PageData,\n PageId,\n ReleaseId,\n AnswerRecord,\n} from '../types/contracts.js';\nimport { getStorageAdapter } from './storage/indexeddb.js';\nimport { buildCacheFromRecord } from './session.js';\nimport { calculateCompletionState } from './state-calculator.js';\nimport { recalculateTotalsFromPages } from '../utils/calculation-helpers.js';\nimport { info, warn, error as logError } from '../utils/logger.js';\n\n/**\n * Storage Service for managing student records\n */\nexport class StorageService {\n private adapter;\n private dbName: string;\n\n /**\n * Create storage service with specified database name\n *\n * @param dbName - IndexedDB database name (REQUIRED - no default)\n */\n constructor(dbName: string) {\n if (!dbName) {\n throw new Error('FATAL: dbName is required for StorageService');\n }\n this.dbName = dbName;\n this.adapter = getStorageAdapter(dbName);\n }\n\n /**\n * Initialize IndexedDB storage\n */\n async init(): Promise {\n try {\n await this.adapter.init();\n info(`Storage service initialized (IndexedDB \"${this.dbName}\" ready)`);\n } catch (err) {\n logError('Failed to initialize storage service', err as Error);\n throw err;\n }\n }\n\n /**\n * Load student record from IndexedDB\n *\n * Creates a new record if none exists.\n *\n * @param session - Current session data\n * @returns Student record\n */\n async loadStudentRecord(session: SessionData): Promise {\n try {\n const existing = await this.adapter.getStudent(session.release, session.serviceId);\n\n if (existing) {\n info(`Loaded student record for ${session.serviceId} from IndexedDB`);\n return existing;\n }\n\n // Create new student record\n const newRecord: StudentRecord = {\n schema: 1,\n docId: session.release, // Use release as docId\n release: session.release,\n serviceId: session.serviceId,\n name: session.name,\n attempted: 0,\n correct: 0,\n updated: new Date().toISOString(),\n pages: {},\n };\n\n info(`Created new student record for ${session.serviceId}`);\n return newRecord;\n } catch (err) {\n // If IndexedDB has schema issues, create a new record\n warn(`IndexedDB error, creating new record: ${(err as Error).message}`);\n const newRecord: StudentRecord = {\n schema: 1,\n docId: session.release,\n release: session.release,\n serviceId: session.serviceId,\n name: session.name,\n attempted: 0,\n correct: 0,\n updated: new Date().toISOString(),\n pages: {},\n };\n return newRecord;\n }\n }\n\n /**\n * Save student record to IndexedDB\n *\n * @param record - Student record to save\n */\n async saveStudentRecord(record: StudentRecord): Promise {\n try {\n // Update timestamp\n record.updated = new Date().toISOString();\n\n // Recalculate totals from pages using calculation helper\n const totals = recalculateTotalsFromPages(record.pages);\n record.attempted = totals.attempted;\n record.correct = totals.correct;\n\n await this.adapter.saveStudent(record);\n info(`Saved student record for ${record.serviceId} to IndexedDB`);\n } catch (err) {\n logError('Failed to save student record', err as Error);\n throw err;\n }\n }\n\n /**\n * Update student record with a new answer\n *\n * @param record - Current student record\n * @param pageId - Page where answer was submitted\n * @param questionIndex - Question index (0-based)\n * @param answer - Answer record\n * @param totalQuestions - Total questions on the page\n * @returns Updated student record\n */\n updateRecordWithAnswer(\n record: StudentRecord,\n pageId: PageId,\n questionIndex: number,\n answer: AnswerRecord,\n totalQuestions: number,\n ): StudentRecord {\n // Get or create page data\n const existingPage = record.pages[pageId];\n const pageData: PageData = existingPage || {\n answers: [],\n state: 'unstarted',\n };\n\n // Ensure answers array is large enough\n while (pageData.answers.length <= questionIndex) {\n pageData.answers.push({\n answer: '',\n success: false,\n timestamp: new Date().toISOString(),\n });\n }\n\n // Update answer at index (FR-015: overwrites previous answer for re-submissions)\n // Only the most recent answer is stored, with updated timestamp\n pageData.answers[questionIndex] = answer;\n\n // Update timestamps\n const now = new Date().toISOString();\n if (!pageData.firstAttempted) {\n pageData.firstAttempted = now;\n }\n pageData.lastAttempted = now;\n\n // Recalculate state\n pageData.state = calculateCompletionState(pageData.answers, totalQuestions);\n\n // Update record\n return {\n ...record,\n pages: {\n ...record.pages,\n [pageId]: pageData,\n },\n };\n }\n\n /**\n * Build session cache from student record\n *\n * @param record - Student record\n * @returns Session cache\n */\n buildCache(record: StudentRecord): SessionCache {\n return buildCacheFromRecord(record);\n }\n\n /**\n * Get all students for a release\n *\n * @param release - Release identifier\n * @returns Array of student records\n */\n async getStudentsByRelease(release: ReleaseId): Promise {\n try {\n return await this.adapter.getStudentsByRelease(release);\n } catch (err) {\n logError('Failed to get students by release', err as Error);\n throw err;\n }\n }\n\n /**\n * Clear all data from IndexedDB\n */\n async clearAll(): Promise {\n try {\n await this.adapter.clearAll();\n info('Cleared all data from IndexedDB');\n } catch (err) {\n logError('Failed to clear all data', err as Error);\n throw err;\n }\n }\n\n /**\n * Create backup of student record\n *\n * @param record - Student record to backup\n */\n async backup(record: StudentRecord): Promise {\n try {\n await this.adapter.backup(record);\n info(`Created backup for ${record.serviceId}`);\n } catch (err) {\n warn(`Failed to create backup for ${record.serviceId}`, err);\n }\n }\n}\n\n// ============================================================================\n// SINGLETON PATTERN\n// ============================================================================\n\nlet storageServiceInstance: StorageService | null = null;\nlet currentServiceDbName: string | null = null;\n\n/**\n * Get singleton storage service instance\n *\n * @param dbName - IndexedDB database name (optional, uses existing instance if available)\n */\nexport function getStorageService(dbName?: string): StorageService {\n // If instance exists and no dbName specified, return existing\n if (storageServiceInstance && !dbName) {\n return storageServiceInstance;\n }\n\n // If dbName specified and different, warn but return existing (don't break app)\n if (storageServiceInstance && dbName && currentServiceDbName !== dbName) {\n warn(\n `Storage service already initialized with dbName=\"${currentServiceDbName}\", ignoring new dbName=\"${dbName}\"`,\n );\n return storageServiceInstance;\n }\n\n // Create new instance if none exists\n if (!storageServiceInstance) {\n if (!dbName) {\n throw new Error('FATAL: dbName is required for first getStorageService() call');\n }\n storageServiceInstance = new StorageService(dbName);\n currentServiceDbName = dbName;\n }\n\n return storageServiceInstance;\n}\n\n/**\n * Reset singleton (for testing)\n */\nexport function resetStorageService(): void {\n storageServiceInstance = null;\n currentServiceDbName = null;\n}\n","/**\n * Quiz Table Enhancer\n *\n * Implements single-phase progressive enhancement for quiz tables.\n * Replaces the old two-phase (prepare/activate) pattern with a simpler\n * conditional approach based on interactive flag.\n *\n * Features:\n * - Non-interactive mode: Hide answer column for security\n * - Interactive mode: Inject input controls, validation, auto-save\n * - Uses WeakMap for metadata (not DOM attributes)\n * - Debounced auto-save to prevent excessive writes\n * - Event emission for state changes\n */\n\nimport type {\n ParsedQuizTable,\n QuizQuestion,\n AnswerRecord,\n PageId,\n SessionData,\n SessionCache,\n} from '../types/contracts.js';\nimport { parseQuizTable } from '../services/quiz-parser.js';\nimport { validateAnswer } from '../services/quiz-parser.js';\nimport { registerPageQuestions } from '../services/session.js';\nimport { getQuestionInputSpec } from '../services/question-input.js';\nimport { formatStudentAnswersForDisplay } from '../services/answer-display.js';\nimport { Debouncer } from '../utils/debouncer.js';\nimport { createElement, addClass, removeClass } from '../utils/dom-helpers.js';\nimport { emitCustomEvent } from '../utils/event-helpers.js';\nimport { getJSON, setJSON } from '../utils/storage-helpers.js';\nimport { STORAGE_KEYS } from '../types/contracts.js';\nimport { info, error as logError, warn } from '../utils/logger.js';\nimport { getStorageService } from '../services/storage-service.js';\n\n/**\n * Enhancement options\n */\nexport interface EnhanceQuizTableOptions {\n /** Whether to enable interactive controls */\n interactive: boolean;\n /** Current page ID (required for interactive mode) */\n pageId?: PageId;\n}\n\n/**\n * Quiz table metadata (stored in WeakMap)\n */\ninterface QuizTableMetadata {\n /** Parsed quiz data */\n parsed: ParsedQuizTable;\n /** Enhancement mode */\n interactive: boolean;\n /** Page ID (if interactive) */\n pageId?: PageId;\n /** Row input elements (if interactive) - can be text inputs or select dropdowns */\n inputs?: (HTMLInputElement | HTMLSelectElement)[];\n /** Debouncer for auto-save */\n debouncer?: Debouncer;\n /** Cleanup function for instructor event listeners */\n cleanupInstructorListeners?: () => void;\n}\n\n// WeakMap to store table metadata without polluting DOM\nconst tableMetadata = new WeakMap();\n\n/**\n * Enhance a quiz table with single-phase enhancement\n *\n * @param table - The quiz table element\n * @param options - Enhancement options\n * @returns true if enhancement succeeded, false if errors occurred\n *\n * @example\n * ```typescript\n * // Non-interactive mode (hide answers)\n * const table = document.querySelector('table.qd-quiz');\n * if (table) {\n * enhanceQuizTable(table, { interactive: false });\n * }\n *\n * // Interactive mode (inject controls)\n * enhanceQuizTable(table, { interactive: true, pageId: 'gram-1' });\n * ```\n */\nexport function enhanceQuizTable(\n table: HTMLTableElement,\n options: EnhanceQuizTableOptions,\n): boolean {\n // Check if already enhanced\n const existing = tableMetadata.get(table);\n let parsed: ParsedQuizTable;\n\n if (existing) {\n // If upgrading from non-interactive to interactive, proceed\n if (!existing.interactive && options.interactive) {\n info('Upgrading quiz table from non-interactive to interactive mode');\n // Reuse existing parsed data (answers already extracted before clearing DOM)\n parsed = existing.parsed;\n } else {\n // Already enhanced in same or higher mode, skip\n info('Quiz table already enhanced, skipping');\n return true;\n }\n } else {\n // Parse the table (first enhancement)\n parsed = parseQuizTable(table);\n\n // Check for parsing errors\n if (parsed.errors && parsed.errors.length > 0) {\n logError('Quiz table has validation errors:', parsed.errors);\n // Still continue enhancement to show errors visually\n }\n }\n\n // Store metadata in WeakMap\n const metadata: QuizTableMetadata = {\n parsed,\n interactive: options.interactive,\n pageId: options.pageId,\n };\n\n if (options.interactive) {\n // Validate pageId is provided for interactive mode\n if (!options.pageId) {\n logError('Interactive mode requires pageId option');\n return false;\n }\n\n info(`Preparing interactive enhancement for pageId: ${options.pageId}`);\n\n // Initialize debouncer for auto-save\n metadata.debouncer = new Debouncer();\n metadata.inputs = [];\n }\n\n tableMetadata.set(table, metadata);\n\n // Apply enhancement based on mode\n if (options.interactive) {\n const result = enhanceInteractive(table, metadata);\n if (result) {\n info(`Interactive enhancement succeeded for table with ${parsed.questions.length} questions`);\n } else {\n logError('Interactive enhancement failed');\n }\n return result;\n } else {\n return enhanceNonInteractive(table);\n }\n}\n\n/**\n * Enhance table in non-interactive mode\n * - Hide answer column (security: don't show correct answers before login)\n * - Hide detail column (security: don't show MCQ options or tolerances before login)\n *\n * @param table - Quiz table element\n * @returns true if successful\n */\nfunction enhanceNonInteractive(table: HTMLTableElement): boolean {\n // Remove colgroup to allow auto-sizing of columns\n removeColgroup(table);\n\n // Hide answer column (column index 1) - security: hide correct answers before login\n hideAnswerColumn(table);\n\n // Hide detail column (column index 2) - security: hide MCQ options/tolerances\n hideDetailColumn(table);\n\n addClass(table, 'qd-quiz-non-interactive');\n info('Quiz table enhanced in non-interactive mode');\n\n return true;\n}\n\n/**\n * Enhance table in interactive mode\n * - Inject input controls for each question\n * - Setup validation and auto-save\n * - Load existing answers from storage\n *\n * @param table - Quiz table element\n * @param metadata - Table metadata\n * @returns true if successful\n */\nfunction enhanceInteractive(table: HTMLTableElement, metadata: QuizTableMetadata): boolean {\n const { parsed, pageId, debouncer } = metadata;\n\n if (!pageId || !debouncer) {\n logError('Interactive mode requires pageId and debouncer');\n return false;\n }\n\n // Show answer column (remove qd-hidden class from non-interactive mode)\n showAnswerColumn(table);\n\n // Hide detail column in interactive mode\n // - MCQ options are now in the select dropdown\n // - Numeric tolerance is applied automatically\n hideDetailColumn(table);\n\n // Get session data\n const session = getJSON(STORAGE_KEYS.SESSION);\n if (!session) {\n logError('No active session found');\n return false;\n }\n\n // Get session cache\n let cache = getJSON(STORAGE_KEYS.CACHE);\n if (!cache) {\n info('No cache found, creating empty cache');\n cache = {\n totals: { total: 0, answered: 0, correct: 0 },\n pages: {},\n };\n } else {\n info(\n `Cache loaded: ${cache.totals.total} total questions, ${Object.keys(cache.pages).length} pages`,\n );\n }\n\n // Register page questions (updates total count in cache)\n const totalQuestions = parsed.questions.length;\n cache = registerPageQuestions(cache, pageId, totalQuestions);\n setJSON(STORAGE_KEYS.CACHE, cache);\n\n const pageCache = cache?.pages[pageId];\n const existingAnswers = pageCache?.answers || [];\n info(\n `Page ${pageId}: ${existingAnswers.length} existing answers, state: ${pageCache?.state || 'none'}`,\n );\n\n // Get all tbody rows\n const tbody = table.querySelector('tbody');\n if (!tbody) {\n logError('Quiz table has no tbody element');\n return false;\n }\n\n const rows = Array.from(tbody.querySelectorAll('tr'));\n const inputs: (HTMLInputElement | HTMLSelectElement)[] = [];\n\n // Inject controls for each question\n parsed.questions.forEach((question, index) => {\n const row = rows[index];\n if (!row) return;\n\n const cells = Array.from(row.querySelectorAll('td'));\n if (cells.length !== 3) return;\n\n const questionCell = cells[0];\n const answerCell = cells[1];\n\n if (!questionCell || !answerCell) return;\n\n // Get existing answer for this question\n const existingAnswer = existingAnswers[index];\n if (existingAnswer && existingAnswer.answer) {\n info(\n `Q${index + 1}: Pre-filling with \"${existingAnswer.answer}\" (${existingAnswer.success ? 'correct' : 'incorrect'})`,\n );\n }\n\n // Create input control based on question type\n const input = createQuestionInput(question, existingAnswer);\n inputs.push(input);\n\n // Clear answer cell styling from any previous user session\n removeClass(answerCell, 'qd-answer-correct', 'qd-answer-incorrect');\n\n // Clear answer cell and inject input\n answerCell.textContent = '';\n answerCell.appendChild(input);\n\n // Apply validation styling if answer exists\n if (existingAnswer) {\n applyValidationStyling(answerCell, existingAnswer.success);\n }\n\n // Setup auto-save on input change\n // Use 'change' for select elements (MCQ), 'input' for text inputs (numeric)\n const eventType = input.tagName === 'SELECT' ? 'change' : 'input';\n input.addEventListener(eventType, () => {\n handleAnswerInput(table, metadata, index, input.value);\n });\n });\n\n // Store input references\n metadata.inputs = inputs;\n\n // Setup instructor answer display listeners\n const showAnswersHandler = () => {\n void showStudentAnswersForTable(table, metadata);\n };\n const hideAnswersHandler = () => {\n hideStudentAnswersForTable(table);\n };\n\n document.addEventListener('qd:instructor-show-answers', showAnswersHandler);\n document.addEventListener('qd:instructor-hide-answers', hideAnswersHandler);\n\n // Check if instructor mode with toggle already enabled\n const isInstructor = sessionStorage.getItem(STORAGE_KEYS.INSTRUCTOR) === 'true';\n const showAnswers = sessionStorage.getItem('qd/instructor/showAnswers') === 'true';\n if (isInstructor && showAnswers) {\n void showStudentAnswersForTable(table, metadata);\n }\n\n // Reset UI state - used on both logout and login (new user)\n const resetUIState = () => {\n // Clear student-specific color-coded feedback\n const answerCells = table.querySelectorAll('td.qd-answer-correct, td.qd-answer-incorrect');\n answerCells.forEach((cell) => {\n removeClass(cell, 'qd-answer-correct', 'qd-answer-incorrect');\n });\n\n // Reset input values to default state\n if (metadata.inputs) {\n for (const input of metadata.inputs) {\n if (input instanceof HTMLSelectElement) {\n // Reset select to first option (\"Select an answer...\")\n input.selectedIndex = 0;\n } else if (input instanceof HTMLInputElement) {\n // Clear text input\n input.value = '';\n }\n }\n }\n\n // Clear any displayed student answers\n hideStudentAnswersForTable(table);\n };\n\n // Add logout listener to clear student-specific UI state (FR-001, FR-002)\n const logoutHandler = () => {\n resetUIState();\n info('Cleared student UI state from quiz table on logout');\n };\n\n // Add login listener to reset UI for new user (handles migration retry case)\n const loginHandler = () => {\n resetUIState();\n info('Reset quiz table UI on login');\n };\n\n document.addEventListener('qd:logout', logoutHandler);\n document.addEventListener('qd:login', loginHandler);\n\n // Store cleanup function in metadata\n metadata.cleanupInstructorListeners = () => {\n document.removeEventListener('qd:instructor-show-answers', showAnswersHandler);\n document.removeEventListener('qd:instructor-hide-answers', hideAnswersHandler);\n document.removeEventListener('qd:logout', logoutHandler);\n document.removeEventListener('qd:login', loginHandler);\n };\n\n addClass(table, 'qd-quiz-interactive');\n info(`Quiz table enhanced in interactive mode for page ${pageId}`);\n\n return true;\n}\n\n/**\n * Create input control for a question\n *\n * For MCQ questions: Creates a ` for MCQ questions, a text\n * `` for numeric questions. Extracted from `quiz-table.ts`.\n */\n\nimport type { QuizQuestion, AnswerRecord } from '../types/contracts.js';\nimport { getQuestionInputSpec } from '../services/question-input.js';\nimport { createElement } from '../utils/dom-helpers.js';\n\n/**\n * Create the input control for a question.\n *\n * @param question - Quiz question\n * @param existingAnswer - Existing answer if any (used to pre-fill)\n * @returns Input or select element\n */\nexport function createQuestionInput(\n question: QuizQuestion,\n existingAnswer?: AnswerRecord,\n): HTMLInputElement | HTMLSelectElement {\n const spec = getQuestionInputSpec(question, existingAnswer);\n\n if (spec.type === 'select') {\n // Create select dropdown for MCQ\n const select = createElement('select');\n select.className = spec.className;\n\n // Add placeholder option\n const placeholderOption = createElement('option');\n placeholderOption.value = '';\n placeholderOption.textContent = spec.placeholder;\n placeholderOption.disabled = true;\n select.appendChild(placeholderOption);\n\n // Add options from spec\n if (spec.options) {\n spec.options.forEach((opt) => {\n const option = createElement('option');\n option.value = opt.value;\n option.textContent = opt.text;\n select.appendChild(option);\n });\n }\n\n // Set value from spec\n select.value = spec.value;\n\n return select;\n }\n\n // Create text input for numeric questions\n const input = createElement('input');\n input.type = spec.type;\n input.className = spec.className;\n input.placeholder = spec.placeholder;\n input.value = spec.value;\n\n return input;\n}\n","/**\n * Question Input Service\n *\n * Pure functions for generating question input specifications.\n * No DOM dependencies - returns data structures that can be tested\n * without browser environment.\n *\n * Feature: 007-lit-component-refactor\n */\n\nimport type { QuizQuestion, AnswerRecord } from '../types/contracts.js';\n\n/**\n * Option specification for MCQ dropdowns\n */\nexport interface OptionSpec {\n value: string;\n text: string;\n}\n\n/**\n * Specification for rendering a question input\n */\nexport interface QuestionInputSpec {\n /** Input type: 'select' for MCQ, 'text' for numeric */\n type: 'select' | 'text';\n /** CSS class name */\n className: string;\n /** Placeholder text */\n placeholder: string;\n /** Current value (from existing answer or empty) */\n value: string;\n /** Options for select (MCQ only) */\n options?: OptionSpec[];\n}\n\n/**\n * Get input specification for a quiz question\n *\n * Returns a data structure describing how to render the input,\n * without creating DOM elements.\n *\n * @param question - Quiz question configuration\n * @param existingAnswer - Existing answer record (optional)\n * @returns Input specification\n */\nexport function getQuestionInputSpec(\n question: QuizQuestion,\n existingAnswer?: AnswerRecord,\n): QuestionInputSpec {\n if (question.kind === 'mcq') {\n // MCQ question - select dropdown\n const options: OptionSpec[] = (question.options || []).map((optionText, index) => ({\n value: String(index + 1), // 1-indexed\n text: `${index + 1}. ${optionText}`,\n }));\n\n return {\n type: 'select',\n className: 'qd-quiz-input',\n placeholder: 'Select an answer...',\n value: existingAnswer?.answer || '',\n options,\n };\n } else {\n // Numeric question - text input\n return {\n type: 'text',\n className: 'qd-quiz-input',\n placeholder: 'Enter value',\n value: existingAnswer?.answer || '',\n };\n }\n}\n","/**\n * Storage Adapter Utilities\n *\n * Provides utility functions for working with storage keys, validation,\n * and error types for the storage layer.\n *\n * Storage Key Format: qd/{release}/u{serviceId}\n * Example: qd/11-2024/uRN2344\n */\n\nimport type { StudentRecord, ReleaseId, ServiceId } from '../../types/contracts.js';\nimport { error as logError } from '../../utils/logger.js';\n\n/**\n * Generate storage key for a student record\n *\n * Format: qd/{release}/u{serviceId}\n *\n * @param release - Release identifier (e.g., \"01-2025\")\n * @param serviceId - Service ID (e.g., \"RN2344\")\n * @returns Storage key string\n *\n * @example\n * ```typescript\n * const key = getStorageKey('11-2024', 'RN2344');\n * // Returns: \"qd/11-2024/uRN2344\"\n * ```\n */\nexport function getStorageKey(release: ReleaseId, serviceId: ServiceId): string {\n return `qd/${release}/u${serviceId}`;\n}\n\n/**\n * Parse a storage key back into its components\n *\n * @param key - Storage key to parse\n * @returns Object with release and serviceId, or null if invalid\n *\n * @example\n * ```typescript\n * const parts = parseStorageKey('qd/11-2024/uRN2344');\n * // Returns: { release: '11-2024', serviceId: 'RN2344' }\n * ```\n */\nexport function parseStorageKey(key: string): { release: ReleaseId; serviceId: ServiceId } | null {\n const match = key.match(/^qd\\/([^/]+)\\/u(.+)$/);\n if (!match || !match[1] || !match[2]) {\n return null;\n }\n return {\n release: match[1],\n serviceId: match[2],\n };\n}\n\n/**\n * Validate release ID format (MM-YYYY)\n *\n * @param release - Release ID to validate\n * @returns True if valid, false otherwise\n *\n * @example\n * ```typescript\n * isValidReleaseId('11-2024'); // true\n * isValidReleaseId('2024-11'); // false\n * isValidReleaseId('13-2024'); // false (month > 12)\n * ```\n */\nexport function isValidReleaseId(release: string): boolean {\n const match = release.match(/^(\\d{2})-(\\d{4})$/);\n if (!match || !match[1] || !match[2]) {\n return false;\n }\n\n // Validate month range (01-12)\n const month = parseInt(match[1], 10);\n return month >= 1 && month <= 12;\n}\n\n/**\n * Validate service ID format (2-10 alphanumeric characters)\n *\n * @param serviceId - Service ID to validate\n * @returns True if valid, false otherwise\n *\n * @example\n * ```typescript\n * isValidServiceId('RN2344'); // true\n * isValidServiceId('AB'); // true (minimum 2 chars)\n * isValidServiceId('A'); // false (too short)\n * isValidServiceId('ABCDEFGHIJK'); // false (too long)\n * ```\n */\nexport function isValidServiceId(serviceId: string): boolean {\n return /^[A-Za-z0-9]{2,10}$/.test(serviceId);\n}\n\n/**\n * Create a default empty StudentRecord\n *\n * @param release - Release identifier\n * @param serviceId - Service identifier\n * @param name - Student name\n * @param docId - Document identifier\n * @returns New StudentRecord with default values\n *\n * @example\n * ```typescript\n * const record = createEmptyStudentRecord('11-2024', 'RN2344', 'Alice Student', 'doc-123');\n * // Returns StudentRecord with empty pages, 0 scores, current timestamp\n * ```\n */\nexport function createEmptyStudentRecord(\n release: ReleaseId,\n serviceId: ServiceId,\n name: string,\n docId: string,\n): StudentRecord {\n return {\n schema: 1,\n docId,\n release,\n serviceId,\n name,\n attempted: 0,\n correct: 0,\n updated: new Date().toISOString(),\n pages: {},\n };\n}\n\n/**\n * Storage adapter error types\n */\nexport class StorageError extends Error {\n constructor(\n message: string,\n public readonly operation: string,\n public readonly cause?: Error,\n ) {\n super(message);\n this.name = 'StorageError';\n\n // Log error for debugging\n if (cause) {\n logError(`Storage error in ${operation}: ${message}`, cause);\n } else {\n logError(`Storage error in ${operation}: ${message}`);\n }\n }\n}\n\n/**\n * Error thrown when storage is not initialized\n */\nexport class StorageNotInitializedError extends StorageError {\n constructor(operation: string) {\n super('Storage adapter not initialized. Call init() first.', operation);\n this.name = 'StorageNotInitializedError';\n }\n}\n\n/**\n * Error thrown when a storage operation times out\n */\nexport class StorageTimeoutError extends StorageError {\n constructor(operation: string, timeout: number) {\n super(`Storage operation timed out after ${timeout}ms`, operation);\n this.name = 'StorageTimeoutError';\n }\n}\n\n/**\n * Error thrown when storage quota is exceeded\n */\nexport class StorageQuotaError extends StorageError {\n constructor(operation: string) {\n super('Storage quota exceeded. Please clear old data or free up space.', operation);\n this.name = 'StorageQuotaError';\n }\n}\n\n/**\n * Error thrown when storage format doesn't match ENCRYPT_STORAGE setting\n *\n * This indicates a mismatch between the stored data format and the\n * current obfuscation configuration. Run migration to resolve.\n */\nexport class StorageFormatError extends StorageError {\n /** What format was expected based on ENCRYPT_STORAGE */\n public readonly expected: 'obfuscated' | 'plain';\n /** What format was actually found in storage */\n public readonly found: 'obfuscated' | 'plain';\n /** Storage key where mismatch occurred */\n public readonly storageKey: string;\n\n constructor(\n message: string,\n expected: 'obfuscated' | 'plain',\n found: 'obfuscated' | 'plain',\n storageKey: string,\n ) {\n super(message, 'formatCheck');\n this.name = 'StorageFormatError';\n this.expected = expected;\n this.found = found;\n this.storageKey = storageKey;\n }\n}\n","/**\n * IndexedDB connection management.\n *\n * Owns the database schema version, object-store names, the `onupgradeneeded`\n * schema creation, and the open/timeout/corruption-recovery flow. Extracted\n * from `indexeddb.ts` so the adapter no longer embeds connection plumbing.\n */\n\nimport { StorageError } from './adapter-utils.js';\nimport { warn as logWarn, error as logError } from '../../utils/logger.js';\n\n/** Database version - increment to force schema upgrade */\nexport const DB_VERSION = 3;\n\n/** Object store names */\nexport const STORE_STUDENTS = 'students';\nexport const STORE_BACKUPS = 'backups';\nexport const STORE_AUDIT_LOG = 'auditLog';\n\n/** Timeout for hung database open operations */\nconst OPEN_TIMEOUT_MS = 5000;\n\n/**\n * Create the object stores and indexes for a fresh or upgrading database.\n */\nfunction createSchema(event: IDBVersionChangeEvent): void {\n const db = (event.target as IDBOpenDBRequest).result;\n const transaction = (event.target as IDBOpenDBRequest).transaction;\n\n if (transaction) {\n transaction.onerror = () => {\n logError(`Upgrade transaction error: ${transaction.error?.message || 'unknown'}`);\n };\n transaction.onabort = () => {\n logError(`Upgrade transaction aborted: ${transaction.error?.message || 'unknown'}`);\n };\n }\n\n try {\n // Create students object store\n if (!db.objectStoreNames.contains(STORE_STUDENTS)) {\n const studentsStore = db.createObjectStore(STORE_STUDENTS, { keyPath: null });\n studentsStore.createIndex('by-release', 'release', { unique: false });\n studentsStore.createIndex('by-service-id', 'serviceId', { unique: false });\n }\n\n // Create backups object store\n if (!db.objectStoreNames.contains(STORE_BACKUPS)) {\n const backupsStore = db.createObjectStore(STORE_BACKUPS, { keyPath: null });\n backupsStore.createIndex('by-original-key', 'originalKey', { unique: false });\n backupsStore.createIndex('by-timestamp', 'timestamp', { unique: false });\n }\n\n // Create audit log object store (v3 - PIN reset events)\n if (!db.objectStoreNames.contains(STORE_AUDIT_LOG)) {\n const auditStore = db.createObjectStore(STORE_AUDIT_LOG, { keyPath: 'eventId' });\n auditStore.createIndex('by-service-id', 'serviceId', { unique: false });\n auditStore.createIndex('by-reset-at', 'resetAt', { unique: false });\n }\n } catch (err) {\n logError('Error during database upgrade', err as Error);\n throw err;\n }\n}\n\n/**\n * Whether the opened database is missing any required object store.\n */\nfunction isCorrupted(db: IDBDatabase): boolean {\n return (\n !db.objectStoreNames.contains(STORE_STUDENTS) ||\n !db.objectStoreNames.contains(STORE_BACKUPS) ||\n !db.objectStoreNames.contains(STORE_AUDIT_LOG)\n );\n}\n\n/**\n * Open (and if necessary create/upgrade or recover) the IndexedDB database.\n *\n * Mirrors the original adapter `init()` flow: a 5s open timeout triggers a\n * delete-and-recreate recovery, and an opened-but-store-less database is\n * treated as corrupted and recreated.\n *\n * @param dbName - Database name\n * @returns The ready database connection\n */\nexport function openDatabase(dbName: string): Promise {\n return new Promise((resolve, reject) => {\n let timeoutId: number | undefined;\n let resolved = false;\n\n const cleanup = () => {\n if (timeoutId) {\n clearTimeout(timeoutId);\n timeoutId = undefined;\n }\n };\n\n timeoutId = window.setTimeout(() => {\n if (resolved) return;\n resolved = true;\n\n logWarn(`IndexedDB open timed out after ${OPEN_TIMEOUT_MS}ms - attempting recovery`);\n\n const deleteReq = indexedDB.deleteDatabase(dbName);\n deleteReq.onsuccess = () => {\n openDatabase(dbName).then(resolve).catch(reject);\n };\n deleteReq.onerror = () => {\n reject(\n new StorageError(\n `Database \"${dbName}\" appears corrupted. Please clear site data in browser settings.`,\n 'init',\n ),\n );\n };\n deleteReq.onblocked = () => {\n reject(\n new StorageError(\n `Cannot recover database - close all other tabs with this site and reload.`,\n 'init',\n ),\n );\n };\n }, OPEN_TIMEOUT_MS);\n\n const request = indexedDB.open(dbName, DB_VERSION);\n\n request.onerror = () => {\n if (resolved) return;\n resolved = true;\n cleanup();\n logError(`IndexedDB open error: ${request.error?.message || 'unknown'}`);\n reject(new StorageError('Failed to open database', 'init', request.error as Error));\n };\n\n request.onblocked = () => {\n logWarn('IndexedDB open blocked - close other tabs with this database');\n };\n\n request.onsuccess = () => {\n if (resolved) return;\n resolved = true;\n cleanup();\n\n const db = request.result;\n\n // Verify object stores exist - if not, database is corrupted\n if (isCorrupted(db)) {\n logWarn(\n `Database corrupted (missing stores). Found: [${Array.from(db.objectStoreNames).join(', ')}]`,\n );\n db.close();\n\n const deleteRequest = indexedDB.deleteDatabase(dbName);\n deleteRequest.onsuccess = () => {\n openDatabase(dbName).then(resolve).catch(reject);\n };\n deleteRequest.onerror = () => {\n reject(\n new StorageError(\n 'Failed to delete corrupted database',\n 'init',\n deleteRequest.error as Error,\n ),\n );\n };\n return;\n }\n\n resolve(db);\n };\n\n request.onupgradeneeded = (event) => {\n createSchema(event);\n };\n });\n}\n","/**\n * IndexedDB request/transaction helpers.\n *\n * Collapse the hand-rolled `new Promise(...)` + `onsuccess`/`onerror`\n * scaffolding that the adapter repeated for every operation into two reusable\n * helpers. Quota errors are surfaced as {@link StorageQuotaError}; all other\n * failures as {@link StorageError} tagged with the operation name.\n */\n\nimport { StorageError, StorageQuotaError } from './adapter-utils.js';\n\n/**\n * Wrap a single {@link IDBRequest} in a promise.\n *\n * @param request - The IndexedDB request\n * @param op - Operation name for error reporting\n * @returns The request result when it succeeds\n */\nexport function promisifyRequest(request: IDBRequest, op: string): Promise {\n return new Promise((resolve, reject) => {\n request.onsuccess = () => resolve(request.result);\n request.onerror = () => reject(toStorageError(request.error, op));\n });\n}\n\n/**\n * Run a single-store transaction and resolve with the request result.\n *\n * @param db - Open database connection\n * @param store - Object store name\n * @param mode - Transaction mode\n * @param fn - Builds the request against the object store\n * @param op - Operation name for error reporting\n * @returns The request result when both the request and transaction succeed\n */\nexport function runTransaction(\n db: IDBDatabase,\n store: string,\n mode: IDBTransactionMode,\n fn: (objectStore: IDBObjectStore) => IDBRequest,\n op: string,\n): Promise {\n return new Promise((resolve, reject) => {\n let request: IDBRequest;\n try {\n const transaction = db.transaction(store, mode);\n const objectStore = transaction.objectStore(store);\n request = fn(objectStore);\n transaction.onerror = () => reject(toStorageError(transaction.error, op));\n } catch (error) {\n reject(new StorageError(`Failed during ${op}`, op, error as Error));\n return;\n }\n\n request.onsuccess = () => resolve(request.result);\n request.onerror = () => reject(toStorageError(request.error, op));\n });\n}\n\n/**\n * Convert a DOMException from IndexedDB into the appropriate storage error.\n */\nfunction toStorageError(error: DOMException | null, op: string): Error {\n if (error?.name === 'QuotaExceededError') {\n return new StorageQuotaError(op);\n }\n return new StorageError(`Failed during ${op}`, op, error as Error);\n}\n","/**\n * Feature Flags\n *\n * Compile-time feature toggles for the Sonar Quiz System.\n * Set via environment variables at build time.\n */\n\n// Vite injects this at build time from ENCRYPT_STORAGE env var\ndeclare const __ENCRYPT_STORAGE__: boolean;\n\n/**\n * Enable storage obfuscation for IndexedDB data\n *\n * When true: StudentRecord values are XOR-encoded with base64 before storage.\n * When false: Data stored as plain JSON objects (readable in DevTools).\n *\n * IMPORTANT: Changing this flag requires data migration.\n * Use `window.SonarQuiz.migrateObfuscation()` (exported from src/index.ts) to convert existing data.\n *\n * Set via environment variable at build time:\n * ENCRYPT_STORAGE=true npm run build\n *\n * @default false - Development mode (readable data)\n */\nexport const ENCRYPT_STORAGE: boolean =\n typeof __ENCRYPT_STORAGE__ !== 'undefined' ? __ENCRYPT_STORAGE__ : false;\n","/**\n * Obfuscation Utilities for IndexedDB Storage\n *\n * Provides deterrence-level obfuscation for student records.\n * Uses XOR cipher with base64 encoding - NOT cryptographic security.\n *\n * Purpose: Prevent casual inspection via browser DevTools.\n */\n\n/**\n * Magic prefix for obfuscated data\n * Used for format detection and fail-fast behavior\n */\nexport const OBFUSCATION_PREFIX = 'OBF:' as const;\n\n/**\n * Type representing obfuscated data format\n * Always starts with \"OBF:\" followed by base64 payload\n */\nexport type ObfuscatedString = `${typeof OBFUSCATION_PREFIX}${string}`;\n\n/**\n * Derive obfuscation key from Release ID\n *\n * Creates a repeating byte pattern for XOR cipher.\n * Converts each character to its char code and joins with separator.\n *\n * @param releaseId - Release ID from DOM (.wh_publication_title .title)\n * @returns Obfuscation key string\n */\nexport function deriveKey(releaseId: string): string {\n if (!releaseId) {\n return '';\n }\n // Convert each character to char code and join\n // This creates a repeating numeric pattern for XOR\n return releaseId\n .split('')\n .map((c) => c.charCodeAt(0).toString())\n .join('');\n}\n\n/**\n * XOR a string with a key\n *\n * Symmetric operation - same function encodes and decodes.\n * Key cycles if shorter than input.\n *\n * @param input - String to XOR\n * @param key - Key for XOR operation\n * @returns XOR'd string\n */\nexport function xorString(input: string, key: string): string {\n if (!input || !key) {\n return input || '';\n }\n\n const result: string[] = [];\n for (let i = 0; i < input.length; i++) {\n const inputChar = input.charCodeAt(i);\n const keyChar = key.charCodeAt(i % key.length);\n result.push(String.fromCharCode(inputChar ^ keyChar));\n }\n return result.join('');\n}\n\n/**\n * Convert string to UTF-8 bytes for safe base64 encoding\n * Handles Unicode characters correctly\n */\nfunction stringToUtf8Bytes(str: string): Uint8Array {\n return new TextEncoder().encode(str);\n}\n\n/**\n * Convert UTF-8 bytes back to string\n */\nfunction utf8BytesToString(bytes: Uint8Array): string {\n return new TextDecoder().decode(bytes);\n}\n\n/**\n * XOR bytes with key bytes (for UTF-8 safe operation)\n */\nfunction xorBytes(data: Uint8Array, key: Uint8Array): Uint8Array {\n if (key.length === 0) {\n return data;\n }\n const result = new Uint8Array(data.length);\n for (let i = 0; i < data.length; i++) {\n const dataByte = data[i];\n const keyByte = key[i % key.length];\n if (dataByte !== undefined && keyByte !== undefined) {\n result[i] = dataByte ^ keyByte;\n }\n }\n return result;\n}\n\n/**\n * Convert Uint8Array to base64 string\n */\nfunction bytesToBase64(bytes: Uint8Array): string {\n // Convert bytes to binary string\n let binary = '';\n for (let i = 0; i < bytes.length; i++) {\n const byte = bytes[i];\n if (byte !== undefined) {\n binary += String.fromCharCode(byte);\n }\n }\n return btoa(binary);\n}\n\n/**\n * Convert base64 string to Uint8Array\n */\nfunction base64ToBytes(base64: string): Uint8Array {\n const binary = atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return bytes;\n}\n\n/**\n * Encode data with obfuscation\n *\n * Process: JSON.stringify → UTF-8 bytes → XOR with key → base64 → add prefix\n *\n * @param data - Plain object to obfuscate\n * @param key - Obfuscation key (from deriveKey)\n * @returns Obfuscated string with OBF: prefix\n */\nexport function encode(data: T, key: string): ObfuscatedString {\n const json = JSON.stringify(data);\n const jsonBytes = stringToUtf8Bytes(json);\n const keyBytes = stringToUtf8Bytes(key || 'default');\n const xoredBytes = xorBytes(jsonBytes, keyBytes);\n const base64 = bytesToBase64(xoredBytes);\n return `${OBFUSCATION_PREFIX}${base64}`;\n}\n\n/**\n * Decode obfuscated data\n *\n * Process: remove prefix → base64 decode → XOR with key → UTF-8 to string → JSON.parse\n *\n * @param encoded - Obfuscated string (must start with OBF:)\n * @param key - Obfuscation key (same as used for encoding)\n * @returns Decoded object\n * @throws Error if decode fails (corrupted/tampered data)\n */\nexport function decode(encoded: ObfuscatedString, key: string): T {\n // Remove prefix\n const base64 = encoded.slice(OBFUSCATION_PREFIX.length);\n\n if (!base64) {\n throw new Error('Empty obfuscated payload');\n }\n\n // Base64 decode - throws if invalid\n let xoredBytes: Uint8Array;\n try {\n xoredBytes = base64ToBytes(base64);\n } catch {\n throw new Error('Invalid base64 in obfuscated data');\n }\n\n // XOR to get original JSON bytes\n const keyBytes = stringToUtf8Bytes(key || 'default');\n const jsonBytes = xorBytes(xoredBytes, keyBytes);\n\n // Convert to string\n let json: string;\n try {\n json = utf8BytesToString(jsonBytes);\n } catch {\n throw new Error('Failed to decode UTF-8 data - possibly corrupted');\n }\n\n // Parse JSON - throws if invalid\n try {\n return JSON.parse(json) as T;\n } catch {\n throw new Error('Failed to parse JSON - data may be corrupted or tampered');\n }\n}\n\n/**\n * Check if a value is obfuscated\n *\n * @param value - Value to check\n * @returns True if value starts with OBF: prefix\n */\nexport function isObfuscated(value: unknown): value is ObfuscatedString {\n return typeof value === 'string' && value.startsWith(OBFUSCATION_PREFIX);\n}\n","/**\n * IndexedDB Storage Adapter Implementation\n *\n * Thin coordinator over the storage sub-modules:\n * - `idb-connection` — open/upgrade/recovery + schema (DB_VERSION, stores)\n * - `idb-helpers` — promisify/runTransaction scaffolding\n * - `idb-codec` — encryption-aware encode/decode + format checks\n * - `backup-repository` / `audit-log-repository` — non-student stores\n *\n * Database: Configured via #qd-db-name element (REQUIRED)\n * Stores: students (main data), backups (backup copies), auditLog (PIN resets)\n * Keys: qd/{release}/u{serviceId}\n */\n\nimport type {\n StorageAdapter,\n StudentRecord,\n ReleaseId,\n ServiceId,\n PinResetEvent,\n} from '../../types/contracts.js';\nimport { getStorageKey, StorageNotInitializedError, StorageError } from './adapter-utils.js';\nimport { openDatabase, STORE_STUDENTS } from './idb-connection.js';\nimport { promisifyRequest, runTransaction } from './idb-helpers.js';\nimport {\n encodeForStore,\n decodeStoredValue,\n tryDecodeCursorValue,\n isEncryptionEnabled,\n} from './idb-codec.js';\nimport { createBackup } from './backup-repository.js';\nimport { saveAuditEvent as persistAuditEvent } from './audit-log-repository.js';\nimport { STORE_BACKUPS, STORE_AUDIT_LOG } from './idb-connection.js';\n\n/**\n * IndexedDB implementation of StorageAdapter\n *\n * Features:\n * - Automatic schema creation with indexes\n * - Atomic transactions\n * - Quota error handling\n * - Backup functionality\n */\nexport class IndexedDBStorageAdapter implements StorageAdapter {\n private db: IDBDatabase | null = null;\n private initPromise: Promise | null = null;\n private dbName: string;\n\n /**\n * Create a new IndexedDB storage adapter\n *\n * @param dbName - Database name (REQUIRED - no default)\n */\n constructor(dbName: string) {\n if (!dbName) {\n throw new Error('FATAL: dbName is required for IndexedDBStorageAdapter');\n }\n this.dbName = dbName;\n }\n\n /**\n * Initialize the IndexedDB database\n *\n * Creates object stores and indexes on first run. Safe to call multiple\n * times - will reuse the existing connection or in-flight init.\n */\n async init(): Promise {\n if (this.initPromise) {\n return this.initPromise;\n }\n if (this.db) {\n return Promise.resolve();\n }\n\n this.initPromise = openDatabase(this.dbName)\n .then((db) => {\n this.db = db;\n })\n .finally(() => {\n this.initPromise = null;\n });\n\n return this.initPromise;\n }\n\n /**\n * Ensure database is initialized before operations\n *\n * @throws StorageNotInitializedError if not initialized\n * @returns Database instance\n */\n private ensureInitialized(): IDBDatabase {\n if (!this.db) {\n throw new StorageNotInitializedError('ensureInitialized');\n }\n return this.db;\n }\n\n /**\n * Get a student record by release and service ID\n *\n * @param release - Release identifier\n * @param serviceId - Service identifier\n * @returns Student record or null if not found\n * @throws StorageFormatError if format mismatch detected\n */\n async getStudent(release: ReleaseId, serviceId: ServiceId): Promise {\n const db = this.ensureInitialized();\n const key = getStorageKey(release, serviceId);\n\n const rawValue = await runTransaction(\n db,\n STORE_STUDENTS,\n 'readonly',\n (store) => store.get(key),\n 'getStudent',\n );\n\n if (rawValue === undefined || rawValue === null) {\n return null;\n }\n\n return decodeStoredValue(rawValue, release, key);\n }\n\n /**\n * Save a student record (encodes when encryption is enabled).\n *\n * @param record - Student record to save\n * @throws StorageQuotaError if storage quota exceeded\n */\n async saveStudent(record: StudentRecord): Promise {\n const db = this.ensureInitialized();\n const key = getStorageKey(record.release, record.serviceId);\n const valueToStore = encodeForStore(record);\n\n await runTransaction(\n db,\n STORE_STUDENTS,\n 'readwrite',\n (store) => store.put(valueToStore, key),\n 'saveStudent',\n );\n }\n\n /**\n * Get all students for a specific release.\n *\n * Uses the by-release index in plain mode; performs a full decode scan when\n * encryption is enabled (the index cannot see into obfuscated values).\n *\n * @param release - Release identifier\n * @returns Array of student records (empty if none found)\n */\n async getStudentsByRelease(release: ReleaseId): Promise {\n const db = this.ensureInitialized();\n\n if (isEncryptionEnabled()) {\n return this.getStudentsByReleaseEncrypted(release);\n }\n\n const all = await runTransaction(\n db,\n STORE_STUDENTS,\n 'readonly',\n (store) => store.index('by-release').getAll(release) as IDBRequest,\n 'getStudentsByRelease',\n );\n return all || [];\n }\n\n /**\n * Full-scan decode of all students for a release (encryption enabled).\n */\n private getStudentsByReleaseEncrypted(release: ReleaseId): Promise {\n const db = this.ensureInitialized();\n\n return new Promise((resolve, reject) => {\n const transaction = db.transaction(STORE_STUDENTS, 'readonly');\n const store = transaction.objectStore(STORE_STUDENTS);\n const request = store.openCursor();\n const results: StudentRecord[] = [];\n\n request.onsuccess = () => {\n const cursor = request.result;\n if (cursor) {\n const record = tryDecodeCursorValue(cursor.value, release);\n if (record && record.release === release) {\n results.push(record);\n }\n cursor.continue();\n } else {\n resolve(results);\n }\n };\n\n request.onerror = () => {\n reject(\n new StorageError(\n 'Failed during getStudentsByRelease',\n 'getStudentsByRelease',\n request.error as Error,\n ),\n );\n };\n });\n }\n\n /**\n * Clear all data from the database.\n *\n * Removes students, backups, and audit-log entries in a single atomic\n * transaction.\n */\n async clearAll(): Promise {\n const db = this.ensureInitialized();\n const transaction = db.transaction(\n [STORE_STUDENTS, STORE_BACKUPS, STORE_AUDIT_LOG],\n 'readwrite',\n );\n\n await Promise.all([\n promisifyRequest(transaction.objectStore(STORE_STUDENTS).clear(), 'clearAll'),\n promisifyRequest(transaction.objectStore(STORE_BACKUPS).clear(), 'clearAll'),\n promisifyRequest(transaction.objectStore(STORE_AUDIT_LOG).clear(), 'clearAll'),\n ]);\n }\n\n /**\n * Create a backup of a student record.\n *\n * @param record - Student record to backup\n * @throws StorageQuotaError if storage quota exceeded\n */\n async backup(record: StudentRecord): Promise {\n const db = this.ensureInitialized();\n await createBackup(db, record);\n }\n\n /**\n * Save a PIN reset event to the audit log.\n *\n * @param event - PIN reset event to log\n */\n async saveAuditEvent(event: PinResetEvent): Promise {\n const db = this.ensureInitialized();\n await persistAuditEvent(db, event);\n }\n\n /**\n * Close the database connection.\n *\n * Useful for cleanup in tests and application shutdown.\n */\n close(): void {\n if (this.db) {\n this.db.close();\n this.db = null;\n this.initPromise = null;\n }\n }\n}\n\n/**\n * Singleton storage adapter instance\n */\nlet storageInstance: IndexedDBStorageAdapter | null = null;\nlet currentDbName: string | null = null;\n\n/**\n * Get the singleton storage adapter instance\n *\n * Creates a new instance on first call, reuses it thereafter.\n * If dbName changes, closes old instance and creates new one.\n *\n * @param dbName - Database name (REQUIRED - no default)\n * @returns IndexedDB storage adapter\n */\nexport function getStorageAdapter(dbName: string): IndexedDBStorageAdapter {\n if (!dbName) {\n throw new Error('FATAL: dbName is required for getStorageAdapter()');\n }\n\n // If dbName changed, close old instance and create new one\n if (storageInstance && currentDbName !== dbName) {\n storageInstance.close();\n storageInstance = null;\n }\n\n if (!storageInstance) {\n storageInstance = new IndexedDBStorageAdapter(dbName);\n currentDbName = dbName;\n }\n return storageInstance;\n}\n\n/**\n * Reset the singleton instance\n *\n * Useful for testing to ensure clean state between tests.\n */\nexport function resetStorageAdapter(): void {\n if (storageInstance) {\n storageInstance.close();\n storageInstance = null;\n currentDbName = null;\n }\n}\n","/**\n * Encryption-aware codec for stored student records.\n *\n * Confines the `ENCRYPT_STORAGE` policy and the obfuscation encode/decode +\n * format-mismatch detection to a single module. The adapter no longer\n * references `ENCRYPT_STORAGE`, `deriveKey`, or `isObfuscated` directly.\n */\n\nimport type { StudentRecord, ReleaseId } from '../../types/contracts.js';\nimport { StorageError, StorageFormatError } from './adapter-utils.js';\nimport { ENCRYPT_STORAGE } from '../../config/feature-flags.js';\nimport { encode, decode, isObfuscated, deriveKey } from './obfuscation.js';\n\n/**\n * Whether the codec is currently operating in encrypted mode.\n */\nexport function isEncryptionEnabled(): boolean {\n return ENCRYPT_STORAGE;\n}\n\n/**\n * Encode a record for storage, obfuscating it when encryption is enabled.\n *\n * @param record - Student record to encode\n * @returns The value to persist (obfuscated string or the plain record)\n */\nexport function encodeForStore(record: StudentRecord): unknown {\n return ENCRYPT_STORAGE ? encode(record, deriveKey(record.release)) : record;\n}\n\n/**\n * Decode a stored value, verifying its format matches the ENCRYPT_STORAGE\n * setting.\n *\n * @param rawValue - Value from IndexedDB (obfuscated string or plain object)\n * @param release - Release ID for key derivation\n * @param storageKey - Storage key for error reporting\n * @returns The decoded student record\n * @throws StorageFormatError if the stored format does not match the setting\n */\nexport function decodeStoredValue(\n rawValue: unknown,\n release: ReleaseId,\n storageKey: string,\n): StudentRecord {\n const storedIsObfuscated = isObfuscated(rawValue);\n\n // Check for format mismatch\n if (ENCRYPT_STORAGE && !storedIsObfuscated) {\n throw new StorageFormatError(\n 'Unobfuscated data found with ENCRYPT_STORAGE enabled. Run migration to encrypt existing data.',\n 'obfuscated',\n 'plain',\n storageKey,\n );\n }\n\n if (!ENCRYPT_STORAGE && storedIsObfuscated) {\n throw new StorageFormatError(\n 'Obfuscated data found with ENCRYPT_STORAGE disabled. Run migration to decrypt or re-enable encryption.',\n 'plain',\n 'obfuscated',\n storageKey,\n );\n }\n\n // Decode if encrypted, otherwise return as-is\n if (ENCRYPT_STORAGE && storedIsObfuscated) {\n const obfKey = deriveKey(release);\n try {\n return decode(rawValue, obfKey);\n } catch (error) {\n throw new StorageError(\n 'Failed to decode obfuscated data - data may be corrupted or tampered',\n 'getStudent',\n error instanceof Error ? error : new Error(String(error)),\n );\n }\n }\n\n return rawValue as StudentRecord;\n}\n\n/**\n * Decode a record found during a full-store cursor scan (encrypted mode).\n *\n * Returns null for non-obfuscated entries or records that fail to decode, so\n * the caller can skip them without aborting the scan.\n *\n * @param rawValue - Cursor value\n * @param release - Release ID for key derivation\n * @returns The decoded record, or null if it should be skipped\n */\nexport function tryDecodeCursorValue(rawValue: unknown, release: ReleaseId): StudentRecord | null {\n if (!isObfuscated(rawValue)) {\n return null;\n }\n try {\n return decode(rawValue, deriveKey(release));\n } catch {\n return null;\n }\n}\n","/**\n * Backup repository.\n *\n * Owns the backup-record shape, key format, and persistence to the `backups`\n * object store. Extracted from `indexeddb.ts`.\n */\n\nimport type { StudentRecord } from '../../types/contracts.js';\nimport { getStorageKey } from './adapter-utils.js';\nimport { runTransaction } from './idb-helpers.js';\nimport { STORE_BACKUPS } from './idb-connection.js';\n\n/**\n * A student record copy with backup metadata.\n */\nexport interface BackupRecord extends StudentRecord {\n /** Original storage key */\n originalKey: string;\n /** Backup timestamp */\n timestamp: string;\n}\n\n/**\n * Create a timestamped backup of a student record.\n *\n * Backup key format: `backup_{timestamp}_{serviceId}`.\n *\n * @param db - Open database connection\n * @param record - Student record to back up\n * @throws StorageQuotaError if storage quota exceeded\n */\nexport async function createBackup(db: IDBDatabase, record: StudentRecord): Promise {\n const timestamp = new Date().toISOString();\n const backupKey = `backup_${timestamp}_${record.serviceId}`;\n const originalKey = getStorageKey(record.release, record.serviceId);\n\n const backupRecord: BackupRecord = {\n ...record,\n originalKey,\n timestamp,\n };\n\n await runTransaction(\n db,\n STORE_BACKUPS,\n 'readwrite',\n (store) => store.put(backupRecord, backupKey),\n 'backup',\n );\n}\n","/**\n * Audit-log repository.\n *\n * Persists PIN-reset audit events to the `auditLog` object store. Extracted\n * from `indexeddb.ts`.\n */\n\nimport type { PinResetEvent } from '../../types/contracts.js';\nimport { runTransaction } from './idb-helpers.js';\nimport { STORE_AUDIT_LOG } from './idb-connection.js';\n\n/**\n * Append a PIN-reset event to the audit log.\n *\n * @param db - Open database connection\n * @param event - PIN reset event to record\n */\nexport async function saveAuditEvent(db: IDBDatabase, event: PinResetEvent): Promise {\n await runTransaction(\n db,\n STORE_AUDIT_LOG,\n 'readwrite',\n (store) => store.add(event),\n 'saveAuditEvent',\n );\n}\n","/**\n * Completion State Calculator\n *\n * Functions for calculating page completion states based on answer data.\n *\n * State Rules (from CLAUDE.md):\n * - unstarted: No answers provided\n * - incomplete: Some answered OR any incorrect\n * - complete: All answered AND all correct\n */\n\nimport type { AnswerRecord, AnalysisData, CompletionState } from '../types/contracts.js';\n\n/**\n * Calculate the completion state for a page\n *\n * @param answers - Array of answer records for the page\n * @param totalQuestions - Total number of questions on the page\n * @returns Completion state (unstarted | incomplete | complete)\n *\n * @example\n * ```typescript\n * const answers = [\n * { answer: 'a', success: true, timestamp: '2024-11-16T10:00:00Z' },\n * { answer: 'b', success: false, timestamp: '2024-11-16T10:01:00Z' },\n * ];\n * const state = calculateCompletionState(answers, 3); // 'incomplete' (not all answered)\n * ```\n */\nexport function calculateCompletionState(\n answers: AnswerRecord[],\n totalQuestions: number,\n): CompletionState {\n // Handle edge case: no questions\n if (totalQuestions === 0) {\n return 'unstarted';\n }\n\n // Check if unstarted\n if (isPageUnstarted(answers)) {\n return 'unstarted';\n }\n\n // Check if complete\n if (isPageComplete(answers, totalQuestions)) {\n return 'complete';\n }\n\n // Otherwise, it's incomplete\n return 'incomplete';\n}\n\n/**\n * Check if a page is complete\n *\n * A page is complete when:\n * - All questions are answered\n * - All answered questions are correct\n *\n * @param answers - Array of answer records\n * @param totalQuestions - Total number of questions\n * @returns True if page is complete\n */\nexport function isPageComplete(answers: AnswerRecord[], totalQuestions: number): boolean {\n // Must have answered all questions\n if (answers.length !== totalQuestions) {\n return false;\n }\n\n // All answers must be correct\n return answers.every((answer) => answer.success === true);\n}\n\n/**\n * Check if a page is unstarted\n *\n * A page is unstarted when no answers have been provided.\n *\n * @param answers - Array of answer records\n * @returns True if page is unstarted\n */\nexport function isPageUnstarted(answers: AnswerRecord[]): boolean {\n return answers.length === 0;\n}\n\n/**\n * Count the number of correct answers\n *\n * @param answers - Array of answer records\n * @returns Number of correct answers\n */\nexport function countCorrectAnswers(answers: AnswerRecord[]): number {\n return answers.filter((answer) => answer.success === true).length;\n}\n\n/**\n * Calculate success percentage\n *\n * @param answers - Array of answer records\n * @param totalQuestions - Total number of questions\n * @returns Percentage of correct answers (0-100)\n *\n * @example\n * ```typescript\n * const answers = [\n * { answer: 'a', success: true, timestamp: '...' },\n * { answer: 'b', success: false, timestamp: '...' },\n * { answer: 'c', success: true, timestamp: '...' },\n * ];\n * const percentage = calculateSuccessPercentage(answers, 3); // 67 (2 out of 3 correct)\n * ```\n */\nexport function calculateSuccessPercentage(\n answers: AnswerRecord[],\n totalQuestions: number,\n): number {\n if (totalQuestions === 0) {\n return 0;\n }\n\n const correct = countCorrectAnswers(answers);\n return Math.round((correct / totalQuestions) * 100);\n}\n\n/**\n * Count the analysis cells that hold text.\n *\n * @param analysis - Analysis data for a page\n * @returns Number of non-empty cells\n */\nexport function countFilledCells(analysis: AnalysisData | undefined): number {\n if (!analysis) {\n return 0;\n }\n return Object.values(analysis.cells).filter((text) => text.trim() !== '').length;\n}\n\n/**\n * Completion state for a page with a quiz, an analysis table, or both\n * (SRD 013 ANA-04).\n *\n * - unstarted: no quiz answer and no analysis text\n * - complete: every quiz question answered correctly (when the page has a\n * quiz) and every analysis cell filled (when it has an analysis table)\n * - incomplete: anything in between\n *\n * Analysis entries count as starting a page, so writing an analysis no\n * longer leaves its page badge red.\n *\n * @param page - Page answers, question count and analysis data\n * @returns Completion state\n */\nexport function calculatePageState(page: {\n answers: AnswerRecord[];\n totalQuestions?: number;\n analysis?: AnalysisData;\n}): CompletionState {\n const totalQuestions = page.totalQuestions ?? 0;\n const answered = page.answers.filter((a) => a.answer.trim() !== '');\n const filled = countFilledCells(page.analysis);\n\n if (answered.length === 0 && filled === 0) {\n return 'unstarted';\n }\n\n const quizComplete =\n totalQuestions === 0 || calculateCompletionState(page.answers, totalQuestions) === 'complete';\n const analysisComplete =\n !page.analysis ||\n (page.analysis.totalCells !== undefined && filled >= page.analysis.totalCells);\n\n return quizComplete && analysisComplete ? 'complete' : 'incomplete';\n}\n","/**\n * Storage Service\n *\n * Coordinates between IndexedDB persistence and sessionStorage cache.\n * Provides high-level operations for loading/saving student records.\n */\n\nimport type {\n StudentRecord,\n SessionData,\n SessionCache,\n PageData,\n PageId,\n ReleaseId,\n AnswerRecord,\n AnalysisData,\n CellKey,\n} from '../types/contracts.js';\nimport { getStorageAdapter } from './storage/indexeddb.js';\nimport { buildCacheFromRecord } from './session-cache.js';\nimport { calculatePageState } from './state-calculator.js';\nimport { recalculateTotalsFromPages } from '../utils/calculation-helpers.js';\nimport { setJSON } from '../utils/storage-helpers.js';\nimport { STORAGE_KEYS } from '../types/contracts.js';\nimport { info, warn, error as logError } from '../utils/logger.js';\n\n/**\n * Build a fresh, empty student record for a session.\n *\n * Used when no persisted record exists yet (first login) or when IndexedDB\n * cannot be read. Centralizes the record shape so the two creation paths in\n * {@link StorageService.loadStudentRecord} stay identical.\n *\n * @param session - Current session data\n * @returns A new, empty student record\n */\nexport function createEmptyStudentRecord(session: SessionData): StudentRecord {\n return {\n schema: 1,\n docId: session.release, // Use release as docId\n release: session.release,\n serviceId: session.serviceId,\n name: session.name,\n attempted: 0,\n correct: 0,\n updated: new Date().toISOString(),\n pages: {},\n };\n}\n\n/**\n * Carry attempt tracking from the previous answer to a new one\n * (SRD 013 SCO-01, SCO-07).\n *\n * - A commit (an MCQ choice, or a numeric value when its field loses focus)\n * counts one attempt; the first commit also fixes `firstSuccess`.\n * - A provisional save (a numeric value while typing) keeps the counts, so\n * typing \"1500\" slowly is not four attempts.\n * - An answer written before v3 has no counts; its history is unknown, so it\n * stays without them and displays as \"–\".\n *\n * @param previous - Stored answer for the question, if any\n * @param next - New answer (answer, success, timestamp)\n * @param commit - Whether this save is a committed answer\n * @returns The answer to store\n */\nexport function withAttempts(\n previous: AnswerRecord | undefined,\n next: AnswerRecord,\n commit: boolean,\n): AnswerRecord {\n const { answer, success, timestamp } = next;\n const previouslyAnswered = previous !== undefined && previous.answer.trim() !== '';\n\n // Answered before v3: history unknown, so record no counts\n if (previouslyAnswered && previous.attempts === undefined) {\n return { answer, success, timestamp };\n }\n\n const priorAttempts = previous?.attempts ?? 0;\n if (!commit || answer.trim() === '') {\n return previous?.firstSuccess === undefined\n ? { answer, success, timestamp, attempts: priorAttempts }\n : {\n answer,\n success,\n timestamp,\n attempts: priorAttempts,\n firstSuccess: previous.firstSuccess,\n };\n }\n\n const attempts = priorAttempts + 1;\n const firstSuccess = attempts === 1 ? success : (previous?.firstSuccess ?? success);\n return { answer, success, timestamp, attempts, firstSuccess };\n}\n\n/**\n * Storage Service for managing student records\n */\nexport class StorageService {\n private adapter;\n private dbName: string;\n\n /**\n * Create storage service with specified database name\n *\n * @param dbName - IndexedDB database name (REQUIRED - no default)\n */\n constructor(dbName: string) {\n if (!dbName) {\n throw new Error('FATAL: dbName is required for StorageService');\n }\n this.dbName = dbName;\n this.adapter = getStorageAdapter(dbName);\n }\n\n /**\n * Initialize IndexedDB storage\n */\n async init(): Promise {\n try {\n await this.adapter.init();\n info(`Storage service initialized (IndexedDB \"${this.dbName}\" ready)`);\n } catch (err) {\n logError('Failed to initialize storage service', err as Error);\n throw err;\n }\n }\n\n /**\n * Load student record from IndexedDB\n *\n * Creates a new record if none exists.\n *\n * @param session - Current session data\n * @returns Student record\n */\n async loadStudentRecord(session: SessionData): Promise {\n try {\n const existing = await this.adapter.getStudent(session.release, session.serviceId);\n\n if (existing) {\n info(`Loaded student record for ${session.serviceId} from IndexedDB`);\n return existing;\n }\n\n // Create new student record\n const newRecord = createEmptyStudentRecord(session);\n\n info(`Created new student record for ${session.serviceId}`);\n return newRecord;\n } catch (err) {\n // If IndexedDB has schema issues, create a new record\n warn(`IndexedDB error, creating new record: ${(err as Error).message}`);\n return createEmptyStudentRecord(session);\n }\n }\n\n /**\n * Save student record to IndexedDB\n *\n * @param record - Student record to save\n */\n async saveStudentRecord(record: StudentRecord): Promise {\n try {\n // Update timestamp\n record.updated = new Date().toISOString();\n\n // Recalculate totals from pages using calculation helper\n const totals = recalculateTotalsFromPages(record.pages);\n record.attempted = totals.attempted;\n record.correct = totals.correct;\n\n await this.adapter.saveStudent(record);\n info(`Saved student record for ${record.serviceId} to IndexedDB`);\n } catch (err) {\n logError('Failed to save student record', err as Error);\n throw err;\n }\n }\n\n /**\n * Update student record with a new answer\n *\n * @param record - Current student record\n * @param pageId - Page where answer was submitted\n * @param questionIndex - Question index (0-based)\n * @param answer - Answer record\n * @param totalQuestions - Total questions on the page\n * @returns Updated student record\n */\n updateRecordWithAnswer(\n record: StudentRecord,\n pageId: PageId,\n questionIndex: number,\n answer: AnswerRecord,\n totalQuestions: number,\n options: { commit?: boolean } = {},\n ): StudentRecord {\n // Get or create page data\n const existingPage = record.pages[pageId];\n const pageData: PageData = existingPage || {\n answers: [],\n state: 'unstarted',\n };\n\n // Ensure answers array is large enough\n while (pageData.answers.length <= questionIndex) {\n pageData.answers.push({\n answer: '',\n success: false,\n timestamp: new Date().toISOString(),\n });\n }\n\n // Update answer at index (FR-015: overwrites previous answer for re-submissions)\n // The most recent answer is stored, plus how many answers were committed\n // and whether the first was right (SRD 013 SCO-01)\n pageData.answers[questionIndex] = withAttempts(\n pageData.answers[questionIndex],\n answer,\n options.commit === true,\n );\n\n // Update timestamps\n const now = new Date().toISOString();\n if (!pageData.firstAttempted) {\n pageData.firstAttempted = now;\n }\n pageData.lastAttempted = now;\n\n // Recalculate state from the quiz and any analysis entries on the page\n pageData.totalQuestions = totalQuestions;\n pageData.state = calculatePageState(pageData);\n\n // Update record\n return {\n ...record,\n pages: {\n ...record.pages,\n [pageId]: pageData,\n },\n };\n }\n\n /**\n * Update a student record with an analysis cell edit.\n *\n * Mirrors {@link updateRecordWithAnswer} for analysis tables: gets/creates the\n * page and its analysis data, writes the cell content, and stamps the\n * first/last-edited timestamps.\n *\n * @param record - Current student record (mutated in place, also returned)\n * @param pageId - Page where the cell was edited\n * @param tableId - Analysis table identifier\n * @param cellKey - Cell key being edited\n * @param content - New cell content\n * @returns The updated student record\n */\n updateRecordWithAnalysis(\n record: StudentRecord,\n pageId: PageId,\n tableId: string,\n cellKey: CellKey,\n content: string,\n details: { totalCells?: number; label?: string } = {},\n ): StudentRecord {\n // Get or create page data\n const pageData: PageData = record.pages[pageId] || {\n answers: [],\n state: 'unstarted',\n };\n\n // Get or create analysis data\n const analysisData: AnalysisData = pageData.analysis || {\n tableId,\n cells: {},\n };\n\n // Update cell content, plus what is needed to judge and report it (ANA-03, ANA-04)\n analysisData.cells[cellKey] = content;\n if (details.totalCells !== undefined) {\n analysisData.totalCells = details.totalCells;\n }\n if (details.label) {\n analysisData.labels = { ...analysisData.labels, [cellKey]: details.label };\n }\n\n // Update timestamps\n const now = new Date().toISOString();\n if (!analysisData.firstEdited) {\n analysisData.firstEdited = now;\n }\n analysisData.lastEdited = now;\n\n // Store analysis data back in the page and record; analysis text now\n // counts towards the page state, so its badge is not left red\n pageData.analysis = analysisData;\n pageData.state = calculatePageState(pageData);\n record.pages[pageId] = pageData;\n record.updated = now;\n\n return record;\n }\n\n /**\n * Build session cache from student record\n *\n * @param record - Student record\n * @returns Session cache\n */\n buildCache(record: StudentRecord): SessionCache {\n return buildCacheFromRecord(record);\n }\n\n /**\n * Get all students for a release\n *\n * @param release - Release identifier\n * @returns Array of student records\n */\n async getStudentsByRelease(release: ReleaseId): Promise {\n try {\n return await this.adapter.getStudentsByRelease(release);\n } catch (err) {\n logError('Failed to get students by release', err as Error);\n throw err;\n }\n }\n\n /**\n * Load (and persist) the student record for a session and refresh the\n * sessionStorage cache. Falls back to an empty cache on any IndexedDB error.\n *\n * Used on login to rebuild the R/A/G cache from IndexedDB.\n *\n * @param session - Current session data\n */\n async refreshCacheOnLogin(session: SessionData): Promise {\n try {\n const record = await this.loadStudentRecord(session);\n // Save (creates if new, updates if exists) then cache.\n await this.saveStudentRecord(record);\n setJSON(STORAGE_KEYS.CACHE, this.buildCache(record));\n info(`Cache built from IndexedDB for ${session.serviceId}`);\n } catch {\n info('Failed to load from IndexedDB, initializing empty cache');\n setJSON(STORAGE_KEYS.CACHE, {\n totals: { total: 0, answered: 0, correct: 0 },\n pages: {},\n });\n }\n }\n\n /**\n * Clear all data from IndexedDB\n */\n async clearAll(): Promise {\n try {\n await this.adapter.clearAll();\n info('Cleared all data from IndexedDB');\n } catch (err) {\n logError('Failed to clear all data', err as Error);\n throw err;\n }\n }\n\n /**\n * Create backup of student record\n *\n * @param record - Student record to backup\n */\n async backup(record: StudentRecord): Promise {\n try {\n await this.adapter.backup(record);\n info(`Created backup for ${record.serviceId}`);\n } catch (err) {\n warn(`Failed to create backup for ${record.serviceId}`, err);\n }\n }\n}\n\n// ============================================================================\n// SINGLETON PATTERN\n// ============================================================================\n\nlet storageServiceInstance: StorageService | null = null;\nlet currentServiceDbName: string | null = null;\n\n/**\n * Get singleton storage service instance\n *\n * @param dbName - IndexedDB database name (optional, uses existing instance if available)\n */\nexport function getStorageService(dbName?: string): StorageService {\n // If instance exists and no dbName specified, return existing\n if (storageServiceInstance && !dbName) {\n return storageServiceInstance;\n }\n\n // If dbName specified and different, warn but return existing (don't break app)\n if (storageServiceInstance && dbName && currentServiceDbName !== dbName) {\n warn(\n `Storage service already initialized with dbName=\"${currentServiceDbName}\", ignoring new dbName=\"${dbName}\"`,\n );\n return storageServiceInstance;\n }\n\n // Create new instance if none exists\n if (!storageServiceInstance) {\n if (!dbName) {\n throw new Error('FATAL: dbName is required for first getStorageService() call');\n }\n storageServiceInstance = new StorageService(dbName);\n currentServiceDbName = dbName;\n }\n\n return storageServiceInstance;\n}\n\n/**\n * Reset singleton (for testing)\n */\nexport function resetStorageService(): void {\n storageServiceInstance = null;\n currentServiceDbName = null;\n}\n","/**\n * Calculation Helpers\n *\n * Pure functions for status indicators, percentages, and totals.\n * Feature: 007-lit-component-refactor\n *\n * These functions have no side effects and no DOM dependencies,\n * making them easy to unit test.\n */\n\nimport type { PageData, PageId } from '../types/contracts';\n\n/**\n * Status indicator values for R/A/G progress display.\n */\nexport type StatusIndicator = 'red' | 'amber' | 'green';\n\n/**\n * Overall progress colour from stored answers (SRD 013 SCO-02, SCO-03).\n *\n * Red until something is answered. Green only when the publication's total\n * is known and every question is answered correctly; with no known total\n * there is no way to say the student has finished, so it stays amber.\n *\n * @param answered - Questions answered\n * @param correct - Questions answered correctly\n * @param total - Publication question total, when known\n * @returns Indicator colour\n */\nexport function calculateProgressIndicator(\n answered: number,\n correct: number,\n total?: number,\n): StatusIndicator {\n if (answered === 0) {\n return 'red';\n }\n return total !== undefined && correct >= total ? 'green' : 'amber';\n}\n\n/**\n * One-line progress summary for the student panel (SRD 013 SCO-02, SCO-03).\n *\n * @param answered - Questions answered\n * @param correct - Questions answered correctly\n * @param total - Publication question total, when known\n * @returns e.g. \"12 of 40 answered · 9 correct\", or \"12 answered · 9 correct\"\n */\nexport function formatProgress(answered: number, correct: number, total?: number): string {\n const answeredText =\n total !== undefined ? `${answered} of ${total} answered` : `${answered} answered`;\n return `${answeredText} · ${correct} correct`;\n}\n\n/**\n * Calculates percentage with safe division.\n *\n * @param correct - Numerator (correct count)\n * @param attempted - Denominator (attempted count)\n * @returns Rounded percentage (0 if attempted is 0)\n */\nexport function calculatePercentage(correct: number, attempted: number): number {\n if (attempted === 0) {\n return 0;\n }\n return Math.round((correct / attempted) * 100);\n}\n\n/**\n * Totals calculated from page data.\n */\nexport interface RecalculatedTotals {\n attempted: number;\n correct: number;\n}\n\n/**\n * Recalculates totals from all pages in a student record.\n * Only counts answers with non-empty answer strings (excludes placeholder entries).\n *\n * @param pages - Record of page ID to page data\n * @returns Aggregated attempted and correct counts\n */\nexport function recalculateTotalsFromPages(pages: Record): RecalculatedTotals {\n let attempted = 0;\n let correct = 0;\n\n for (const pageId in pages) {\n const pageData = pages[pageId];\n if (pageData && pageData.answers && Array.isArray(pageData.answers)) {\n // Filter to only non-empty answers (matches storage-service.ts behavior)\n const answered = pageData.answers.filter((a) => a.answer.trim() !== '');\n attempted += answered.length;\n correct += answered.filter((a) => a.success).length;\n }\n }\n\n return { attempted, correct };\n}\n\n/**\n * Checks if a session has expired.\n *\n * @param expiresAt - ISO 8601 expiration timestamp\n * @param now - Current time (defaults to new Date())\n * @returns True if session has expired\n */\nexport function isSessionExpired(expiresAt: string, now: Date = new Date()): boolean {\n const expiryDate = new Date(expiresAt);\n // Invalid date -> treat as expired\n if (isNaN(expiryDate.getTime())) {\n return true;\n }\n return now >= expiryDate;\n}\n\n/**\n * Masks a service ID for display (shows last N digits).\n *\n * @param serviceId - Full service ID\n * @param visibleDigits - Number of digits to show (default 4)\n * @returns Masked string like \"...1234\"\n */\nexport function maskServiceId(serviceId: string, visibleDigits: number = 4): string {\n if (!serviceId) {\n return '';\n }\n if (serviceId.length <= visibleDigits) {\n return serviceId;\n }\n if (visibleDigits === 0) {\n return '...';\n }\n return '...' + serviceId.slice(-visibleDigits);\n}\n","/**\n * Event helper utilities\n *\n * Provides type-safe custom event emission and handling, with consistent\n * configuration for bubbling and composition. Saves ~8 lines per event emission.\n */\n\nimport type { QuizEvents } from '../types/contracts.js';\n\n/**\n * Emit a custom event on the document\n *\n * Events bubble by default and are composed (cross shadow DOM boundaries).\n *\n * @param name - Event name (should use 'qd:' namespace)\n * @param detail - Event detail data\n * @param options - Optional event configuration\n *\n * @example\n * ```typescript\n * // Emit login event\n * emitCustomEvent('qd:login', {\n * serviceId: 'RN2344',\n * name: 'John Doe',\n * loginTime: new Date().toISOString(),\n * });\n * ```\n */\nexport function emitCustomEvent(\n name: K,\n detail: QuizEvents[K]['detail'],\n options?: {\n bubbles?: boolean;\n composed?: boolean;\n cancelable?: boolean;\n },\n): boolean {\n const event = new CustomEvent(name, {\n detail,\n bubbles: options?.bubbles ?? true,\n composed: options?.composed ?? true,\n cancelable: options?.cancelable ?? false,\n });\n\n return document.dispatchEvent(event);\n}\n\n/**\n * Add event listener for custom event\n *\n * @param name - Event name\n * @param handler - Event handler function\n * @param options - Optional event listener options\n *\n * @example\n * ```typescript\n * // Listen for login events\n * const unsubscribe = addEventListener('qd:login', (event) => {\n * console.log('User logged in:', event.detail.serviceId);\n * });\n *\n * // Later: remove listener\n * unsubscribe();\n * ```\n */\nexport function addEventListener(\n name: K,\n handler: (event: CustomEvent) => void,\n options?: AddEventListenerOptions,\n): () => void {\n const listener = handler as EventListener;\n document.addEventListener(name, listener, options);\n\n // Return unsubscribe function\n return () => {\n document.removeEventListener(name, listener, options);\n };\n}\n\n/**\n * Remove event listener for custom event\n *\n * @param name - Event name\n * @param handler - Event handler function\n * @param options - Optional event listener options\n *\n * @example\n * ```typescript\n * function handleLogin(event) {\n * console.log('Logged in:', event.detail.serviceId);\n * }\n *\n * addEventListener('qd:login', handleLogin);\n * // Later...\n * removeEventListener('qd:login', handleLogin);\n * ```\n */\nexport function removeEventListener(\n name: K,\n handler: (event: CustomEvent) => void,\n options?: EventListenerOptions,\n): void {\n const listener = handler as EventListener;\n document.removeEventListener(name, listener, options);\n}\n\n/**\n * Add one-time event listener that auto-removes after first trigger\n *\n * @param name - Event name\n * @param handler - Event handler function\n *\n * @example\n * ```typescript\n * // Wait for login, then perform action once\n * addEventListenerOnce('qd:login', (event) => {\n * console.log('First login detected');\n * });\n * ```\n */\nexport function addEventListenerOnce(\n name: K,\n handler: (event: CustomEvent) => void,\n): void {\n const listener = handler as EventListener;\n document.addEventListener(name, listener, { once: true });\n}\n\n/**\n * Wait for a specific event to occur\n *\n * Returns a promise that resolves when the event is emitted.\n *\n * @param name - Event name to wait for\n * @param timeout - Optional timeout in milliseconds\n * @returns Promise that resolves with event detail\n *\n * @example\n * ```typescript\n * // Wait for login\n * const session = await waitForEvent('qd:login', 5000);\n * console.log('User logged in:', session.serviceId);\n * ```\n */\nexport function waitForEvent(\n name: K,\n timeout?: number,\n): Promise {\n return new Promise((resolve, reject) => {\n let timeoutId: ReturnType | undefined;\n\n const handler = (event: Event) => {\n if (timeoutId !== undefined) {\n clearTimeout(timeoutId);\n }\n const customEvent = event as CustomEvent;\n resolve(customEvent.detail);\n };\n\n document.addEventListener(name, handler, { once: true });\n\n if (timeout !== undefined) {\n timeoutId = setTimeout(() => {\n document.removeEventListener(name, handler);\n reject(new Error(`Timeout waiting for event: ${name}`));\n }, timeout);\n }\n });\n}\n\n/**\n * Dispatch event on a specific element\n *\n * @param element - Element to dispatch event on\n * @param name - Event name\n * @param detail - Event detail\n * @param options - Optional event configuration\n *\n * @example\n * ```typescript\n * const button = document.querySelector('button');\n * if (button) {\n * dispatchEventOn(button, 'qd:custom', { data: 'test' });\n * }\n * ```\n */\nexport function dispatchEventOn(\n element: Element,\n name: string,\n detail: T,\n options?: {\n bubbles?: boolean;\n composed?: boolean;\n cancelable?: boolean;\n },\n): boolean {\n const event = new CustomEvent(name, {\n detail,\n bubbles: options?.bubbles ?? true,\n composed: options?.composed ?? true,\n cancelable: options?.cancelable ?? false,\n });\n\n return element.dispatchEvent(event);\n}\n","/**\n * Shared persist-and-notify step for the enhancers.\n *\n * The quiz and analysis persistence paths share the same tail: save the\n * student record to IndexedDB (warn on failure), rebuild the session cache,\n * persist it to sessionStorage, apply any DOM update, then emit events. This\n * helper centralizes that sequence.\n */\n\nimport type { StudentRecord, QuizEvents } from '../types/contracts.js';\nimport { STORAGE_KEYS } from '../types/contracts.js';\nimport { getStorageService } from '../services/storage-service.js';\nimport { setJSON } from '../utils/storage-helpers.js';\nimport { emitCustomEvent } from '../utils/event-helpers.js';\nimport { warn } from '../utils/logger.js';\n\n/** A custom event to emit after the record is persisted. */\nexport interface NotifyEvent {\n name: keyof QuizEvents;\n detail: unknown;\n}\n\n/** Options controlling the DOM update and events for {@link persistAndNotify}. */\nexport interface PersistAndNotifyOptions {\n /** Optional DOM update run after the cache is saved, before events fire. */\n onSavedDom?: () => void;\n /** Events to emit (in order) once the cache is saved. */\n events: NotifyEvent[];\n}\n\n/**\n * Persist a student record, refresh the session cache, update the DOM, and\n * emit events.\n *\n * @param record - The updated student record to persist\n * @param options - DOM update and events to fire\n */\nexport async function persistAndNotify(\n record: StudentRecord,\n options: PersistAndNotifyOptions,\n): Promise {\n const storageService = getStorageService();\n\n try {\n await storageService.saveStudentRecord(record);\n } catch (err) {\n warn('Failed to save student record to IndexedDB', err);\n }\n\n // Rebuild cache from the updated record and persist to sessionStorage\n const cache = storageService.buildCache(record);\n setJSON(STORAGE_KEYS.CACHE, cache);\n\n // Apply any DOM update, then emit events\n options.onSavedDom?.();\n const emit = emitCustomEvent as (name: keyof QuizEvents, detail: unknown) => void;\n for (const event of options.events) {\n emit(event.name, event.detail);\n }\n}\n","/**\n * Accessibility helpers for progressively enhanced DITA content\n * (SRD 013 A11Y-01, A11Y-02, A11Y-03).\n */\n\n/** Class that hides text visually but keeps it for screen readers. */\nexport const SR_ONLY_CLASS = 'qd-sr-only';\n\n/** Id of the shared polite live region. */\nexport const LIVE_REGION_ID = 'qd-live-region';\n\nlet idCounter = 0;\n\n/**\n * Return the element's id, assigning a unique one first if it has none.\n *\n * DITA output gives some cells ids (table headers) but not body cells, so\n * `aria-labelledby` targets are created on demand.\n *\n * @param el - Element that needs an id\n * @param prefix - Prefix for a generated id\n * @returns The element's id\n */\nexport function ensureId(el: Element, prefix: string): string {\n if (!el.id) {\n idCounter++;\n el.id = `${prefix}-${idCounter}`;\n }\n return el.id;\n}\n\n/**\n * Announce a short message to screen-reader users through a shared, visually\n * hidden polite live region. The region is created on first use.\n *\n * @param message - Text to announce\n */\nexport function announce(message: string): void {\n let region = document.getElementById(LIVE_REGION_ID);\n if (!region) {\n region = document.createElement('div');\n region.id = LIVE_REGION_ID;\n region.className = SR_ONLY_CLASS;\n region.setAttribute('role', 'status');\n region.setAttribute('aria-live', 'polite');\n region.setAttribute('aria-atomic', 'true');\n document.body.appendChild(region);\n }\n // Clear first so repeating the same message is announced again\n region.textContent = '';\n region.textContent = message;\n}\n","/**\n * Quiz answer persistence.\n *\n * Handles user input → validation → IndexedDB save → cache update → events,\n * plus the DOM-only validation styling. Extracted from `quiz-table.ts` so the\n * persistence path is independently testable from the table lifecycle.\n */\n\nimport type { AnswerRecord, SessionData } from '../types/contracts.js';\nimport { STORAGE_KEYS } from '../types/contracts.js';\nimport type { QuizTableMetadata } from './quiz-table.js';\nimport { validateAnswer } from '../services/quiz-parser.js';\nimport { getStorageService } from '../services/storage-service.js';\nimport { getJSON } from '../utils/storage-helpers.js';\nimport { addClass, removeClass } from '../utils/dom-helpers.js';\nimport { persistAndNotify } from './persist-and-notify.js';\nimport { announce, ensureId, SR_ONLY_CLASS } from '../utils/a11y.js';\nimport { info, error as logError, warn } from '../utils/logger.js';\n\n/**\n * Handle user answer input (debounced save).\n *\n * @param table - Quiz table element\n * @param metadata - Table metadata\n * @param questionIndex - Question index\n * @param answer - User's answer\n */\nexport function handleAnswerInput(\n table: HTMLTableElement,\n metadata: QuizTableMetadata,\n questionIndex: number,\n answer: string,\n commit = false,\n): void {\n const { debouncer, pageId, parsed } = metadata;\n\n if (!debouncer || !pageId) {\n return;\n }\n\n const question = parsed.questions[questionIndex];\n if (!question) {\n return;\n }\n\n // Debounce the save operation (200ms delay). A commit shares the key, so\n // it supersedes a provisional save still waiting.\n debouncer.debounce(\n `save-answer-${questionIndex}`,\n () => {\n void saveAnswer(table, metadata, questionIndex, answer, commit);\n },\n 200,\n );\n}\n\n/**\n * Save answer to storage and update the UI.\n *\n * @param table - Quiz table element\n * @param metadata - Table metadata\n * @param questionIndex - Question index\n * @param answer - User's answer\n */\nexport async function saveAnswer(\n table: HTMLTableElement,\n metadata: QuizTableMetadata,\n questionIndex: number,\n answer: string,\n commit = false,\n): Promise {\n const { pageId, parsed, inputs } = metadata;\n\n if (!pageId || !inputs) {\n return;\n }\n\n const question = parsed.questions[questionIndex];\n if (!question) {\n return;\n }\n\n // Get session\n const session = getJSON(STORAGE_KEYS.SESSION);\n if (!session) {\n logError('No active session found');\n return;\n }\n\n // Validate answer\n const success = validateAnswer(question, answer);\n\n // Create answer record\n const answerRecord: AnswerRecord = {\n answer: answer.trim(),\n success,\n timestamp: new Date().toISOString(),\n };\n\n // Load student record from IndexedDB\n const storageService = getStorageService();\n let studentRecord;\n try {\n studentRecord = await storageService.loadStudentRecord(session);\n } catch (err) {\n warn('Failed to load student record, answer not saved', err);\n return;\n }\n\n // Update record with new answer\n const totalQuestions = parsed.questions.length;\n const updatedRecord = storageService.updateRecordWithAnswer(\n studentRecord,\n pageId,\n questionIndex,\n answerRecord,\n totalQuestions,\n { commit },\n );\n\n // Persist, refresh cache, apply validation styling, then emit events\n const pageData = updatedRecord.pages[pageId];\n await persistAndNotify(updatedRecord, {\n onSavedDom: () => {\n const row = table.querySelector(`tbody tr:nth-child(${questionIndex + 1})`);\n const answerCell = row?.querySelector('td:nth-child(2)');\n if (answerCell) {\n applyValidationStyling(answerCell, success);\n }\n // Say the result as well as colouring it (SRD 013 A11Y-02)\n announce(`Question ${questionIndex + 1}: ${success ? 'correct' : 'incorrect'}`);\n },\n events: [\n { name: 'qd:answer-saved', detail: { pageId, answer: answerRecord } },\n ...(pageData\n ? [{ name: 'qd:state-changed' as const, detail: { pageId, state: pageData.state } }]\n : []),\n ],\n });\n\n info(\n `Answer saved for question ${questionIndex + 1} on page ${pageId}: ${success ? 'correct' : 'incorrect'}`,\n );\n}\n\n/**\n * Apply validation styling to an answer cell (DOM-only).\n *\n * Colour alone fails WCAG 1.4.1, so the cell also gets a visible mark (✓ or\n * ✗) with hidden \"Correct\"/\"Incorrect\" text, and the input is described by it\n * (SRD 013 A11Y-02).\n *\n * @param cell - Answer cell element\n * @param success - Whether the answer is correct\n */\nexport function applyValidationStyling(cell: Element, success: boolean): void {\n removeClass(cell, 'qd-answer-correct', 'qd-answer-incorrect');\n addClass(cell, success ? 'qd-answer-correct' : 'qd-answer-incorrect');\n\n let feedback = cell.querySelector('.qd-answer-feedback');\n if (!feedback) {\n feedback = document.createElement('span');\n feedback.className = 'qd-answer-feedback';\n cell.appendChild(feedback);\n }\n feedback.replaceChildren();\n const mark = document.createElement('span');\n mark.setAttribute('aria-hidden', 'true');\n mark.textContent = success ? '✓ Correct' : '✗ Incorrect';\n const text = document.createElement('span');\n text.className = SR_ONLY_CLASS;\n text.textContent = success ? 'Correct' : 'Incorrect';\n feedback.append(mark, text);\n\n const control = cell.querySelector('select, input');\n if (control) {\n control.setAttribute('aria-describedby', ensureId(feedback, 'qd-feedback'));\n }\n}\n\n/**\n * Remove correctness styling and the feedback mark from an answer cell.\n *\n * @param cell - Answer cell element\n */\nexport function clearValidationFeedback(cell: Element): void {\n removeClass(cell, 'qd-answer-correct', 'qd-answer-incorrect');\n cell.querySelector('.qd-answer-feedback')?.remove();\n cell.querySelector('select, input')?.removeAttribute('aria-describedby');\n}\n\n/**\n * Show an inline error under an answer input and mark it invalid\n * (SRD 013 UX-03).\n *\n * @param cell - Answer cell element\n * @param input - The input to mark\n * @param message - Error text\n */\nexport function showInputError(cell: Element, input: HTMLElement, message: string): void {\n let error = cell.querySelector('.qd-input-error');\n if (!error) {\n error = document.createElement('span');\n error.className = 'qd-input-error';\n error.setAttribute('role', 'alert');\n input.insertAdjacentElement('afterend', error);\n }\n error.textContent = message;\n input.setAttribute('aria-invalid', 'true');\n input.setAttribute('aria-errormessage', ensureId(error, 'qd-input-error'));\n}\n\n/**\n * Remove an inline input error.\n *\n * @param cell - Answer cell element\n * @param input - The input to unmark\n */\nexport function clearInputError(cell: Element, input: HTMLElement): void {\n cell.querySelector('.qd-input-error')?.remove();\n input.removeAttribute('aria-invalid');\n input.removeAttribute('aria-errormessage');\n}\n","/**\n * Validation Helpers\n *\n * Pure functions for form validation and input sanitization.\n * Feature: 007-lit-component-refactor\n *\n * These functions have no side effects and no DOM dependencies,\n * making them easy to unit test.\n */\n\n/**\n * Validation error messages (array - empty if valid).\n */\nexport type ValidationErrors = string[];\n\n/** Per-field validation messages for the student login form. */\nexport interface StudentFieldErrors {\n name?: string;\n serviceId?: string;\n pin?: string;\n}\n\n/**\n * Validate each student login field separately, so the form can mark the\n * faulty fields and report all of them at once (SRD 013 UX-10).\n *\n * @param name - Student name\n * @param serviceId - Service ID (2-10 alphanumeric characters)\n * @param pin - 4-digit PIN\n * @returns Message per invalid field; empty object when all are valid\n */\nexport function validateStudentFields(\n name: string,\n serviceId: string,\n pin: string,\n): StudentFieldErrors {\n const errors: StudentFieldErrors = {};\n if (!name || name.trim() === '') {\n errors.name = 'Name required';\n }\n if (!serviceId) {\n errors.serviceId = 'Service ID required';\n } else if (!/^[a-zA-Z0-9]{2,10}$/.test(serviceId)) {\n errors.serviceId = 'Service ID must be 2-10 alphanumeric characters';\n }\n if (!pin) {\n errors.pin = 'PIN required';\n } else if (!/^\\d{4}$/.test(pin)) {\n errors.pin = 'PIN must be exactly 4 digits';\n }\n return errors;\n}\n\n/**\n * Validates student login form fields.\n *\n * @param name - Student name\n * @param serviceId - Service ID (2-10 alphanumeric characters)\n * @param pin - 4-digit PIN\n * @returns Array of validation error messages (empty if valid)\n */\nexport function validateStudentForm(\n name: string,\n serviceId: string,\n pin: string,\n): ValidationErrors {\n const errors: ValidationErrors = [];\n\n // Validate name\n if (!name || name.trim() === '') {\n errors.push('Name required');\n }\n\n // Validate service ID - empty check first\n if (!serviceId) {\n errors.push('Service ID required');\n } else {\n // Then format check (2-10 alphanumeric)\n const serviceIdRegex = /^[a-zA-Z0-9]{2,10}$/;\n if (!serviceIdRegex.test(serviceId)) {\n errors.push('Service ID must be 2-10 alphanumeric characters');\n }\n }\n\n // Validate PIN - empty check first\n if (!pin) {\n errors.push('PIN required');\n } else {\n // Then format check (exactly 4 digits)\n const pinRegex = /^\\d{4}$/;\n if (!pinRegex.test(pin)) {\n errors.push('PIN must be exactly 4 digits');\n }\n }\n\n return errors;\n}\n\n/**\n * Sanitizes PIN input to only allow digits.\n *\n * @param input - Raw input string\n * @returns String with non-digit characters removed\n */\nexport function sanitizePinInput(input: string): string {\n return input.replace(/\\D/g, '');\n}\n\n/**\n * Validates that PIN and confirmation match.\n *\n * @param pin - Original PIN\n * @param confirmPin - Confirmation PIN\n * @returns True if they match\n */\nexport function validatePinMatch(pin: string, confirmPin: string): boolean {\n return pin === confirmPin;\n}\n\n/**\n * Whether a numeric answer is a complete number: optional sign, digits and an\n * optional decimal point (SRD 013 UX-03).\n *\n * @param value - Trimmed input value\n * @returns True for values such as \"1500\", \"-5\", \"2.5\", \".5\"\n */\nexport function isNumericAnswer(value: string): boolean {\n return /^[+-]?(\\d+(\\.\\d*)?|\\.\\d+)$/.test(value);\n}\n\n/**\n * Whether a numeric input contains a character no number can contain, so the\n * error can show at once rather than waiting for the field to lose focus.\n *\n * @param value - Raw input value\n * @returns True when the value has characters other than digits, sign, point\n * and spaces\n */\nexport function hasNonNumericCharacters(value: string): boolean {\n return /[^0-9+\\-.\\s]/.test(value);\n}\n\n/** Message shown under a numeric input that is not a number. */\nexport const NUMERIC_INPUT_ERROR = 'Enter a number, for example 1500';\n","/**\n * Date formatting utilities for consistent timestamp display across the application.\n * Provides both display formatting (24-hour, month/date/time) and CSV export formatting (ISO 8601).\n */\n\n/**\n * Format options for timestamp display\n */\nexport type TimestampFormat = 'display' | 'csv';\n\n/**\n * Format a date for display in the instructor interface\n * @param date - Date to format\n * @returns Formatted string in \"Nov 19 14:23\" or \"11/19 14:23:45\" format (24-hour time)\n */\nfunction formatDisplayTimestamp(date: Date): string {\n // Use short month name format: \"Nov 19 14:23\"\n const month = date.toLocaleDateString('en-US', { month: 'short' });\n const day = date.getDate();\n const hours = date.getHours().toString().padStart(2, '0');\n const minutes = date.getMinutes().toString().padStart(2, '0');\n\n return `${month} ${day} ${hours}:${minutes}`;\n}\n\n/**\n * Format a date for CSV export\n * @param date - Date to format\n * @returns ISO 8601 formatted string for spreadsheet compatibility\n */\nfunction formatCSVTimestamp(date: Date): string {\n return date.toISOString();\n}\n\n/**\n * Main timestamp formatting function\n * @param date - Date to format (can be Date object or ISO string)\n * @param format - Format type ('display' for UI, 'csv' for export)\n * @returns Formatted timestamp string\n */\nexport function formatTimestamp(date: Date | string, format: TimestampFormat = 'display'): string {\n // Handle null/undefined\n if (date == null) {\n console.warn('Invalid date provided to formatTimestamp:', date);\n return 'Invalid Date';\n }\n\n const dateObj = typeof date === 'string' ? new Date(date) : date;\n\n // Validate date\n if (isNaN(dateObj.getTime())) {\n console.warn('Invalid date provided to formatTimestamp:', date);\n return 'Invalid Date';\n }\n\n return format === 'csv' ? formatCSVTimestamp(dateObj) : formatDisplayTimestamp(dateObj);\n}\n\n/**\n * Parse an ISO 8601 timestamp from storage and format for display\n * @param isoString - ISO 8601 timestamp string from IndexedDB\n * @returns Formatted display string\n */\nexport function formatStoredTimestamp(isoString: string): string {\n return formatTimestamp(isoString, 'display');\n}\n\n/**\n * Get current timestamp in ISO 8601 format for storage\n * @returns Current time as ISO 8601 string\n */\nexport function getCurrentTimestamp(): string {\n return new Date().toISOString();\n}\n","/**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n */\nconst t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&\"adoptedStyleSheets\"in Document.prototype&&\"replace\"in CSSStyleSheet.prototype,s=Symbol(),o=new WeakMap;class n{constructor(t,e,o){if(this._$cssResult$=!0,o!==s)throw Error(\"CSSResult is not constructable. Use `unsafeCSS` or `css` instead.\");this.cssText=t,this.t=e}get styleSheet(){let t=this.o;const s=this.t;if(e&&void 0===t){const e=void 0!==s&&1===s.length;e&&(t=o.get(s)),void 0===t&&((this.o=t=new CSSStyleSheet).replaceSync(this.cssText),e&&o.set(s,t))}return t}toString(){return this.cssText}}const r=t=>new n(\"string\"==typeof t?t:t+\"\",void 0,s),i=(t,...e)=>{const o=1===t.length?t[0]:e.reduce(((e,s,o)=>e+(t=>{if(!0===t._$cssResult$)return t.cssText;if(\"number\"==typeof t)return t;throw Error(\"Value passed to 'css' function must be a 'css' function result: \"+t+\". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.\")})(s)+t[o+1]),t[0]);return new n(o,t,s)},S=(s,o)=>{if(e)s.adoptedStyleSheets=o.map((t=>t instanceof CSSStyleSheet?t:t.styleSheet));else for(const e of o){const o=document.createElement(\"style\"),n=t.litNonce;void 0!==n&&o.setAttribute(\"nonce\",n),o.textContent=e.cssText,s.appendChild(o)}},c=e?t=>t:t=>t instanceof CSSStyleSheet?(t=>{let e=\"\";for(const s of t.cssRules)e+=s.cssText;return r(e)})(t):t;export{n as CSSResult,S as adoptStyles,i as css,c as getCompatibleStyle,e as supportsAdoptingStyleSheets,r as unsafeCSS};\n//# sourceMappingURL=css-tag.js.map\n","import{getCompatibleStyle as t,adoptStyles as s}from\"./css-tag.js\";export{CSSResult,css,supportsAdoptingStyleSheets,unsafeCSS}from\"./css-tag.js\";\n/**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n */const{is:i,defineProperty:e,getOwnPropertyDescriptor:h,getOwnPropertyNames:r,getOwnPropertySymbols:o,getPrototypeOf:n}=Object,a=globalThis,c=a.trustedTypes,l=c?c.emptyScript:\"\",p=a.reactiveElementPolyfillSupport,d=(t,s)=>t,u={toAttribute(t,s){switch(s){case Boolean:t=t?l:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t)}return t},fromAttribute(t,s){let i=t;switch(s){case Boolean:i=null!==t;break;case Number:i=null===t?null:Number(t);break;case Object:case Array:try{i=JSON.parse(t)}catch(t){i=null}}return i}},f=(t,s)=>!i(t,s),b={attribute:!0,type:String,converter:u,reflect:!1,useDefault:!1,hasChanged:f};Symbol.metadata??=Symbol(\"metadata\"),a.litPropertyMetadata??=new WeakMap;class y extends HTMLElement{static addInitializer(t){this._$Ei(),(this.l??=[]).push(t)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(t,s=b){if(s.state&&(s.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(t)&&((s=Object.create(s)).wrapped=!0),this.elementProperties.set(t,s),!s.noAccessor){const i=Symbol(),h=this.getPropertyDescriptor(t,i,s);void 0!==h&&e(this.prototype,t,h)}}static getPropertyDescriptor(t,s,i){const{get:e,set:r}=h(this.prototype,t)??{get(){return this[s]},set(t){this[s]=t}};return{get:e,set(s){const h=e?.call(this);r?.call(this,s),this.requestUpdate(t,h,i)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??b}static _$Ei(){if(this.hasOwnProperty(d(\"elementProperties\")))return;const t=n(this);t.finalize(),void 0!==t.l&&(this.l=[...t.l]),this.elementProperties=new Map(t.elementProperties)}static finalize(){if(this.hasOwnProperty(d(\"finalized\")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(d(\"properties\"))){const t=this.properties,s=[...r(t),...o(t)];for(const i of s)this.createProperty(i,t[i])}const t=this[Symbol.metadata];if(null!==t){const s=litPropertyMetadata.get(t);if(void 0!==s)for(const[t,i]of s)this.elementProperties.set(t,i)}this._$Eh=new Map;for(const[t,s]of this.elementProperties){const i=this._$Eu(t,s);void 0!==i&&this._$Eh.set(i,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(s){const i=[];if(Array.isArray(s)){const e=new Set(s.flat(1/0).reverse());for(const s of e)i.unshift(t(s))}else void 0!==s&&i.push(t(s));return i}static _$Eu(t,s){const i=s.attribute;return!1===i?void 0:\"string\"==typeof i?i:\"string\"==typeof t?t.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise((t=>this.enableUpdating=t)),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach((t=>t(this)))}addController(t){(this._$EO??=new Set).add(t),void 0!==this.renderRoot&&this.isConnected&&t.hostConnected?.()}removeController(t){this._$EO?.delete(t)}_$E_(){const t=new Map,s=this.constructor.elementProperties;for(const i of s.keys())this.hasOwnProperty(i)&&(t.set(i,this[i]),delete this[i]);t.size>0&&(this._$Ep=t)}createRenderRoot(){const t=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return s(t,this.constructor.elementStyles),t}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach((t=>t.hostConnected?.()))}enableUpdating(t){}disconnectedCallback(){this._$EO?.forEach((t=>t.hostDisconnected?.()))}attributeChangedCallback(t,s,i){this._$AK(t,i)}_$ET(t,s){const i=this.constructor.elementProperties.get(t),e=this.constructor._$Eu(t,i);if(void 0!==e&&!0===i.reflect){const h=(void 0!==i.converter?.toAttribute?i.converter:u).toAttribute(s,i.type);this._$Em=t,null==h?this.removeAttribute(e):this.setAttribute(e,h),this._$Em=null}}_$AK(t,s){const i=this.constructor,e=i._$Eh.get(t);if(void 0!==e&&this._$Em!==e){const t=i.getPropertyOptions(e),h=\"function\"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:u;this._$Em=e;const r=h.fromAttribute(s,t.type);this[e]=r??this._$Ej?.get(e)??r,this._$Em=null}}requestUpdate(t,s,i){if(void 0!==t){const e=this.constructor,h=this[t];if(i??=e.getPropertyOptions(t),!((i.hasChanged??f)(h,s)||i.useDefault&&i.reflect&&h===this._$Ej?.get(t)&&!this.hasAttribute(e._$Eu(t,i))))return;this.C(t,s,i)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(t,s,{useDefault:i,reflect:e,wrapped:h},r){i&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,r??s??this[t]),!0!==h||void 0!==r)||(this._$AL.has(t)||(this.hasUpdated||i||(s=void 0),this._$AL.set(t,s)),!0===e&&this._$Em!==t&&(this._$Eq??=new Set).add(t))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(t){Promise.reject(t)}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[t,s]of this._$Ep)this[t]=s;this._$Ep=void 0}const t=this.constructor.elementProperties;if(t.size>0)for(const[s,i]of t){const{wrapped:t}=i,e=this[s];!0!==t||this._$AL.has(s)||void 0===e||this.C(s,void 0,i,e)}}let t=!1;const s=this._$AL;try{t=this.shouldUpdate(s),t?(this.willUpdate(s),this._$EO?.forEach((t=>t.hostUpdate?.())),this.update(s)):this._$EM()}catch(s){throw t=!1,this._$EM(),s}t&&this._$AE(s)}willUpdate(t){}_$AE(t){this._$EO?.forEach((t=>t.hostUpdated?.())),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(t)),this.updated(t)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(t){return!0}update(t){this._$Eq&&=this._$Eq.forEach((t=>this._$ET(t,this[t]))),this._$EM()}updated(t){}firstUpdated(t){}}y.elementStyles=[],y.shadowRootOptions={mode:\"open\"},y[d(\"elementProperties\")]=new Map,y[d(\"finalized\")]=new Map,p?.({ReactiveElement:y}),(a.reactiveElementVersions??=[]).push(\"2.1.1\");export{y as ReactiveElement,s as adoptStyles,u as defaultConverter,t as getCompatibleStyle,f as notEqual};\n//# sourceMappingURL=reactive-element.js.map\n","/**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n */\nconst t=globalThis,i=t.trustedTypes,s=i?i.createPolicy(\"lit-html\",{createHTML:t=>t}):void 0,e=\"$lit$\",h=`lit$${Math.random().toFixed(9).slice(2)}$`,o=\"?\"+h,n=`<${o}>`,r=document,l=()=>r.createComment(\"\"),c=t=>null===t||\"object\"!=typeof t&&\"function\"!=typeof t,a=Array.isArray,u=t=>a(t)||\"function\"==typeof t?.[Symbol.iterator],d=\"[ \\t\\n\\f\\r]\",f=/<(?:(!--|\\/[^a-zA-Z])|(\\/?[a-zA-Z][^>\\s]*)|(\\/?$))/g,v=/-->/g,_=/>/g,m=RegExp(`>|${d}(?:([^\\\\s\"'>=/]+)(${d}*=${d}*(?:[^ \\t\\n\\f\\r\"'\\`<>=]|(\"|')|))|$)`,\"g\"),p=/'/g,g=/\"/g,$=/^(?:script|style|textarea|title)$/i,y=t=>(i,...s)=>({_$litType$:t,strings:i,values:s}),x=y(1),b=y(2),w=y(3),T=Symbol.for(\"lit-noChange\"),E=Symbol.for(\"lit-nothing\"),A=new WeakMap,C=r.createTreeWalker(r,129);function P(t,i){if(!a(t)||!t.hasOwnProperty(\"raw\"))throw Error(\"invalid template strings array\");return void 0!==s?s.createHTML(i):i}const V=(t,i)=>{const s=t.length-1,o=[];let r,l=2===i?\"\":3===i?\"\":\"\",c=f;for(let i=0;i\"===u[0]?(c=r??f,d=-1):void 0===u[1]?d=-2:(d=c.lastIndex-u[2].length,a=u[1],c=void 0===u[3]?m:'\"'===u[3]?g:p):c===g||c===p?c=m:c===v||c===_?c=f:(c=m,r=void 0);const x=c===m&&t[i+1].startsWith(\"/>\")?\" \":\"\";l+=c===f?s+n:d>=0?(o.push(a),s.slice(0,d)+e+s.slice(d)+h+x):s+h+(-2===d?i:x)}return[P(t,l+(t[s]||\"\")+(2===i?\"\":3===i?\"\":\"\")),o]};class N{constructor({strings:t,_$litType$:s},n){let r;this.parts=[];let c=0,a=0;const u=t.length-1,d=this.parts,[f,v]=V(t,s);if(this.el=N.createElement(f,n),C.currentNode=this.el.content,2===s||3===s){const t=this.el.content.firstChild;t.replaceWith(...t.childNodes)}for(;null!==(r=C.nextNode())&&d.length0){r.textContent=i?i.emptyScript:\"\";for(let i=0;i2||\"\"!==s[0]||\"\"!==s[1]?(this._$AH=Array(s.length-1).fill(new String),this.strings=s):this._$AH=E}_$AI(t,i=this,s,e){const h=this.strings;let o=!1;if(void 0===h)t=S(this,t,i,0),o=!c(t)||t!==this._$AH&&t!==T,o&&(this._$AH=t);else{const e=t;let n,r;for(t=h[0],n=0;n{const e=s?.renderBefore??i;let h=e._$litPart$;if(void 0===h){const t=s?.renderBefore??null;e._$litPart$=h=new R(i.insertBefore(l(),t),t,void 0,s??{})}return h._$AI(t),h};export{Z as _$LH,x as html,w as mathml,T as noChange,E as nothing,B as render,b as svg};\n//# sourceMappingURL=lit-html.js.map\n","import{ReactiveElement as t}from\"@lit/reactive-element\";export*from\"@lit/reactive-element\";import{render as e,noChange as r}from\"lit-html\";export*from\"lit-html\";\n/**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n */const s=globalThis;class i extends t{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){const t=super.createRenderRoot();return this.renderOptions.renderBefore??=t.firstChild,t}update(t){const r=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(t),this._$Do=e(r,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return r}}i._$litElement$=!0,i[\"finalized\"]=!0,s.litElementHydrateSupport?.({LitElement:i});const o=s.litElementPolyfillSupport;o?.({LitElement:i});const n={_$AK:(t,e,r)=>{t._$AK(e,r)},_$AL:t=>t._$AL};(s.litElementVersions??=[]).push(\"4.2.1\");export{i as LitElement,n as _$LE};\n//# sourceMappingURL=lit-element.js.map\n","/**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n */\nconst t=t=>(e,o)=>{void 0!==o?o.addInitializer((()=>{customElements.define(t,e)})):customElements.define(t,e)};export{t as customElement};\n//# sourceMappingURL=custom-element.js.map\n","import{defaultConverter as t,notEqual as e}from\"../reactive-element.js\";\n/**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n */const o={attribute:!0,type:String,converter:t,reflect:!1,hasChanged:e},r=(t=o,e,r)=>{const{kind:n,metadata:i}=r;let s=globalThis.litPropertyMetadata.get(i);if(void 0===s&&globalThis.litPropertyMetadata.set(i,s=new Map),\"setter\"===n&&((t=Object.create(t)).wrapped=!0),s.set(r.name,t),\"accessor\"===n){const{name:o}=r;return{set(r){const n=e.get.call(this);e.set.call(this,r),this.requestUpdate(o,n,t)},init(e){return void 0!==e&&this.C(o,void 0,t,e),e}}}if(\"setter\"===n){const{name:o}=r;return function(r){const n=this[o];e.call(this,r),this.requestUpdate(o,n,t)}}throw Error(\"Unsupported decorator location: \"+n)};function n(t){return(e,o)=>\"object\"==typeof o?r(t,e,o):((t,e,o)=>{const r=e.hasOwnProperty(o);return e.constructor.createProperty(o,t),r?Object.getOwnPropertyDescriptor(e,o):void 0})(t,e,o)}export{n as property,r as standardProperty};\n//# sourceMappingURL=property.js.map\n","import{property as t}from\"./property.js\";\n/**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n */function r(r){return t({...r,state:!0,attribute:!1})}export{r as state};\n//# sourceMappingURL=state.js.map\n","import{desc as t}from\"./base.js\";\n/**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n */function e(e,r){return(n,s,i)=>{const o=t=>t.renderRoot?.querySelector(e)??null;if(r){const{get:e,set:r}=\"object\"==typeof s?n:i??(()=>{const t=Symbol();return{get(){return this[t]},set(e){this[t]=e}}})();return t(n,s,{get(){let t=e.call(this);return void 0===t&&(t=o(this),(null!==t||this.hasUpdated)&&r.call(this,t)),t}})}return t(n,s,{get(){return o(this)}})}}export{e as query};\n//# sourceMappingURL=query.js.map\n","/**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n */\nconst e=(e,t,c)=>(c.configurable=!0,c.enumerable=!0,Reflect.decorate&&\"object\"!=typeof t&&Object.defineProperty(e,t,c),c);export{e as desc};\n//# sourceMappingURL=base.js.map\n","/**\n * Student Answers Component\n *\n * Reusable, Shadow-DOM-isolated display of student quiz answers for the\n * instructor overlay. Bindings are auto-escaped (no `innerHTML`), and all\n * styles live in `static styles` (no global CSS).\n *\n * @element qd-student-answers\n */\n\nimport { LitElement, html, css } from 'lit';\nimport { customElement, property } from 'lit/decorators.js';\nimport type { StudentAnswerDisplay } from '../services/answer-display.js';\n\n/**\n * Renders the list of student answers for a single question.\n */\n@customElement('qd-student-answers')\nexport class QdStudentAnswers extends LitElement {\n /** Formatted student answers to display. */\n @property({ attribute: false })\n answers: StudentAnswerDisplay[] = [];\n\n static override styles = css`\n :host {\n display: block;\n margin-top: 12px;\n padding: 8px;\n background: #f8f9fa;\n border-radius: 4px;\n border: 1px solid #dee2e6;\n }\n\n .qd-student-answer {\n font-size: 12px;\n padding: 4px 0;\n line-height: 1.4;\n }\n\n .qd-student-answer.qd-correct {\n color: #28a745;\n }\n\n .qd-student-answer.qd-incorrect {\n color: #dc3545;\n }\n\n .qd-student-name {\n font-weight: 600;\n }\n\n .qd-student-answer-text {\n margin: 0 4px;\n }\n\n .qd-timestamp {\n color: #6c757d;\n font-size: 11px;\n margin-left: 8px;\n }\n `;\n\n override render() {\n return this.answers.map(\n (sa) =>\n html`
                                        \n ${sa.name} (${sa.maskedServiceId}):\n ${sa.answer}\n ${sa.formattedTimestamp}\n
                                        `,\n );\n }\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n 'qd-student-answers': QdStudentAnswers;\n }\n}\n","/**\n * Quiz instructor overlay.\n *\n * Renders (and clears) the per-question list of student answers shown when an\n * instructor toggles \"show answers\". Extracted from `quiz-table.ts`.\n *\n * SECURITY (FR-004): student-controlled name/answer fields are rendered via\n * `textContent`, never `innerHTML`, so any HTML they contain is inert text.\n */\n\nimport type { SessionData } from '../types/contracts.js';\nimport { STORAGE_KEYS } from '../types/contracts.js';\nimport type { QuizTableMetadata } from './quiz-table.js';\nimport { getStorageService } from '../services/storage-service.js';\nimport { formatStudentAnswersForDisplay } from '../services/answer-display.js';\nimport { getJSON } from '../utils/storage-helpers.js';\nimport { info, error as logError } from '../utils/logger.js';\nimport '../components/qd-student-answers.js';\n\n/**\n * Show student answers for all questions in a table (instructor mode).\n *\n * @param table - Quiz table element\n * @param metadata - Table metadata\n */\nexport async function showStudentAnswersForTable(\n table: HTMLTableElement,\n metadata: QuizTableMetadata,\n): Promise {\n const { pageId, parsed } = metadata;\n if (!pageId) return;\n\n const session = getJSON(STORAGE_KEYS.SESSION);\n if (!session) return;\n\n // Get storage service to load all student records\n const storageService = getStorageService();\n\n try {\n // Load all student records for current release\n const students = await storageService.getStudentsByRelease(session.release);\n\n // Check if there are any students\n if (students.length === 0) {\n info('No student data available for this release');\n alert(\n 'No student data available for this release. Students need to log in and answer questions first.',\n );\n return;\n }\n\n // Get tbody rows\n const tbody = table.querySelector('tbody');\n if (!tbody) return;\n\n const rows = Array.from(tbody.querySelectorAll('tr'));\n\n // For each question, collect student answers and display\n parsed.questions.forEach((_question, questionIndex) => {\n const row = rows[questionIndex];\n if (!row) return;\n\n const cells = Array.from(row.querySelectorAll('td'));\n const answerCell = cells[1];\n if (!answerCell) return;\n\n // Remove any existing student answers display\n const existingDisplay = answerCell.querySelector('.qd-student-answers');\n if (existingDisplay) {\n existingDisplay.remove();\n }\n\n // Use pure helper function to format student answers\n const studentAnswers = formatStudentAnswersForDisplay(students, pageId, questionIndex);\n\n // Render via the reusable, auto-escaped component\n // (FR-004: student-controlled fields are never rendered as live markup).\n if (studentAnswers.length > 0) {\n const display = document.createElement('qd-student-answers');\n display.classList.add('qd-student-answers');\n display.answers = studentAnswers;\n answerCell.appendChild(display);\n }\n });\n\n info(`Displayed student answers for ${students.length} students on page ${pageId}`);\n } catch (err) {\n logError('Failed to load student answers', err as Error);\n }\n}\n\n/**\n * Hide student answers for all questions in a table.\n *\n * @param table - Quiz table element\n */\nexport function hideStudentAnswersForTable(table: HTMLTableElement): void {\n const displays = table.querySelectorAll('.qd-student-answers');\n displays.forEach((display) => display.remove());\n info('Hid student answers from quiz table');\n}\n","/**\n * Answer Display Service\n *\n * Pure functions for formatting student answer data for display.\n * No DOM dependencies - returns data structures that can be tested\n * without browser environment.\n *\n * Feature: 007-lit-component-refactor\n */\n\nimport type { StudentRecord, PageId } from '../types/contracts.js';\nimport { formatStoredTimestamp } from '../utils/date-helpers.js';\n\n/**\n * Formatted student answer for display\n */\nexport interface StudentAnswerDisplay {\n /** Student name */\n name: string;\n /** Last 4 digits of service ID */\n maskedServiceId: string;\n /** Answer value */\n answer: string;\n /** Whether answer is correct */\n success: boolean;\n /** Formatted timestamp for display (24-hour format) */\n formattedTimestamp: string;\n /** CSS class based on success: 'qd-correct' or 'qd-incorrect' */\n cssClass: 'qd-correct' | 'qd-incorrect';\n}\n\n/**\n * Format student answers for a specific question for display\n *\n * Collects and formats answers from all students for a specific\n * question, ready for rendering in instructor view.\n *\n * @param students - Array of student records\n * @param pageId - Page identifier\n * @param questionIndex - 0-based question index\n * @returns Array of formatted student answers\n */\nexport function formatStudentAnswersForDisplay(\n students: StudentRecord[],\n pageId: PageId,\n questionIndex: number,\n): StudentAnswerDisplay[] {\n const result: StudentAnswerDisplay[] = [];\n\n for (const student of students) {\n const pageData = student.pages[pageId];\n if (!pageData || !pageData.answers) continue;\n\n const answerRecord = pageData.answers[questionIndex];\n if (!answerRecord) continue;\n\n result.push({\n name: student.name,\n maskedServiceId: student.serviceId.slice(-4),\n answer: answerRecord.answer,\n success: answerRecord.success,\n formattedTimestamp: formatStoredTimestamp(answerRecord.timestamp),\n cssClass: answerRecord.success ? 'qd-correct' : 'qd-incorrect',\n });\n }\n\n return result;\n}\n","/**\n * Quiz Table Enhancer\n *\n * Implements single-phase progressive enhancement for quiz tables.\n * Replaces the old two-phase (prepare/activate) pattern with a simpler\n * conditional approach based on interactive flag.\n *\n * Features:\n * - Non-interactive mode: Hide answer column for security\n * - Interactive mode: Inject input controls, validation, auto-save\n * - Uses WeakMap for metadata (not DOM attributes)\n * - Debounced auto-save to prevent excessive writes\n * - Event emission for state changes\n */\n\nimport type { ParsedQuizTable, PageId, SessionData, SessionCache } from '../types/contracts.js';\nimport { parseQuizTable } from '../services/quiz-parser.js';\nimport { readAnswerKey, tableNeedsMissingKey } from '../services/answer-encoding.js';\nimport { registerPageQuestions } from '../services/session-cache.js';\nimport { Debouncer } from '../utils/debouncer.js';\nimport { addClass, removeClass } from '../utils/dom-helpers.js';\nimport { getJSON, setJSON, INSTRUCTOR_SHOW_ANSWERS_KEY } from '../utils/storage-helpers.js';\nimport { STORAGE_KEYS } from '../types/contracts.js';\nimport { isInstructor } from '../utils/session-state.js';\nimport { info, error as logError } from '../utils/logger.js';\nimport {\n removeColgroup,\n hideAnswerColumn,\n showAnswerColumn,\n hideDetailColumn,\n setStudentAnswerHeader,\n setLoginNotice,\n} from './quiz-table-columns.js';\nimport { createQuestionInput } from './quiz-input-factory.js';\nimport {\n handleAnswerInput,\n applyValidationStyling,\n clearValidationFeedback,\n showInputError,\n clearInputError,\n} from './quiz-answer-persistence.js';\nimport { ensureId } from '../utils/a11y.js';\nimport {\n isNumericAnswer,\n hasNonNumericCharacters,\n NUMERIC_INPUT_ERROR,\n} from '../utils/validation-helpers.js';\nimport {\n showStudentAnswersForTable,\n hideStudentAnswersForTable,\n} from './quiz-instructor-overlay.js';\n\n/**\n * Enhancement options\n */\nexport interface EnhanceQuizTableOptions {\n /** Whether to enable interactive controls */\n interactive: boolean;\n /** Current page ID (required for interactive mode) */\n pageId?: PageId;\n}\n\n/**\n * Quiz table metadata (stored in WeakMap)\n */\nexport interface QuizTableMetadata {\n /** Parsed quiz data */\n parsed: ParsedQuizTable;\n /** Enhancement mode */\n interactive: boolean;\n /** Page ID (if interactive) */\n pageId?: PageId;\n /** Row input elements (if interactive) - can be text inputs or select dropdowns */\n inputs?: (HTMLInputElement | HTMLSelectElement)[];\n /** Debouncer for auto-save */\n debouncer?: Debouncer;\n /** Cleanup function for instructor event listeners */\n cleanupInstructorListeners?: () => void;\n}\n\n// WeakMap to store table metadata without polluting DOM\nconst tableMetadata = new WeakMap();\n\n/**\n * Enhance a quiz table with single-phase enhancement.\n *\n * Non-interactive mode hides the answer/detail columns; interactive mode\n * (requires `pageId`) injects input controls, validation, and auto-save.\n *\n * @param table - The quiz table element\n * @param options - Enhancement options\n * @returns true if enhancement succeeded, false if errors occurred\n */\nexport function enhanceQuizTable(\n table: HTMLTableElement,\n options: EnhanceQuizTableOptions,\n): boolean {\n // Check if already enhanced\n const existing = tableMetadata.get(table);\n let parsed: ParsedQuizTable;\n\n if (existing) {\n // If upgrading from non-interactive to interactive, proceed\n if (!existing.interactive && options.interactive) {\n info('Upgrading quiz table from non-interactive to interactive mode');\n // Reuse existing parsed data (answers already extracted before clearing DOM)\n parsed = existing.parsed;\n } else {\n // Already enhanced in same or higher mode, skip\n info('Quiz table already enhanced, skipping');\n return true;\n }\n } else {\n // Encoded answers with no usable key: leave the table as published rather\n // than show garbled answers (SRD 013 ANS-09)\n const answerKey = readAnswerKey();\n if (tableNeedsMissingKey(table, answerKey)) {\n logError(\n 'Quiz table has encoded answers but #qd-answer-key is missing or invalid; table not enhanced',\n );\n return false;\n }\n\n // Parse the table (first enhancement)\n parsed = parseQuizTable(table, answerKey);\n\n // Check for parsing errors\n if (parsed.errors && parsed.errors.length > 0) {\n logError('Quiz table has validation errors:', parsed.errors);\n // Still continue enhancement to show errors visually\n }\n }\n\n // Store metadata in WeakMap\n const metadata: QuizTableMetadata = {\n parsed,\n interactive: options.interactive,\n pageId: options.pageId,\n };\n\n if (options.interactive) {\n // Validate pageId is provided for interactive mode\n if (!options.pageId) {\n logError('Interactive mode requires pageId option');\n return false;\n }\n\n info(`Preparing interactive enhancement for pageId: ${options.pageId}`);\n\n // Initialize debouncer for auto-save\n metadata.debouncer = new Debouncer();\n metadata.inputs = [];\n }\n\n tableMetadata.set(table, metadata);\n\n // Apply enhancement based on mode\n if (options.interactive) {\n const result = enhanceInteractive(table, metadata);\n if (result) {\n info(`Interactive enhancement succeeded for table with ${parsed.questions.length} questions`);\n } else {\n logError('Interactive enhancement failed');\n }\n return result;\n } else {\n return enhanceNonInteractive(table);\n }\n}\n\n/**\n * Enhance table in non-interactive mode\n * - Hide answer column (security: don't show correct answers before login)\n * - Hide detail column (security: don't show MCQ options or tolerances before login)\n *\n * @param table - Quiz table element\n * @returns true if successful\n */\nfunction enhanceNonInteractive(table: HTMLTableElement): boolean {\n // Remove colgroup (auto-size), then hide answer + detail columns for security\n removeColgroup(table);\n hideAnswerColumn(table);\n hideDetailColumn(table);\n\n // Tell a logged-out student why there is nothing to answer yet (UX-05);\n // the instructor reveal and the interactive upgrade remove it again\n setLoginNotice(table, true);\n\n addClass(table, 'qd-quiz-non-interactive');\n info('Quiz table enhanced in non-interactive mode');\n\n return true;\n}\n\n/**\n * Enhance table in interactive mode\n * - Inject input controls for each question\n * - Setup validation and auto-save\n * - Load existing answers from storage\n *\n * @param table - Quiz table element\n * @param metadata - Table metadata\n * @returns true if successful\n */\nfunction enhanceInteractive(table: HTMLTableElement, metadata: QuizTableMetadata): boolean {\n const { parsed, pageId, debouncer } = metadata;\n\n if (!pageId || !debouncer) {\n logError('Interactive mode requires pageId and debouncer');\n return false;\n }\n\n // Show answer column (remove qd-hidden class from non-interactive mode)\n showAnswerColumn(table);\n\n // The column now holds the student's input, whatever the author called it\n setStudentAnswerHeader(table);\n setLoginNotice(table, false);\n\n // Hide detail column in interactive mode\n // - MCQ options are now in the select dropdown\n // - Numeric tolerance is applied automatically\n hideDetailColumn(table);\n\n // Get session data\n const session = getJSON(STORAGE_KEYS.SESSION);\n if (!session) {\n logError('No active session found');\n return false;\n }\n\n // Get session cache\n let cache = getJSON(STORAGE_KEYS.CACHE);\n if (!cache) {\n info('No cache found, creating empty cache');\n cache = {\n totals: { total: 0, answered: 0, correct: 0 },\n pages: {},\n };\n } else {\n info(\n `Cache loaded: ${cache.totals.total} total questions, ${Object.keys(cache.pages).length} pages`,\n );\n }\n\n // Register page questions (updates total count in cache)\n const totalQuestions = parsed.questions.length;\n cache = registerPageQuestions(cache, pageId, totalQuestions);\n setJSON(STORAGE_KEYS.CACHE, cache);\n\n const pageCache = cache?.pages[pageId];\n const existingAnswers = pageCache?.answers || [];\n info(\n `Page ${pageId}: ${existingAnswers.length} existing answers, state: ${pageCache?.state || 'none'}`,\n );\n\n // Get all tbody rows\n const tbody = table.querySelector('tbody');\n if (!tbody) {\n logError('Quiz table has no tbody element');\n return false;\n }\n\n const rows = Array.from(tbody.querySelectorAll('tr'));\n const inputs: (HTMLInputElement | HTMLSelectElement)[] = [];\n\n // Inject controls for each question\n parsed.questions.forEach((question, index) => {\n const row = rows[index];\n if (!row) return;\n\n const cells = Array.from(row.querySelectorAll('td'));\n if (cells.length !== 3) return;\n\n const questionCell = cells[0];\n const answerCell = cells[1];\n\n if (!questionCell || !answerCell) return;\n\n // Get existing answer for this question\n const existingAnswer = existingAnswers[index];\n if (existingAnswer && existingAnswer.answer) {\n info(\n `Q${index + 1}: Pre-filling with \"${existingAnswer.answer}\" (${existingAnswer.success ? 'correct' : 'incorrect'})`,\n );\n }\n\n // Create input control based on question type\n const input = createQuestionInput(question, existingAnswer);\n inputs.push(input);\n\n // Name the control after its question for screen readers (A11Y-01)\n input.setAttribute('aria-labelledby', ensureId(questionCell, 'qd-question'));\n\n // Clear answer cell styling from any previous user session\n removeClass(answerCell, 'qd-answer-correct', 'qd-answer-incorrect');\n\n // Clear answer cell and inject input\n answerCell.textContent = '';\n answerCell.appendChild(input);\n\n // Apply validation styling if answer exists\n if (existingAnswer) {\n applyValidationStyling(answerCell, existingAnswer.success);\n }\n\n if (input instanceof HTMLSelectElement) {\n // MCQ: each choice is a committed answer\n input.addEventListener('change', () => {\n handleAnswerInput(table, metadata, index, input.value, true);\n });\n return;\n }\n\n // Numeric: only numbers are saved or counted (UX-03). Characters no\n // number can contain are flagged at once; an incomplete number such as\n // \"-\" is flagged when the field loses focus.\n input.inputMode = 'decimal';\n input.addEventListener('input', () => {\n const value = input.value.trim();\n if (hasNonNumericCharacters(value)) {\n showInputError(answerCell, input, NUMERIC_INPUT_ERROR);\n return;\n }\n clearInputError(answerCell, input);\n if (value === '' || isNumericAnswer(value)) {\n handleAnswerInput(table, metadata, index, input.value);\n }\n });\n input.addEventListener('blur', () => {\n const value = input.value.trim();\n if (value !== '' && !isNumericAnswer(value)) {\n showInputError(answerCell, input, NUMERIC_INPUT_ERROR);\n }\n });\n // Leaving a changed field commits the number as an attempt (SCO-01).\n // Typing saves along the way without counting each keystroke.\n input.addEventListener('change', () => {\n const value = input.value.trim();\n if (isNumericAnswer(value)) {\n handleAnswerInput(table, metadata, index, input.value, true);\n }\n });\n });\n\n // Store input references\n metadata.inputs = inputs;\n\n // Setup instructor answer display listeners\n const showAnswersHandler = () => {\n void showStudentAnswersForTable(table, metadata);\n };\n const hideAnswersHandler = () => {\n hideStudentAnswersForTable(table);\n };\n\n document.addEventListener('qd:instructor-show-answers', showAnswersHandler);\n document.addEventListener('qd:instructor-hide-answers', hideAnswersHandler);\n\n // Check if instructor mode with toggle already enabled\n const instructorActive = isInstructor();\n const showAnswers = sessionStorage.getItem(INSTRUCTOR_SHOW_ANSWERS_KEY) === 'true';\n if (instructorActive && showAnswers) {\n void showStudentAnswersForTable(table, metadata);\n }\n\n // Reset UI state - used on both logout and login (new user)\n const resetUIState = () => {\n // Clear student-specific feedback: colour, marks and input errors\n table.querySelectorAll('tbody tr').forEach((row) => {\n const cell = row.querySelectorAll('td')[1];\n if (!cell) return;\n clearValidationFeedback(cell);\n const control = cell.querySelector('select, input');\n if (control) clearInputError(cell, control);\n });\n\n // Reset input values to default state\n if (metadata.inputs) {\n for (const input of metadata.inputs) {\n if (input instanceof HTMLSelectElement) {\n // Reset select to first option (\"Select an answer...\")\n input.selectedIndex = 0;\n } else if (input instanceof HTMLInputElement) {\n // Clear text input\n input.value = '';\n }\n }\n }\n\n // Clear any displayed student answers\n hideStudentAnswersForTable(table);\n };\n\n // Add logout listener to clear student-specific UI state (FR-001, FR-002)\n const logoutHandler = () => {\n resetUIState();\n // Nobody is logged in now, so prompt again (UX-05)\n setLoginNotice(table, true);\n info('Cleared student UI state from quiz table on logout');\n };\n\n // Add login listener to reset UI for new user (handles migration retry case)\n const loginHandler = () => {\n resetUIState();\n setLoginNotice(table, false);\n info('Reset quiz table UI on login');\n };\n\n document.addEventListener('qd:logout', logoutHandler);\n document.addEventListener('qd:login', loginHandler);\n\n // Store cleanup function in metadata\n metadata.cleanupInstructorListeners = () => {\n document.removeEventListener('qd:instructor-show-answers', showAnswersHandler);\n document.removeEventListener('qd:instructor-hide-answers', hideAnswersHandler);\n document.removeEventListener('qd:logout', logoutHandler);\n document.removeEventListener('qd:login', loginHandler);\n };\n\n addClass(table, 'qd-quiz-interactive');\n info(`Quiz table enhanced in interactive mode for page ${pageId}`);\n\n return true;\n}\n/**\n * Get quiz table metadata\n *\n * @param table - Quiz table element\n * @returns Metadata if table has been enhanced, undefined otherwise\n */\nexport function getQuizTableMetadata(table: HTMLTableElement): QuizTableMetadata | undefined {\n return tableMetadata.get(table);\n}\n\n/**\n * Check if table is enhanced\n *\n * @param table - Quiz table element\n * @returns true if table has been enhanced\n */\nexport function isQuizTableEnhanced(table: HTMLTableElement): boolean {\n return tableMetadata.has(table);\n}\n\n/**\n * Reset quiz table to non-interactive mode\n * Called on logout to allow re-enhancement on next login\n *\n * @param table - Quiz table element\n */\nexport function resetQuizTableToNonInteractive(table: HTMLTableElement): void {\n const metadata = tableMetadata.get(table);\n if (!metadata) return;\n\n // Update metadata to mark as non-interactive\n metadata.interactive = false;\n metadata.pageId = undefined;\n metadata.inputs = undefined;\n\n // Cleanup event listeners if they exist\n metadata.cleanupInstructorListeners?.();\n metadata.cleanupInstructorListeners = undefined;\n\n // Hide answer and detail columns\n hideAnswerColumn(table);\n hideDetailColumn(table);\n\n // Remove interactive class\n removeClass(table, 'qd-quiz-interactive');\n\n // Nobody is logged in now. The table's own logout listener, which would\n // show the prompt, was removed above before it could run (UX-05)\n setLoginNotice(table, true);\n\n info('Quiz table reset to non-interactive mode');\n}\n","/**\n * Analysis Table Parser\n *\n * Parses analysis tables and generates stable identifiers for table and cells.\n *\n * Key concepts:\n * - TableId: 16-char hash based on table structure (rows × cols + className)\n * - CellKey: Format \"R{row}C{col}#f:{hash}\" where hash is 8-char from normalized content\n * - Editable cells: Cells WITH 'interactive' class\n * - Read-only cells: Cells WITHOUT 'interactive' class\n *\n * Author constraints:\n * - Add class=\"interactive\" to cells that should be editable in interactive mode\n * - Cells without this class will always be read-only\n *\n * @example\n * ```typescript\n * const table = document.querySelector('table.qd-analysis');\n * if (table instanceof HTMLTableElement) {\n * const parsed = parseAnalysisTable(table);\n * console.log(`Table ID: ${parsed.tableId}`);\n * console.log(`Editable cells: ${parsed.editableCells.length}`);\n * }\n * ```\n */\n\nimport type { ParsedAnalysisTable, TableId, CellKey } from '../types/contracts.js';\nimport { getTableRows, getRowCells, getTextContent } from '../utils/dom-helpers.js';\n\n/**\n * Generate a hash from a string using a simple but stable hash algorithm\n *\n * Uses a modified DJB2 hash algorithm for simplicity and stability.\n * Not cryptographically secure, but suitable for generating stable identifiers.\n *\n * @param input - String to hash\n * @param length - Desired hash length (default: 16)\n * @returns Hex-encoded hash of specified length\n */\nfunction hashString(input: string, length = 16): string {\n let hash = 5381;\n\n for (let i = 0; i < input.length; i++) {\n const char = input.charCodeAt(i);\n hash = (hash << 5) + hash + char; // hash * 33 + char\n hash = hash & hash; // Convert to 32-bit integer\n }\n\n // Convert to positive hex string\n const hexHash = Math.abs(hash).toString(16).padStart(8, '0');\n\n // Repeat and truncate to desired length\n const repeatedHash = hexHash.repeat(Math.ceil(length / hexHash.length));\n return repeatedHash.substring(0, length);\n}\n\n/**\n * Generate stable table ID based on structure\n *\n * Format: 16-character hash from \"{rows}x{cols}:{className}\"\n *\n * @param table - Analysis table element\n * @returns Stable table identifier\n *\n * @example\n * ```typescript\n * const table = document.querySelector('table.qd-analysis');\n * if (table instanceof HTMLTableElement) {\n * const tableId = generateTableId(table);\n * console.log(tableId); // \"8e2b4a1c9f3d7b6e\"\n * }\n * ```\n */\nexport function generateTableId(table: HTMLTableElement): TableId {\n const rows = getTableRows(table);\n const firstRow = rows[0];\n const cols = firstRow ? getRowCells(firstRow).length : 0;\n const className = table.className || 'qd-analysis';\n\n // Create structure signature: \"3x4:qd-analysis\"\n const signature = `${rows.length}x${cols}:${className}`;\n\n return hashString(signature, 16);\n}\n\n/**\n * Generate stable cell key\n *\n * Format: \"R{row}C{col}#f:{hash}\"\n * - Row and column are 0-indexed\n * - Hash is 8-char from normalized cell content (whitespace collapsed)\n *\n * @param row - Row index (0-based)\n * @param col - Column index (0-based)\n * @param content - Cell content\n * @returns Stable cell key\n *\n * @example\n * ```typescript\n * const key = generateCellKey(2, 4, 'Sample content');\n * console.log(key); // \"R2C4#f:abc123de\"\n * ```\n */\nexport function generateCellKey(row: number, col: number, content: string): CellKey {\n // Normalize content: collapse whitespace, trim\n const normalized = content.replace(/\\s+/g, ' ').trim();\n\n // Generate 8-char hash from normalized content\n const contentHash = hashString(normalized, 8);\n\n return `R${row}C${col}#f:${contentHash}`;\n}\n\n/**\n * Check if a cell is editable\n *\n * A cell is editable if it HAS the 'interactive' class.\n * Cells without this class are considered read-only (headers or pre-filled content).\n *\n * Author constraint: Add class=\"interactive\" to cells that should be editable.\n *\n * @param cell - Table cell element\n * @returns true if cell has 'interactive' class, false otherwise\n *\n * @example\n * ```typescript\n * const cell = row.cells[0];\n * if (isCellEditable(cell)) {\n * // Cell has class=\"interactive\", make it editable\n * } else {\n * // Cell is read-only\n * }\n * ```\n */\nexport function isCellEditable(cell: HTMLTableCellElement): boolean {\n // Check for 'interactive' class\n return cell.classList.contains('interactive');\n}\n\n/**\n * Parse an analysis table\n *\n * Extracts table structure, generates stable identifiers, and identifies editable cells.\n *\n * @param table - Analysis table element\n * @returns Parsed analysis table data\n *\n * @example\n * ```typescript\n * const table = document.querySelector('table.qd-analysis');\n * if (table instanceof HTMLTableElement) {\n * const parsed = parseAnalysisTable(table);\n *\n * if (parsed.errors && parsed.errors.length > 0) {\n * console.error('Validation errors:', parsed.errors);\n * }\n *\n * console.log(`Table ID: ${parsed.tableId}`);\n * console.log(`Editable cells: ${parsed.editableCells.length}`);\n * }\n * ```\n */\nexport function parseAnalysisTable(table: HTMLTableElement): ParsedAnalysisTable {\n const errors: string[] = [];\n\n // Validate table structure\n if (!table.querySelector('tbody')) {\n errors.push('Analysis table must have a tbody element');\n }\n\n const rows = getTableRows(table);\n if (rows.length === 0) {\n errors.push('Analysis table must have at least one row');\n }\n\n // Generate table ID\n const tableId = generateTableId(table);\n\n // Identify editable cells\n const editableCells: ParsedAnalysisTable['editableCells'] = [];\n\n rows.forEach((row, rowIndex) => {\n const cells = getRowCells(row);\n\n cells.forEach((cell, colIndex) => {\n if (isCellEditable(cell)) {\n const content = getTextContent(cell);\n const key = generateCellKey(rowIndex, colIndex, content);\n\n editableCells.push({\n row: rowIndex,\n col: colIndex,\n key,\n });\n }\n });\n });\n\n return {\n element: table,\n tableId,\n editableCells,\n errors: errors.length > 0 ? errors : undefined,\n };\n}\n","/**\n * Analysis cell persistence.\n *\n * Handles cell edit → debounce → IndexedDB save → cache update → event for\n * analysis tables. Extracted from `analysis-table.ts`; the record-mutation\n * logic now lives in `storage-service.updateRecordWithAnalysis`.\n */\n\nimport type { SessionData, CellKey } from '../types/contracts.js';\nimport { STORAGE_KEYS } from '../types/contracts.js';\nimport type { AnalysisTableMetadata } from './analysis-table.js';\nimport { getStorageService } from '../services/storage-service.js';\nimport { getJSON } from '../utils/storage-helpers.js';\nimport { getTextContent } from '../utils/dom-helpers.js';\nimport { persistAndNotify } from './persist-and-notify.js';\nimport { info, error as logError, warn } from '../utils/logger.js';\n\n/**\n * Handle a cell edit (debounced save).\n *\n * @param metadata - Table metadata\n * @param cell - Edited cell element\n * @param cellKey - Cell key\n */\nexport function handleCellEdit(\n metadata: AnalysisTableMetadata,\n cell: HTMLTableCellElement,\n cellKey: CellKey,\n): void {\n const { debouncer, pageId } = metadata;\n\n if (!debouncer || !pageId) {\n return;\n }\n\n const content = getTextContent(cell);\n const label = cellLabel(cell);\n\n // Debounce the save (500ms - longer than quiz for thoughtful editing)\n debouncer.debounce(\n `save-cell-${cellKey}`,\n () => {\n void saveCellData(metadata, cellKey, content, label);\n },\n 500,\n );\n}\n\n/**\n * Row label of an analysis cell, from the element that names it for screen\n * readers (set by analysis-table.ts), without a trailing colon. Stored with\n * the entry so exports can say which row it belongs to (SRD 013 ANA-03).\n *\n * @param cell - Editable analysis cell\n * @returns The label, or undefined when the row has none\n */\nexport function cellLabel(cell: HTMLTableCellElement): string | undefined {\n const labelId = cell.getAttribute('aria-labelledby');\n const text = labelId ? document.getElementById(labelId)?.textContent : undefined;\n const label = text?.replace(/\\s+/g, ' ').trim().replace(/:$/, '').trim();\n return label || undefined;\n}\n\n/**\n * Save cell data to storage (sessionStorage cache + IndexedDB).\n *\n * @param metadata - Table metadata\n * @param cellKey - Cell key\n * @param content - Cell content\n */\nexport async function saveCellData(\n metadata: AnalysisTableMetadata,\n cellKey: CellKey,\n content: string,\n label?: string,\n): Promise {\n const { pageId, parsed } = metadata;\n\n if (!pageId) {\n return;\n }\n\n // Get session\n const session = getJSON(STORAGE_KEYS.SESSION);\n if (!session) {\n logError('No active session found');\n return;\n }\n\n // Load student record from IndexedDB\n const storageService = getStorageService();\n let studentRecord;\n try {\n studentRecord = await storageService.loadStudentRecord(session);\n } catch (err) {\n warn('Failed to load student record, analysis not saved', err);\n return;\n }\n\n // Apply the cell edit to the record\n const updatedRecord = storageService.updateRecordWithAnalysis(\n studentRecord,\n pageId,\n parsed.tableId,\n cellKey,\n content,\n { totalCells: parsed.editableCells.length, label },\n );\n\n // Persist, refresh cache, and emit the saved event\n await persistAndNotify(updatedRecord, {\n events: [\n { name: 'qd:analysis-saved', detail: { pageId, tableId: parsed.tableId, cellKey, content } },\n ],\n });\n\n info(`Analysis cell saved for ${cellKey} on page ${pageId}`);\n}\n","/**\n * Student Entries Component\n *\n * Reusable, Shadow-DOM-isolated display of student analysis-cell entries for\n * the instructor view. Replaces the previous raw-DOM builder that set inline\n * `style.cssText` hex colors (Constitution V) — all styles now live in\n * `static styles`, and all bindings are auto-escaped (no `innerHTML`).\n *\n * @element qd-student-entries\n */\n\nimport { LitElement, html, css } from 'lit';\nimport { customElement, property } from 'lit/decorators.js';\nimport type { CellEntry } from '../services/analysis-display.js';\nimport { sortByTimestamp } from '../services/analysis-display.js';\nimport { formatStoredTimestamp } from '../utils/date-helpers.js';\n\n/**\n * Renders a cell's student entries (newest first) or an empty-state placeholder.\n */\n@customElement('qd-student-entries')\nexport class QdStudentEntries extends LitElement {\n /** Entries to display (unsorted; sorted newest-first internally). */\n @property({ attribute: false })\n entries: CellEntry[] = [];\n\n static override styles = css`\n :host {\n display: block;\n margin-top: 12px;\n padding-top: 8px;\n border-top: 2px solid #3b82f6;\n }\n\n :host([data-empty]) {\n margin-top: 0;\n padding-top: 0;\n border-top: none;\n }\n\n .qd-no-entries {\n color: #9ca3af;\n font-style: italic;\n font-size: 13px;\n padding: 8px 0;\n }\n\n .qd-entry {\n padding: 8px 0;\n border-bottom: 1px solid #e5e7eb;\n font-size: 13px;\n color: #1f2937;\n }\n\n .qd-entry-name {\n font-weight: 600;\n color: #374151;\n }\n\n .qd-entry-content {\n white-space: pre-wrap;\n }\n `;\n\n override render() {\n if (this.entries.length === 0) {\n this.setAttribute('data-empty', '');\n return html`
                                        (No entries yet)
                                        `;\n }\n this.removeAttribute('data-empty');\n\n return sortByTimestamp(this.entries).map((entry) => {\n const last4 = entry.serviceId.slice(-4);\n const timestamp = formatStoredTimestamp(entry.timestamp);\n return html`
                                        \n ${entry.name} (${last4}) • ${timestamp}: \n ${entry.content}\n
                                        `;\n });\n }\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n 'qd-student-entries': QdStudentEntries;\n }\n}\n","/**\n * Analysis display logic (pure).\n *\n * DOM-free helpers for collecting and ordering instructor-view student entries\n * for analysis tables. Extracted from `analysis-table.ts`.\n */\n\nimport type { StudentRecord, PageId, CellKey, ServiceId } from '../types/contracts.js';\n\n/**\n * A single student's entry for an analysis cell (instructor view).\n */\nexport interface CellEntry {\n serviceId: ServiceId;\n name: string;\n content: string;\n timestamp: string;\n}\n\n/**\n * Group student entries by cell key (FR-012).\n *\n * @param students - All student records\n * @param pageId - Page ID to filter by\n * @returns Map of cell key to array of student entries\n */\nexport function groupEntriesByCell(\n students: StudentRecord[],\n pageId: PageId,\n): Record {\n const grouped: Record = {};\n\n students.forEach((student) => {\n const pageData = student.pages[pageId];\n if (!pageData || !pageData.analysis) {\n return;\n }\n\n const { cells } = pageData.analysis;\n const timestamp = pageData.analysis.lastEdited || student.updated;\n\n Object.entries(cells).forEach(([cellKey, content]) => {\n if (!grouped[cellKey]) {\n grouped[cellKey] = [];\n }\n\n grouped[cellKey].push({\n serviceId: student.serviceId,\n name: student.name,\n content,\n timestamp,\n });\n });\n });\n\n return grouped;\n}\n\n/**\n * Sort entries by timestamp in descending order (newest first) (FR-012).\n *\n * @param entries - Cell entries to sort\n * @returns Sorted entries (newest first)\n */\nexport function sortByTimestamp(entries: CellEntry[]): CellEntry[] {\n return [...entries].sort((a, b) => {\n const dateA = new Date(a.timestamp).getTime();\n const dateB = new Date(b.timestamp).getTime();\n return dateB - dateA; // Descending (newest first)\n });\n}\n","/**\n * Analysis instructor overlay.\n *\n * Renders (and clears) the per-cell list of student entries shown when an\n * instructor toggles \"show answers\" on an analysis table. Extracted from\n * `analysis-table.ts`.\n */\n\nimport type { SessionData, PageId, StudentRecord } from '../types/contracts.js';\nimport { STORAGE_KEYS } from '../types/contracts.js';\nimport type { AnalysisTableMetadata } from './analysis-table.js';\nimport type { CellEntry } from '../services/analysis-display.js';\nimport { groupEntriesByCell } from '../services/analysis-display.js';\nimport { getStorageService } from '../services/storage-service.js';\nimport { getTableRows, getRowCells } from '../utils/dom-helpers.js';\nimport { getJSON } from '../utils/storage-helpers.js';\nimport { info, error as logError, warn } from '../utils/logger.js';\nimport '../components/qd-student-entries.js';\nimport type { QdStudentEntries } from '../components/qd-student-entries.js';\n\n/**\n * Create the reusable {@link QdStudentEntries} component for a cell's entries\n * (FR-012, FR-013). Styles and escaping are handled by the component.\n *\n * @param entries - Student entries for a cell (sorted internally)\n * @returns A `` element\n */\nexport function createStudentEntriesDisplay(entries: CellEntry[]): QdStudentEntries {\n const el = document.createElement('qd-student-entries');\n el.entries = entries;\n return el;\n}\n\n/**\n * Show student entries for all editable cells in the table (instructor view).\n *\n * @param table - Analysis table element\n * @param metadata - Table metadata for the table\n */\nexport async function showStudentEntriesForTable(\n table: HTMLTableElement,\n metadata: AnalysisTableMetadata,\n): Promise {\n // Get current page ID from metadata (if interactive) or from document\n const pageId = metadata.pageId || getCurrentPageId();\n if (!pageId) {\n warn('Cannot show student entries: page ID not found');\n return;\n }\n\n // Get session to determine release\n const session = getJSON(STORAGE_KEYS.SESSION);\n if (!session) {\n warn('Cannot show student entries: no active session');\n return;\n }\n\n // Load all students for this release\n const storageService = getStorageService();\n let students: StudentRecord[];\n try {\n students = await storageService.getStudentsByRelease(session.release);\n } catch (err) {\n logError('Failed to load students for instructor view:', err);\n return;\n }\n\n // Group entries by cell\n const grouped = groupEntriesByCell(students, pageId);\n\n // Get all editable cells from parsed data\n const { editableCells } = metadata.parsed;\n const rows = getTableRows(table);\n\n // Display entries for each editable cell\n editableCells.forEach(({ row, col, key }) => {\n const rowElement = rows[row];\n if (!rowElement) return;\n\n const cells = getRowCells(rowElement);\n const cell = cells[col];\n if (!cell) return;\n\n // Get entries for this cell\n const entries = grouped[key] || [];\n\n // Create and append display element\n const displayElement = createStudentEntriesDisplay(entries);\n displayElement.setAttribute('data-qd-student-entries', 'true');\n\n // Remove any existing display\n const existing = cell.querySelector('[data-qd-student-entries]');\n if (existing) {\n existing.remove();\n }\n\n cell.appendChild(displayElement);\n });\n\n info(`Displayed student entries for ${editableCells.length} cells`);\n}\n\n/**\n * Hide student entries for all cells in the table.\n *\n * @param table - Analysis table element\n */\nexport function hideStudentEntriesForTable(table: HTMLTableElement): void {\n const displays = table.querySelectorAll('[data-qd-student-entries]');\n displays.forEach((display) => display.remove());\n\n info('Hidden student entries from analysis table');\n}\n\n/**\n * Get the current page ID from the document (body data attribute or URL).\n *\n * @returns Page ID or undefined\n */\nexport function getCurrentPageId(): PageId | undefined {\n // Try body data attribute first\n const bodyPageId = document.body.dataset.pageId;\n if (bodyPageId) {\n return bodyPageId;\n }\n\n // Fallback: extract from URL filename\n const path = window.location.pathname;\n const filename = path.split('/').pop() || '';\n const pageId = filename.replace('.html', '');\n\n return pageId || undefined;\n}\n","/**\n * Analysis Table Enhancer\n *\n * Implements single-phase progressive enhancement for analysis tables.\n * Similar to quiz-table enhancer but for free-form editable content.\n *\n * Features:\n * - Non-interactive mode: Read-only display\n * - Interactive mode: Enable editing for cells with 'interactive' class\n * - Debounced auto-save to prevent excessive writes\n * - Stable cell keys for persistence across page reloads\n * - Uses WeakMap for metadata (not DOM attributes)\n * - Event emission for data changes\n *\n * Author constraints:\n * - Cells WITH class=\"interactive\" = editable (in interactive mode)\n * - Cells WITHOUT 'interactive' class = read-only (always)\n * - Maximum ONE analysis table per page\n */\n\nimport type {\n ParsedAnalysisTable,\n PageId,\n SessionData,\n SessionCache,\n CellKey,\n} from '../types/contracts.js';\nimport { parseAnalysisTable, isCellEditable } from '../services/analysis-parser.js';\nimport { Debouncer } from '../utils/debouncer.js';\nimport { getTableRows, getRowCells, addClass } from '../utils/dom-helpers.js';\nimport { getJSON } from '../utils/storage-helpers.js';\nimport { STORAGE_KEYS } from '../types/contracts.js';\nimport { info, error as logError } from '../utils/logger.js';\nimport { ensureId } from '../utils/a11y.js';\nimport { handleCellEdit } from './analysis-persistence.js';\nimport {\n showStudentEntriesForTable,\n hideStudentEntriesForTable,\n} from './analysis-instructor-overlay.js';\n\n/**\n * Enhancement options\n */\nexport interface EnhanceAnalysisTableOptions {\n /** Whether to enable interactive editing */\n interactive: boolean;\n /** Current page ID (required for interactive mode) */\n pageId?: PageId;\n}\n\n/**\n * Analysis table metadata (stored in WeakMap)\n */\nexport interface AnalysisTableMetadata {\n /** Parsed analysis data */\n parsed: ParsedAnalysisTable;\n /** Enhancement mode */\n interactive: boolean;\n /** Page ID (if interactive) */\n pageId?: PageId;\n /** Debouncer for auto-save */\n debouncer?: Debouncer;\n /** Cell element to cell key mapping */\n cellKeyMap?: Map;\n}\n\n// WeakMap to store table metadata without polluting DOM\nconst tableMetadata = new WeakMap();\n\n/**\n * Enhance an analysis table with single-phase enhancement\n *\n * @param table - The analysis table element\n * @param options - Enhancement options\n * @returns true if enhancement succeeded, false if errors occurred\n *\n * @example\n * ```typescript\n * // Non-interactive mode (read-only)\n * const table = document.querySelector('table.qd-analysis');\n * if (table instanceof HTMLTableElement) {\n * enhanceAnalysisTable(table, { interactive: false });\n * }\n *\n * // Interactive mode (enable editing)\n * enhanceAnalysisTable(table, { interactive: true, pageId: 'gram-1' });\n * ```\n */\nexport function enhanceAnalysisTable(\n table: HTMLTableElement,\n options: EnhanceAnalysisTableOptions,\n): boolean {\n // Parse the table\n const parsed = parseAnalysisTable(table);\n\n // Check for parsing errors\n if (parsed.errors && parsed.errors.length > 0) {\n logError('Analysis table has validation errors:', parsed.errors);\n // Still continue enhancement to show errors visually\n }\n\n // Store metadata in WeakMap\n const metadata: AnalysisTableMetadata = {\n parsed,\n interactive: options.interactive,\n pageId: options.pageId,\n };\n\n if (options.interactive) {\n // Validate pageId is provided for interactive mode\n if (!options.pageId) {\n logError('Interactive mode requires pageId option');\n return false;\n }\n\n // Initialize debouncer for auto-save\n metadata.debouncer = new Debouncer();\n metadata.cellKeyMap = new Map();\n }\n\n tableMetadata.set(table, metadata);\n\n // Apply enhancement based on mode\n if (options.interactive) {\n return enhanceInteractive(table, metadata);\n } else {\n return enhanceNonInteractive(table);\n }\n}\n\n/**\n * Enhance table in non-interactive mode\n * - Read-only display (no contenteditable)\n * - Listen for instructor view events to display student entries\n *\n * @param table - Analysis table element\n * @returns true if successful\n */\nfunction enhanceNonInteractive(table: HTMLTableElement): boolean {\n addClass(table, 'qd-analysis-non-interactive');\n\n // Add event listeners for instructor view\n const showHandler = () => {\n const metadata = tableMetadata.get(table);\n if (metadata) {\n void showStudentEntriesForTable(table, metadata);\n }\n };\n\n const hideHandler = () => {\n hideStudentEntriesForTable(table);\n };\n\n document.addEventListener('qd:instructor-show-answers', showHandler);\n document.addEventListener('qd:instructor-hide-answers', hideHandler);\n\n info('Analysis table enhanced in non-interactive mode with instructor view support');\n\n return true;\n}\n\n/**\n * Enhance table in interactive mode\n * - Enable editing for cells without background-color\n * - Setup auto-save with debouncing\n * - Load existing data from storage\n *\n * @param table - Analysis table element\n * @param metadata - Table metadata\n * @returns true if successful\n */\nfunction enhanceInteractive(table: HTMLTableElement, metadata: AnalysisTableMetadata): boolean {\n const { parsed, pageId, debouncer, cellKeyMap } = metadata;\n\n if (!pageId || !debouncer || !cellKeyMap) {\n logError('Interactive mode requires pageId, debouncer, and cellKeyMap');\n return false;\n }\n\n // Get session data\n const session = getJSON(STORAGE_KEYS.SESSION);\n if (!session) {\n logError('No active session found');\n return false;\n }\n\n // Get session cache\n const cache = getJSON(STORAGE_KEYS.CACHE);\n const pageCache = cache?.pages[pageId];\n const existingAnalysis = pageCache?.analysis;\n\n // Load existing cell data if available\n const existingCells = existingAnalysis?.cells || {};\n\n // Get all rows\n const rows = getTableRows(table);\n\n // Enable editing for editable cells\n parsed.editableCells.forEach(({ row, col, key }) => {\n const rowElement = rows[row];\n if (!rowElement) return;\n\n const cells = getRowCells(rowElement);\n const cell = cells[col];\n if (!cell) return;\n\n // Verify cell is still editable (defensive check)\n if (!isCellEditable(cell)) {\n logError(`Cell at R${row}C${col} is no longer editable`);\n return;\n }\n\n // Store cell key mapping\n cellKeyMap.set(cell, key);\n\n // Load existing content if available\n if (existingCells[key]) {\n cell.textContent = existingCells[key];\n }\n\n // Make cell editable\n cell.contentEditable = 'true';\n addClass(cell, 'qd-editable');\n\n // Expose it as a named text box (SRD 013 A11Y-03)\n cell.setAttribute('role', 'textbox');\n cell.setAttribute('aria-multiline', 'true');\n const label = findCellLabel(cells, col);\n if (label) {\n cell.setAttribute('aria-labelledby', ensureId(label, 'qd-analysis-label'));\n }\n\n // Setup auto-save on input\n cell.addEventListener('input', () => {\n handleCellEdit(metadata, cell, key);\n });\n\n // Prevent Enter key from creating line breaks (optional - may want multi-line)\n // For now, allow multi-line editing\n });\n\n addClass(table, 'qd-analysis-interactive');\n info(`Analysis table enhanced in interactive mode for page ${pageId}`);\n\n return true;\n}\n\n/**\n * Find the cell that labels an editable analysis cell: the nearest\n * non-editable cell to its left in the same row (the row header, as in\n * \"Threat Assessment:\").\n *\n * @param cells - Cells of the row\n * @param col - Column index of the editable cell\n * @returns The labelling cell, or undefined when the row has none\n */\nfunction findCellLabel(\n cells: HTMLTableCellElement[],\n col: number,\n): HTMLTableCellElement | undefined {\n for (let i = col - 1; i >= 0; i--) {\n const candidate = cells[i];\n if (candidate && !isCellEditable(candidate) && candidate.textContent?.trim()) {\n return candidate;\n }\n }\n return undefined;\n}\n\n/**\n * Get analysis table metadata\n *\n * @param table - Analysis table element\n * @returns Metadata if table has been enhanced, undefined otherwise\n */\nexport function getAnalysisTableMetadata(\n table: HTMLTableElement,\n): AnalysisTableMetadata | undefined {\n return tableMetadata.get(table);\n}\n\n/**\n * Check if table is enhanced\n *\n * @param table - Analysis table element\n * @returns true if table has been enhanced\n */\nexport function isAnalysisTableEnhanced(table: HTMLTableElement): boolean {\n return tableMetadata.has(table);\n}\n\n/**\n * Reset analysis table to non-interactive mode\n * Called on logout to clear student/instructor UI state\n *\n * @param table - Analysis table element\n */\nexport function resetAnalysisTableToNonInteractive(table: HTMLTableElement): void {\n const metadata = tableMetadata.get(table);\n if (!metadata) return;\n\n // Hide any displayed student entries (instructor view)\n hideStudentEntriesForTable(table);\n\n // If table was interactive, disable editing and clear content\n if (metadata.interactive) {\n // Find all editable cells, clear content, and disable contentEditable\n const editableCells = table.querySelectorAll('.qd-editable');\n editableCells.forEach((cell) => {\n if (cell instanceof HTMLTableCellElement) {\n cell.contentEditable = 'false';\n cell.classList.remove('qd-editable');\n // Clear student-entered content on logout\n cell.textContent = '';\n }\n });\n\n // Remove interactive class from table\n table.classList.remove('qd-analysis-interactive');\n\n // Cancel any pending saves\n metadata.debouncer?.cancelAll();\n }\n\n // Update metadata to mark as non-interactive\n metadata.interactive = false;\n metadata.pageId = undefined;\n metadata.debouncer = undefined;\n metadata.cellKeyMap = undefined;\n\n info('Reset analysis table to non-interactive mode');\n}\n","/**\n * Instructor answer-reveal enhancer (single shared, security-sensitive).\n *\n * For a non-interactive quiz table, restores the answer/detail columns that are\n * hidden from students and re-injects the correct answers into the DOM, then\n * wires the instructor \"show/hide student answers\" toggle listeners.\n *\n * This is the ONLY place correct answers are re-injected into the DOM. It\n * consolidates the previously duplicated instructor branches in `bootstrap.ts`\n * (`revealQuizAnswersForInstructor`) and `event-coordinator.ts`\n * (`upgradeTablesAfterLogin`).\n */\n\nimport type { QuizTableMetadata } from './quiz-table.js';\nimport {\n showStudentAnswersForTable,\n hideStudentAnswersForTable,\n} from './quiz-instructor-overlay.js';\nimport { INSTRUCTOR_SHOW_ANSWERS_KEY } from '../utils/storage-helpers.js';\nimport { restoreDetailColumn, restoreAnswerHeader, setLoginNotice } from './quiz-table-columns.js';\n\n/**\n * Options controlling reveal behavior. Defaults preserve the historical\n * per-caller behavior exactly.\n */\nexport interface RevealInstructorAnswersOptions {\n /**\n * When true, adds the `qd-quiz-instructor` CSS class to the table (the\n * bootstrap/initial-load path did this; the post-login path did not).\n */\n addInstructorClass?: boolean;\n}\n\n/**\n * Reveal correct answers and wire instructor toggles for a single quiz table.\n *\n * @param table - The quiz table to reveal\n * @param metadata - Parsed metadata for the table (must include `pageId`)\n * @param options - Behavior toggles (see {@link RevealInstructorAnswersOptions})\n */\nexport function revealInstructorAnswers(\n table: HTMLTableElement,\n metadata: QuizTableMetadata,\n options: RevealInstructorAnswersOptions = {},\n): void {\n if (options.addInstructorClass) {\n // Add instructor class for CSS visibility override\n table.classList.add('qd-quiz-instructor');\n }\n\n // Remove qd-hidden class from answer column (column 1)\n const answerCells = table.querySelectorAll('td:nth-child(2), th:nth-child(2)');\n answerCells.forEach((cell) => {\n cell.classList.remove('qd-hidden');\n });\n\n // Restore answer text to data cells only (not header)\n const answerDataCells = table.querySelectorAll('tbody td:nth-child(2)');\n answerDataCells.forEach((cell, index) => {\n const question = metadata.parsed.questions[index];\n if (question && cell instanceof HTMLTableCellElement) {\n cell.textContent = question.correctAnswer;\n }\n });\n\n // Show the detail column (column 2) again and restore its content from memory\n restoreDetailColumn(table);\n\n // Instructors see the author's own header and no \"log in to answer\" prompt\n restoreAnswerHeader(table);\n setLoginNotice(table, false);\n\n // Set up instructor toggle event listeners (since table is non-interactive)\n const showAnswersHandler = (): void => {\n void showStudentAnswersForTable(table, metadata);\n };\n const hideAnswersHandler = (): void => {\n hideStudentAnswersForTable(table);\n };\n\n document.addEventListener('qd:instructor-show-answers', showAnswersHandler);\n document.addEventListener('qd:instructor-hide-answers', hideAnswersHandler);\n\n // Check if toggle already enabled\n const showAnswers = sessionStorage.getItem(INSTRUCTOR_SHOW_ANSWERS_KEY) === 'true';\n if (showAnswers) {\n void showAnswersHandler();\n }\n}\n\n/**\n * Remove any rendered student-answer overlays from a quiz table.\n *\n * Inverse of the toggle's \"show\" action; provided for callers that need to\n * tear the overlay down without re-hiding the answer columns.\n *\n * @param table - The quiz table to clear overlays from\n */\nexport function hideInstructorAnswers(table: HTMLTableElement): void {\n hideStudentAnswersForTable(table);\n}\n","/**\n * Page ID extraction utility\n *\n * Derives the `PageId` for the current document from a URL by taking the final\n * path segment (the filename) and stripping its `.html`/`.htm` extension.\n * Consolidates the inline pathname→filename→strip-extension parses previously\n * duplicated across `bootstrap.ts` and `event-coordinator.ts`.\n */\n\nimport type { PageId } from '../types/contracts.js';\n\n/**\n * Extract the page ID from a URL.\n *\n * @param url - Optional URL or path to parse. When omitted, the current\n * document's `window.location.pathname` is used (preserving the original\n * inline behavior). Query strings and hash fragments are ignored.\n * @returns The filename without its `.html`/`.htm` extension. Returns an empty\n * string when the URL ends in a trailing slash (no filename).\n *\n * @example\n * getPageIdFromUrl('/training/gram-1.html'); // 'gram-1'\n * getPageIdFromUrl('quiz-index.html?x=1#top'); // 'quiz-index'\n */\nexport function getPageIdFromUrl(url?: string): PageId {\n // Strip query string and hash fragment, then isolate the path portion.\n // window.location.pathname never contains '?' or '#', so this is a no-op for\n // the default (current-document) case and preserves the original behavior.\n const path = (url ?? window.location.pathname).split(/[?#]/)[0] ?? '';\n const filename = path.substring(path.lastIndexOf('/') + 1);\n return filename.replace(/\\.html?$/i, '');\n}\n","/**\n * Post-login table upgrade.\n *\n * Upgrades quiz/analysis tables to interactive mode after a student logs in, or\n * reveals answers + wires instructor toggles for an instructor. Extracted from\n * `event-coordinator.ts` so the coordinator only routes events.\n */\n\nimport { enhanceQuizTable, getQuizTableMetadata } from './quiz-table.js';\nimport { enhanceAnalysisTable } from './analysis-table.js';\nimport { revealInstructorAnswers } from './instructor-answer-reveal.js';\nimport { getPageIdFromUrl } from '../utils/page-id.js';\nimport { isInstructor } from '../utils/session-state.js';\nimport { info } from '../utils/logger.js';\n\n/**\n * Upgrade all tables to interactive mode after login.\n *\n * Instructors keep non-interactive tables but get answers revealed; students\n * get interactive quiz/analysis tables for the current page.\n */\nexport function upgradeTablesAfterLogin(): void {\n const pageId = getPageIdFromUrl();\n\n if (!pageId) {\n info('No pageId found, skipping table upgrade to interactive mode');\n return;\n }\n\n const instructorActive = isInstructor();\n if (instructorActive) {\n revealTablesForInstructor(pageId);\n return;\n }\n\n const quizTables = document.querySelectorAll('table.qd-quiz');\n if (quizTables.length > 0) {\n info(`Upgrading ${quizTables.length} quiz table(s) to interactive mode...`);\n quizTables.forEach((table) => enhanceQuizTable(table, { interactive: true, pageId }));\n }\n\n const analysisTables = document.querySelectorAll('table.qd-analysis');\n if (analysisTables.length > 0) {\n info(`Upgrading ${analysisTables.length} analysis table(s) to interactive mode...`);\n analysisTables.forEach((table) => enhanceAnalysisTable(table, { interactive: true, pageId }));\n }\n}\n\n/**\n * Reveal answers and wire instructor toggles on the current page's quiz tables.\n */\nfunction revealTablesForInstructor(pageId: string): void {\n info('Instructor session detected, tables remain in non-interactive mode with answers visible');\n const quizTables = document.querySelectorAll('table.qd-quiz');\n\n quizTables.forEach((table) => {\n const metadata = getQuizTableMetadata(table);\n if (!metadata) return;\n metadata.pageId = pageId;\n // Post-login path does not add the qd-quiz-instructor visibility class.\n revealInstructorAnswers(table, metadata);\n });\n}\n","/**\n * Event Coordinator\n * Registers and coordinates custom events across the application\n */\n\nimport { info } from '../utils/logger.js';\nimport { resetQuizTableToNonInteractive } from '../enhancers/quiz-table.js';\nimport { resetAnalysisTableToNonInteractive } from '../enhancers/analysis-table.js';\nimport { upgradeTablesAfterLogin } from '../enhancers/table-upgrade.js';\nimport { getStorageService } from '../services/storage-service.js';\nimport { STORAGE_KEYS } from '../types/contracts.js';\nimport { getJSON } from '../utils/storage-helpers.js';\nimport type { SessionData } from '../types/contracts.js';\n\n/**\n * Custom event detail types\n */\nexport interface LoginEventDetail {\n serviceId: string;\n name: string;\n release: string;\n loginTime: string;\n}\n\nexport interface LogoutEventDetail {\n serviceId: string;\n}\n\nexport interface AnswerSavedEventDetail {\n pageId: string;\n questionIndex: number;\n answer: string;\n success: boolean;\n}\n\nexport interface StateChangedEventDetail {\n pageId: string;\n state: string;\n}\n\nexport interface InstructorUnlockEventDetail {\n unlockTime: string;\n}\n\nexport interface DataClearedEventDetail {\n timestamp: string;\n}\n\n/**\n * Event coordinator for managing application events\n */\nexport class EventCoordinator {\n private listeners: Map = new Map();\n\n /**\n * Register all event listeners\n */\n initialize(): void {\n this.registerLoginHandlers();\n this.registerLogoutHandlers();\n this.registerAnswerHandlers();\n this.registerStateHandlers();\n this.registerInstructorHandlers();\n this.registerDataHandlers();\n\n info('Event coordinator initialized');\n }\n\n /**\n * Register handlers for login events.\n *\n * On a student login: rebuild the cache from IndexedDB (delegated to\n * StorageService) and upgrade tables to interactive mode (delegated to the\n * table-upgrade enhancer). Instructor logins skip student-record handling.\n */\n private registerLoginHandlers(): void {\n this.addEventListener('qd:login', (event) => {\n void (async () => {\n const detail = (event as CustomEvent).detail;\n info(`Login event: ${detail.serviceId} (${detail.name})`);\n\n if (detail.serviceId === 'INSTRUCTOR') {\n info('Instructor login - skipping student record handling');\n return;\n }\n\n const session = getJSON(STORAGE_KEYS.SESSION);\n if (!session) {\n info('No session found in storage, skipping cache rebuild');\n return;\n }\n\n await getStorageService().refreshCacheOnLogin(session);\n this.dispatchEvent('qd:cache-rebuild', {});\n upgradeTablesAfterLogin();\n })();\n });\n }\n\n /**\n * Register handlers for logout events\n */\n private registerLogoutHandlers(): void {\n this.addEventListener('qd:logout', (event) => {\n const detail = (event as CustomEvent).detail;\n info(`Logout event: ${detail.serviceId}`);\n\n // Reset all quiz tables to non-interactive mode\n const quizTables = document.querySelectorAll('table.qd-quiz');\n quizTables.forEach((table) => {\n resetQuizTableToNonInteractive(table);\n });\n\n // Reset all analysis tables to non-interactive mode\n const analysisTables = document.querySelectorAll('table.qd-analysis');\n analysisTables.forEach((table) => {\n resetAnalysisTableToNonInteractive(table);\n });\n\n // Clear any cached data\n this.dispatchEvent('qd:cache-clear', {});\n });\n }\n\n /**\n * Register handlers for answer saved events\n */\n private registerAnswerHandlers(): void {\n this.addEventListener('qd:answer-saved', (event) => {\n const detail = (event as CustomEvent).detail;\n info(\n `Answer saved: ${detail.pageId} Q${detail.questionIndex} = ${detail.answer} (${detail.success ? 'correct' : 'incorrect'})`,\n );\n\n // Trigger cache update\n this.dispatchEvent('qd:cache-update', { pageId: detail.pageId });\n });\n }\n\n /**\n * Register handlers for state changed events\n */\n private registerStateHandlers(): void {\n this.addEventListener('qd:state-changed', (event) => {\n const detail = (event as CustomEvent).detail;\n info(`State changed: ${detail.pageId} → ${detail.state}`);\n\n // Update badge state\n this.dispatchEvent('qd:badge-update', { pageId: detail.pageId, state: detail.state });\n });\n }\n\n /**\n * Register handlers for instructor events\n */\n private registerInstructorHandlers(): void {\n this.addEventListener('qd:instructor-unlock', (event) => {\n const detail = (event as CustomEvent).detail;\n info(`Instructor mode unlocked at ${detail.unlockTime}`);\n });\n\n this.addEventListener('qd:instructor-lock', () => {\n info('Instructor mode locked');\n });\n }\n\n /**\n * Register handlers for data management events\n */\n private registerDataHandlers(): void {\n this.addEventListener('qd:data-cleared', (event) => {\n const detail = (event as CustomEvent).detail;\n info(`All data cleared at ${detail.timestamp}`);\n\n // Clear cache\n this.dispatchEvent('qd:cache-clear', {});\n });\n }\n\n /**\n * Add event listener\n */\n private addEventListener(eventName: string, handler: EventListener): void {\n document.addEventListener(eventName, handler);\n\n // Track listeners for cleanup\n const handlers = this.listeners.get(eventName) || [];\n handlers.push(handler);\n this.listeners.set(eventName, handlers);\n }\n\n /**\n * Dispatch custom event\n */\n private dispatchEvent(eventName: string, detail: T): void {\n const event = new CustomEvent(eventName, {\n detail,\n bubbles: true,\n composed: true,\n });\n document.dispatchEvent(event);\n }\n\n /**\n * Cleanup event listeners\n */\n cleanup(): void {\n for (const [eventName, handlers] of this.listeners) {\n for (const handler of handlers) {\n document.removeEventListener(eventName, handler);\n }\n }\n this.listeners.clear();\n info('Event coordinator cleaned up');\n }\n}\n","/**\n * Session Management Service\n *\n * Handles user session lifecycle, timeout management, and instructor mode.\n * Integrates with encrypted session storage for secure session data.\n */\n\nimport type { SessionData, SessionCache, ServiceId, ReleaseId } from '../types/contracts.js';\nimport { STORAGE_KEYS, SESSION_TIMEOUT_MS } from '../types/contracts.js';\nimport { info, warn, error } from '../utils/logger.js';\nimport { INSTRUCTOR_SHOW_ANSWERS_KEY } from '../utils/storage-helpers.js';\nimport { isSessionExpired } from '../utils/calculation-helpers.js';\nimport { endInstructorSession } from './auth/instructor-session.js';\n\n/**\n * Session Service for managing user sessions\n */\nexport class SessionService {\n /**\n * Create a new session\n *\n * @param serviceId - Student service ID\n * @param name - Student name\n * @param release - Current release ID\n * @returns Created session data\n */\n createSession(serviceId: ServiceId, name: string, release: ReleaseId): SessionData {\n const now = new Date();\n const loginTime = now.toISOString();\n const expiresAt = new Date(now.getTime() + SESSION_TIMEOUT_MS).toISOString();\n\n const session: SessionData = {\n serviceId,\n name,\n release,\n loginTime,\n lastActivity: loginTime,\n expiresAt,\n instructorUnlocked: false,\n };\n\n this.saveSession(session);\n info(`Session created for ${serviceId} (${name})`);\n\n // Emit login event\n this.emitEvent('qd:login', { serviceId, name, release, loginTime });\n\n return session;\n }\n\n /**\n * Get the current session\n *\n * @returns Session data or null if no session exists\n */\n getSession(): SessionData | null {\n try {\n const sessionData = sessionStorage.getItem(STORAGE_KEYS.SESSION);\n if (!sessionData) {\n return null;\n }\n\n const session = JSON.parse(sessionData) as SessionData;\n\n // Validate required fields\n if (!session.serviceId || !session.release || !session.expiresAt) {\n warn('Invalid session data, missing required fields');\n return null;\n }\n\n return session;\n } catch (err) {\n error('Failed to parse session data', err as Error);\n return null;\n }\n }\n\n /**\n * Update last activity time and extend session expiry\n */\n updateActivity(): void {\n const session = this.getSession();\n if (!session) {\n return;\n }\n\n const now = new Date();\n session.lastActivity = now.toISOString();\n session.expiresAt = new Date(now.getTime() + SESSION_TIMEOUT_MS).toISOString();\n\n this.saveSession(session);\n }\n\n /**\n * Check if the current session is expired\n *\n * @returns True if session is expired or doesn't exist\n */\n isExpired(): boolean {\n const session = this.getSession();\n if (!session) {\n return true;\n }\n\n return isSessionExpired(session.expiresAt);\n }\n\n /**\n * Clear the current session\n */\n clearSession(): void {\n const session = this.getSession();\n sessionStorage.removeItem(STORAGE_KEYS.SESSION);\n sessionStorage.removeItem(STORAGE_KEYS.CACHE);\n endInstructorSession();\n\n // Clear instructor-specific state (FR-001)\n sessionStorage.removeItem(INSTRUCTOR_SHOW_ANSWERS_KEY);\n\n if (session) {\n info(`Session cleared for ${session.serviceId}`);\n\n // Emit logout event\n this.emitEvent('qd:logout', {\n serviceId: session.serviceId,\n timestamp: new Date().toISOString(),\n });\n }\n }\n\n /**\n * Unlock instructor mode\n */\n unlockInstructor(): void {\n const session = this.getSession();\n if (!session) {\n return;\n }\n\n session.instructorUnlocked = true;\n session.unlockTime = new Date().toISOString();\n\n this.saveSession(session);\n\n info('Instructor mode unlocked');\n\n // Emit custom event\n this.emitEvent('qd:instructor-unlock', { timestamp: session.unlockTime });\n }\n\n /**\n * Lock instructor mode\n */\n lockInstructor(): void {\n const session = this.getSession();\n if (!session) {\n return;\n }\n\n session.instructorUnlocked = false;\n delete session.unlockTime;\n\n this.saveSession(session);\n\n info('Instructor mode locked');\n\n // Emit custom event\n this.emitEvent('qd:instructor-lock', { timestamp: new Date().toISOString() });\n }\n\n /**\n * Check if instructor mode is unlocked\n *\n * @returns True if instructor mode is unlocked\n */\n isInstructorUnlocked(): boolean {\n const session = this.getSession();\n return session?.instructorUnlocked === true;\n }\n\n /**\n * Get session cache from sessionStorage\n *\n * @returns Session cache or null if not found\n */\n getCache(): SessionCache | null {\n try {\n const cacheData = sessionStorage.getItem(STORAGE_KEYS.CACHE);\n if (!cacheData) {\n return null;\n }\n\n return JSON.parse(cacheData) as SessionCache;\n } catch (err) {\n error('Failed to parse cache data', err);\n return null;\n }\n }\n\n /**\n * Save session cache to sessionStorage\n *\n * @param cache - Cache data to save\n */\n saveCache(cache: SessionCache): void {\n try {\n sessionStorage.setItem(STORAGE_KEYS.CACHE, JSON.stringify(cache));\n } catch (err) {\n error('Failed to save cache', err);\n }\n }\n\n /**\n * Clear the session cache\n */\n clearCache(): void {\n sessionStorage.removeItem(STORAGE_KEYS.CACHE);\n }\n\n /**\n * Save session to sessionStorage\n *\n * @param session - Session data to save\n */\n private saveSession(session: SessionData): void {\n try {\n sessionStorage.setItem(STORAGE_KEYS.SESSION, JSON.stringify(session));\n } catch (err) {\n error('Failed to save session', err);\n }\n }\n\n /**\n * Emit a custom event\n *\n * @param eventName - Name of the event\n * @param detail - Event detail data\n */\n private emitEvent(eventName: string, detail: unknown): void {\n try {\n const event = new CustomEvent(eventName, { detail, bubbles: true });\n document.dispatchEvent(event);\n } catch (err) {\n error(`Failed to emit event ${eventName}`, err);\n }\n }\n}\n\n// ============================================================================\n// SINGLETON PATTERN\n// ============================================================================\n\n/**\n * Create and return a singleton instance of the session service\n */\nlet sessionInstance: SessionService | null = null;\n\nexport function getSessionService(): SessionService {\n if (!sessionInstance) {\n sessionInstance = new SessionService();\n }\n return sessionInstance;\n}\n\n/**\n * Reset the singleton instance (useful for testing)\n */\nexport function resetSessionService(): void {\n sessionInstance = null;\n}\n","/**\n * Session Coordinator\n * Manages session lifecycle and coordinates session-related events\n */\n\nimport { SessionService } from '../services/session.js';\nimport { info, warn } from '../utils/logger.js';\nimport type { SessionData } from '../types/contracts.js';\nimport { STORAGE_KEYS } from '../types/contracts.js';\n\n/**\n * Session coordinator for managing session lifecycle\n */\nexport class SessionCoordinator {\n private sessionService: SessionService;\n private expiryTimeoutId?: number;\n private activityTrackingActive = false;\n\n constructor() {\n this.sessionService = new SessionService();\n }\n\n /**\n * Initialize session coordinator\n * - Load existing session from storage\n * - Schedule expiry check\n * - Setup activity tracking\n */\n initialize(): void {\n const session = this.sessionService.getSession();\n\n if (session) {\n info(`Existing session loaded for ${session.serviceId}`);\n\n // Check if session is expired\n if (this.sessionService.isExpired()) {\n warn('Session expired, clearing');\n this.expireSession();\n return;\n }\n\n // Schedule expiry check\n this.scheduleExpiryCheck(session);\n\n // Setup activity tracking\n this.setupActivityTracking();\n } else {\n info('No existing session found');\n }\n\n // A login on this page starts a session that the checks above never saw,\n // so start the timer and activity tracking for it too\n document.addEventListener('qd:login', this.handleLogin);\n }\n\n /** Start expiry and activity tracking for a session created on this page. */\n private handleLogin = (): void => {\n const session = this.sessionService.getSession();\n if (!session) {\n return;\n }\n this.scheduleExpiryCheck(session);\n this.setupActivityTracking();\n };\n\n /**\n * End a session that timed out, leaving a marker so the login form can\n * explain why the student was logged out (SRD 013 SEC-10).\n */\n private expireSession(): void {\n sessionStorage.setItem(STORAGE_KEYS.SESSION_EXPIRED, new Date().toISOString());\n document.dispatchEvent(\n new CustomEvent('qd:session-expired', { bubbles: true, composed: true }),\n );\n this.sessionService.clearSession();\n }\n\n /**\n * Schedule expiry check based on session timeout\n */\n private scheduleExpiryCheck(session: SessionData): void {\n // Clear existing timeout\n if (this.expiryTimeoutId !== undefined) {\n window.clearTimeout(this.expiryTimeoutId);\n }\n\n // Calculate time until expiry\n const now = new Date().getTime();\n const expiresAt = new Date(session.expiresAt).getTime();\n const timeUntilExpiry = expiresAt - now;\n\n if (timeUntilExpiry <= 0) {\n // Session already expired\n this.expireSession();\n return;\n }\n\n // Schedule expiry\n this.expiryTimeoutId = window.setTimeout(() => {\n info('Session expired (timeout)');\n this.expireSession();\n }, timeUntilExpiry);\n }\n\n /**\n * Setup activity tracking to extend session on user interaction\n */\n private setupActivityTracking(): void {\n if (this.activityTrackingActive) {\n return;\n }\n this.activityTrackingActive = true;\n const activityHandler = (): void => {\n const session = this.sessionService.getSession();\n if (!session) {\n return;\n }\n\n // Update activity timestamp and extend expiry\n this.sessionService.updateActivity();\n\n // Reschedule expiry check\n const updatedSession = this.sessionService.getSession();\n if (updatedSession) {\n this.scheduleExpiryCheck(updatedSession);\n }\n };\n\n // Track common user activities\n const events = ['click', 'keydown', 'scroll', 'mousemove'];\n\n // Debounce activity updates to avoid excessive writes\n let activityDebounceTimeout: number | undefined;\n const debouncedHandler = (): void => {\n if (activityDebounceTimeout !== undefined) {\n window.clearTimeout(activityDebounceTimeout);\n }\n\n activityDebounceTimeout = window.setTimeout(() => {\n activityHandler();\n }, 5000); // Update activity at most once per 5 seconds\n };\n\n events.forEach((event) => {\n document.addEventListener(event, debouncedHandler, { passive: true });\n });\n }\n\n /**\n * Cleanup session coordinator\n */\n cleanup(): void {\n if (this.expiryTimeoutId !== undefined) {\n window.clearTimeout(this.expiryTimeoutId);\n }\n document.removeEventListener('qd:login', this.handleLogin);\n }\n\n /**\n * Get the session service instance\n */\n getSessionService(): SessionService {\n return this.sessionService;\n }\n}\n","/**\n * Styles for the component.\n *\n * Extracted from qd-login.ts to keep the component focused on behavior.\n */\n\nimport { css } from 'lit';\n\nexport const loginStyles = css`\n :host {\n display: none; /* Hidden if already logged in */\n font-family:\n system-ui,\n -apple-system,\n sans-serif;\n }\n\n :host([data-show]) {\n display: block;\n }\n\n .login-container {\n padding: 8px 12px;\n background: #fff;\n border: 1px solid #ddd;\n border-radius: 4px;\n /* Wide enough to keep every control on one row, including the PIN field\n with its \"PIN (4 digits)\" placeholder. The surrounding header has\n ample horizontal space; this panel was the constraint, not the page. */\n max-width: 560px;\n }\n\n .title {\n margin: 0 0 8px 0;\n font-size: 15px;\n font-weight: 600;\n color: #333;\n }\n\n .login-form {\n display: flex;\n gap: 6px;\n align-items: flex-start;\n flex-wrap: wrap;\n }\n\n input {\n padding: 6px 10px;\n border: 1px solid #ccc;\n border-radius: 4px;\n font-size: 11px;\n width: 110px;\n min-width: 75px;\n max-width: 110px;\n }\n\n input.pin-input {\n /* Sized to show the \"PIN (4 digits)\" placeholder in full, so the field\n states its own rule rather than reading as an unexplained box. */\n width: 88px;\n min-width: 88px;\n max-width: 88px;\n text-align: center;\n letter-spacing: 1px;\n }\n\n input:focus {\n outline: none;\n border-color: #0066cc;\n box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.1);\n }\n\n input:disabled {\n background-color: #f5f5f5;\n cursor: not-allowed;\n }\n\n button {\n padding: 6px 12px;\n border: none;\n border-radius: 4px;\n font-size: 11px;\n font-weight: 500;\n cursor: pointer;\n transition: all 0.2s;\n white-space: nowrap;\n }\n\n .login-btn {\n background: #0066cc;\n color: white;\n }\n\n .login-btn:hover:not(:disabled) {\n background: #0052a3;\n }\n\n .login-btn:disabled {\n background: #ccc;\n cursor: not-allowed;\n }\n\n .instructor-btn {\n background: #6c757d;\n color: white;\n }\n\n .instructor-btn:hover {\n background: #5a6268;\n }\n\n /*\n * Reserve the message row so typing does not make the header grow and\n * shrink (SRD 013 UX-11)\n */\n .message-slot {\n width: 100%;\n min-height: 22px;\n }\n\n input[aria-invalid='true'] {\n border-color: #d32f2f;\n }\n\n .hint-message {\n width: 100%;\n /* Padding inside the 100% width, so long messages stay in the panel */\n box-sizing: border-box;\n color: #555;\n font-size: 11px;\n margin-top: 3px;\n padding: 4px 8px;\n background: #f1f3f4;\n border-radius: 3px;\n border-left: 3px solid #9aa0a6;\n }\n\n .notice-message {\n color: #0b4f8a;\n font-size: 11px;\n margin: 0 0 6px;\n padding: 4px 8px;\n background: #e8f1fb;\n border-radius: 3px;\n border-left: 3px solid #1d6fb8;\n }\n\n .error-message {\n width: 100%;\n /* Padding inside the 100% width, so long messages stay in the panel */\n box-sizing: border-box;\n color: #d32f2f;\n font-size: 11px;\n margin-top: 3px;\n padding: 4px 8px;\n background: #ffebee;\n border-radius: 3px;\n border-left: 3px solid #d32f2f;\n }\n\n .lockout-message {\n width: 100%;\n /* Padding inside the 100% width, so long messages stay in the panel */\n box-sizing: border-box;\n color: #f57c00;\n font-size: 11px;\n margin-top: 3px;\n padding: 4px 8px;\n background: #fff3e0;\n border-radius: 3px;\n border-left: 3px solid #f57c00;\n }\n\n /* Responsive */\n @media (max-width: 600px) {\n .login-form {\n flex-direction: column;\n }\n\n input,\n button {\n width: 100%;\n }\n`;\n","/**\n * Schema Migration Service\n *\n * Handles lazy migration of student records from v1 to v2.\n * Migration occurs on first login for existing students.\n */\n\nimport type { StudentRecord } from '../../types/contracts.js';\nimport { SCHEMA_VERSION, PIN_SCHEMA_VERSION } from '../../types/contracts.js';\nimport type { PinCredential } from '../auth/pin-service.js';\n\n/**\n * Check if a student record needs migration to v2\n *\n * @param record - Student record to check\n * @returns true if record needs PIN migration\n */\nexport function needsMigration(record: StudentRecord): boolean {\n // Compare with the PIN-era schema, not the current one: a later schema bump\n // must never send PIN-bearing records back through PIN setup, which would\n // let whoever logs in next choose a new PIN for someone else's account.\n return record.schema < PIN_SCHEMA_VERSION;\n}\n\n/**\n * Check if a student has a PIN set\n *\n * @param record - Student record to check\n * @returns true if student has a PIN hash\n */\nexport function hasPinSet(record: StudentRecord): boolean {\n return Boolean(record.pinHash && record.pinHash.length > 0);\n}\n\n/**\n * Migrate a student record from v1 to v2\n *\n * Updates schema version but does NOT set PIN - that happens\n * after the student creates their PIN.\n *\n * @param record - Student record to migrate\n * @returns Updated record with v2 schema (pinHash empty)\n */\nexport function migrateToV2(record: StudentRecord): StudentRecord {\n if (record.schema >= PIN_SCHEMA_VERSION) {\n return record;\n }\n\n return {\n ...record,\n schema: SCHEMA_VERSION,\n // PIN fields left empty - student will create PIN on login\n pinHash: '',\n pinCreatedAt: undefined,\n pinResetAt: undefined,\n };\n}\n\n/**\n * Complete PIN setup for a migrated or new student\n *\n * @param record - Student record\n * @param pinHash - Hashed PIN\n * @returns Updated record with PIN set\n */\nexport function completePinSetup(record: StudentRecord, credential: PinCredential): StudentRecord {\n return {\n ...record,\n schema: SCHEMA_VERSION,\n ...credential,\n pinCreatedAt: new Date().toISOString(),\n };\n}\n\n/**\n * Replace a legacy PIN verifier with a salted KDF one (SRD 013 SEC-07).\n *\n * Called after the student's PIN has been verified against the legacy hash.\n * Keeps `pinCreatedAt`, since the PIN itself has not changed.\n *\n * @param record - Student record holding a legacy SHA-256 PIN hash\n * @param credential - New salted verifier for the same PIN\n * @returns Updated record stamped with the current schema\n */\nexport function upgradePinCredential(\n record: StudentRecord,\n credential: PinCredential,\n): StudentRecord {\n return { ...record, schema: SCHEMA_VERSION, ...credential };\n}\n\n/**\n * Reset a student's PIN (instructor action)\n *\n * @param record - Student record\n * @returns Updated record with PIN cleared\n */\nexport function resetPin(record: StudentRecord): StudentRecord {\n const { pinSalt: _salt, pinIterations: _iterations, pinKdf: _kdf, ...rest } = record;\n return {\n ...rest,\n pinHash: '',\n pinResetAt: new Date().toISOString(),\n };\n}\n","/**\n * PIN Authentication Service\n *\n * Provides secure PIN hashing and verification using Web Crypto API.\n * Implements constant-time comparison to prevent timing attacks.\n */\n\nimport { PIN_CONSTANTS } from '../../types/contracts.js';\nimport type { StudentRecord } from '../../types/contracts.js';\n\n/**\n * Salted PIN verifier as stored on a student record (SRD 013 SEC-06).\n */\nexport interface PinCredential {\n /** Hex PBKDF2-SHA-256 output (32 bytes) */\n pinHash: string;\n /** Hex random salt */\n pinSalt: string;\n /** Iteration count used */\n pinIterations: number;\n /** KDF identifier */\n pinKdf: 'pbkdf2-sha256';\n}\n\n/** Result of checking a PIN against a stored record. */\nexport interface StoredPinCheck {\n /** Whether the PIN matched */\n valid: boolean;\n /** Present when the record held a legacy hash: the salted verifier to store instead */\n upgrade?: PinCredential;\n}\n\n/** Hex-encode bytes. */\nfunction toHex(bytes: Uint8Array): string {\n return Array.from(bytes)\n .map((b) => b.toString(16).padStart(2, '0'))\n .join('');\n}\n\n/** Decode a hex string to bytes. */\nfunction fromHex(hex: string): Uint8Array {\n const out = new Uint8Array(new ArrayBuffer(hex.length / 2));\n for (let i = 0; i < out.length; i++) {\n out[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);\n }\n return out;\n}\n\n/**\n * Derive a PIN verifier with PBKDF2-SHA-256.\n *\n * @param pin - 4-digit PIN\n * @param saltHex - Hex-encoded salt\n * @param iterations - PBKDF2 iteration count\n * @returns Hex-encoded 32-byte derived key\n */\nexport async function derivePinHash(\n pin: string,\n saltHex: string,\n iterations: number,\n): Promise {\n const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(pin), 'PBKDF2', false, [\n 'deriveBits',\n ]);\n const bits = await crypto.subtle.deriveBits(\n { name: 'PBKDF2', hash: 'SHA-256', salt: fromHex(saltHex), iterations },\n key,\n 256,\n );\n return toHex(new Uint8Array(bits));\n}\n\n/**\n * Create a new salted verifier for a PIN.\n *\n * A 4-digit PIN has only 10,000 values, so this cannot make a PIN secret from\n * someone holding the database. The per-student salt and iteration count turn\n * a lookup that took milliseconds into minutes of work per PIN.\n *\n * @param pin - 4-digit PIN\n * @param iterations - Iteration count (defaults to the current policy)\n * @returns Credential fields to store on the student record\n */\nexport async function createPinCredential(\n pin: string,\n iterations: number = PIN_CONSTANTS.KDF_ITERATIONS,\n): Promise {\n const salt = toHex(crypto.getRandomValues(new Uint8Array(PIN_CONSTANTS.SALT_BYTES)));\n return {\n pinHash: await derivePinHash(pin, salt, iterations),\n pinSalt: salt,\n pinIterations: iterations,\n pinKdf: 'pbkdf2-sha256',\n };\n}\n\n/**\n * Check a PIN against whatever verifier a student record holds.\n *\n * Salted records are checked with PBKDF2. Legacy records (unsalted SHA-256)\n * still verify, and a match returns a salted replacement so the caller can\n * upgrade the record (SRD 013 SEC-07).\n *\n * @param pin - PIN the student typed\n * @param record - Stored PIN fields\n * @returns Whether it matched, plus an upgrade credential for legacy records\n */\nexport async function verifyStoredPin(\n pin: string,\n record: Pick,\n): Promise {\n const stored = record.pinHash ?? '';\n if (!stored) {\n return { valid: false };\n }\n\n if (record.pinKdf === 'pbkdf2-sha256' && record.pinSalt && record.pinIterations) {\n const derived = await derivePinHash(pin, record.pinSalt, record.pinIterations);\n return { valid: constantTimeCompare(derived, stored) };\n }\n\n if (!(await verifyPin(pin, stored))) {\n return { valid: false };\n }\n return { valid: true, upgrade: await createPinCredential(pin) };\n}\n\n/**\n * PIN validation result\n */\nexport interface PinValidationResult {\n valid: boolean;\n error?: string;\n}\n\n/**\n * Hash a PIN using unsalted SHA-256 (legacy v2 verifier).\n *\n * Kept only to verify records written before SRD 013. New verifiers come from\n * {@link createPinCredential}.\n *\n * @param pin - 4-digit PIN to hash\n * @returns Promise resolving to hex-encoded hash\n */\nexport async function hashPin(pin: string): Promise {\n const encoder = new TextEncoder();\n const data = encoder.encode(pin);\n const hashBuffer = await crypto.subtle.digest('SHA-256', data);\n const hashArray = Array.from(new Uint8Array(hashBuffer));\n return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');\n}\n\n/**\n * Verify a PIN against a stored hash\n *\n * Uses constant-time comparison to prevent timing attacks.\n *\n * @param pin - PIN to verify\n * @param storedHash - Stored SHA-256 hash\n * @returns Promise resolving to true if PIN matches\n */\nexport async function verifyPin(pin: string, storedHash: string): Promise {\n const inputHash = await hashPin(pin);\n return constantTimeCompare(inputHash, storedHash);\n}\n\n/**\n * Constant-time string comparison\n *\n * Compares strings in constant time to prevent timing attacks.\n * XORs each character and accumulates differences.\n *\n * @param a - First string\n * @param b - Second string\n * @returns true if strings are equal\n */\nfunction constantTimeCompare(a: string, b: string): boolean {\n if (a.length !== b.length) {\n return false;\n }\n\n let result = 0;\n for (let i = 0; i < a.length; i++) {\n result |= a.charCodeAt(i) ^ b.charCodeAt(i);\n }\n return result === 0;\n}\n\n/**\n * Validate PIN format\n *\n * @param pin - PIN to validate\n * @returns Validation result with error message if invalid\n */\nexport function validatePinFormat(pin: string): PinValidationResult {\n if (!pin) {\n return { valid: false, error: 'PIN is required' };\n }\n\n if (pin.length !== PIN_CONSTANTS.PIN_LENGTH) {\n return { valid: false, error: `PIN must be exactly ${PIN_CONSTANTS.PIN_LENGTH} digits` };\n }\n\n if (!/^\\d+$/.test(pin)) {\n return { valid: false, error: 'PIN must contain only digits' };\n }\n\n return { valid: true };\n}\n\n/**\n * Validate PIN confirmation matches\n *\n * @param pin - Original PIN\n * @param confirm - Confirmation PIN\n * @returns Validation result with error message if mismatch\n */\nexport function validatePinConfirmation(pin: string, confirm: string): PinValidationResult {\n if (pin !== confirm) {\n return { valid: false, error: 'PINs do not match' };\n }\n return { valid: true };\n}\n","/**\n * Rate Limiter Service for PIN Authentication\n *\n * Tracks failed PIN attempts using sessionStorage.\n * Implements lockout after 3 failed attempts for 30 seconds.\n */\n\nimport type { PinAttemptState, ServiceId } from '../../types/contracts.js';\nimport { PIN_CONSTANTS, STORAGE_KEYS } from '../../types/contracts.js';\nimport { info, warn, maskServiceId } from '../../utils/logger.js';\n\n/**\n * Get the storage key for a service ID's PIN attempts\n */\nfunction getAttemptKey(serviceId: ServiceId): string {\n return `${STORAGE_KEYS.PIN_ATTEMPTS}:${serviceId}`;\n}\n\n/**\n * Get the current PIN attempt state for a service ID\n *\n * @param serviceId - Student service ID\n * @returns Current attempt state or null if none\n */\nexport function getAttemptState(serviceId: ServiceId): PinAttemptState | null {\n const key = getAttemptKey(serviceId);\n const data = sessionStorage.getItem(key);\n if (!data) {\n return null;\n }\n try {\n return JSON.parse(data) as PinAttemptState;\n } catch {\n return null;\n }\n}\n\n/**\n * Check if a service ID is currently locked out\n *\n * @param serviceId - Student service ID\n * @returns Object with isLocked status and remainingMs if locked\n */\nexport function checkLockout(serviceId: ServiceId): { isLocked: boolean; remainingMs: number } {\n const state = getAttemptState(serviceId);\n if (!state || !state.lockoutUntil) {\n return { isLocked: false, remainingMs: 0 };\n }\n\n const lockoutTime = new Date(state.lockoutUntil).getTime();\n const now = Date.now();\n\n if (lockoutTime > now) {\n return { isLocked: true, remainingMs: lockoutTime - now };\n }\n\n // Lockout expired, clear state\n clearAttemptState(serviceId);\n return { isLocked: false, remainingMs: 0 };\n}\n\n/**\n * Record a failed PIN attempt\n *\n * Increments attempt counter and sets lockout if threshold reached.\n *\n * @param serviceId - Student service ID\n * @returns Updated attempt state\n */\nexport function recordFailedAttempt(serviceId: ServiceId): PinAttemptState {\n const now = new Date().toISOString();\n let state = getAttemptState(serviceId);\n\n if (!state) {\n state = {\n serviceId,\n attempts: 0,\n lockoutUntil: null,\n lastAttempt: now,\n };\n }\n\n state.attempts += 1;\n state.lastAttempt = now;\n\n // Check if lockout threshold reached\n if (state.attempts >= PIN_CONSTANTS.MAX_ATTEMPTS) {\n const lockoutTime = new Date(Date.now() + PIN_CONSTANTS.LOCKOUT_MS);\n state.lockoutUntil = lockoutTime.toISOString();\n warn(\n `PIN lockout triggered for ${maskServiceId(serviceId)} after ${state.attempts} failed attempts`,\n );\n } else {\n info(\n `Failed PIN attempt ${state.attempts}/${PIN_CONSTANTS.MAX_ATTEMPTS} for ${maskServiceId(serviceId)}`,\n );\n }\n\n // Save to sessionStorage\n const key = getAttemptKey(serviceId);\n sessionStorage.setItem(key, JSON.stringify(state));\n\n return state;\n}\n\n/**\n * Clear PIN attempt state on successful login\n *\n * @param serviceId - Student service ID\n */\nexport function clearAttemptState(serviceId: ServiceId): void {\n const state = getAttemptState(serviceId);\n if (state && state.attempts > 0) {\n info(\n `Cleared ${state.attempts} failed PIN attempts for ${maskServiceId(serviceId)} on successful login`,\n );\n }\n const key = getAttemptKey(serviceId);\n sessionStorage.removeItem(key);\n}\n\n/**\n * Get remaining attempts before lockout\n *\n * @param serviceId - Student service ID\n * @returns Number of attempts remaining (0 if locked out)\n */\nexport function getRemainingAttempts(serviceId: ServiceId): number {\n const state = getAttemptState(serviceId);\n if (!state) {\n return PIN_CONSTANTS.MAX_ATTEMPTS;\n }\n\n const lockout = checkLockout(serviceId);\n if (lockout.isLocked) {\n return 0;\n }\n\n return Math.max(0, PIN_CONSTANTS.MAX_ATTEMPTS - state.attempts);\n}\n","/**\n * Student authentication service.\n *\n * The single source of student login logic. Previously the ~100-line login\n * flow (lockout check → storage init → student lookup → PIN setup/verify →\n * rate-limiting → new-student creation) was duplicated verbatim in\n * `qd-login.ts` across `handleStudentLogin` and `retryLoginAfterMigration`.\n * Both paths now delegate to {@link AuthService}, which performs all storage,\n * crypto, and rate-limit calls and returns a discriminated {@link LoginResult}.\n *\n * The component is left to translate the result into session creation, custom\n * events, and UI state — it performs no storage/crypto/rate-limit work itself\n * (contracts/module-boundaries.md → Auth).\n */\n\nimport { SCHEMA_VERSION } from '../../types/contracts.js';\nimport type { StudentRecord } from '../../types/contracts.js';\nimport { getStorageAdapter } from '../storage/indexeddb.js';\nimport {\n needsMigration,\n hasPinSet,\n completePinSetup,\n upgradePinCredential,\n} from '../storage/migration.js';\nimport { createPinCredential, verifyStoredPin } from './pin-service.js';\nimport {\n checkLockout,\n recordFailedAttempt,\n clearAttemptState,\n getRemainingAttempts,\n} from './rate-limiter.js';\nimport { StorageFormatError } from '../storage/adapter-utils.js';\n\n/**\n * Discriminated result of a student login attempt.\n *\n * - `pin-created` — a new PIN was stored (new student, migrated student, or a\n * student who had no PIN). The caller should surface the \"PIN stored\"\n * confirmation and complete the login.\n * - `pin-verified` — an existing student's PIN matched. The caller completes\n * the login without a confirmation dialog.\n * - `lockout` — the account is (or just became) locked; `lockoutMs` is the\n * remaining lockout window in milliseconds.\n * - `bad-pin` — the PIN was wrong but the account is not yet locked;\n * `remaining` is the number of attempts left.\n * - `needs-migration` — stored data is in an incompatible format and must be\n * migrated before login can proceed.\n * - `error` — login failed for another reason; `message` is user-facing.\n */\nexport type LoginResult =\n | { kind: 'pin-created'; serviceId: string; name: string; release: string }\n | { kind: 'pin-verified'; serviceId: string; name: string; release: string }\n | { kind: 'lockout'; lockoutMs: number }\n | { kind: 'bad-pin'; remaining: number }\n | { kind: 'needs-migration'; error: StorageFormatError }\n | { kind: 'error'; message: string };\n\n/**\n * Inputs required to authenticate a student.\n */\nexport interface StudentLoginInput {\n serviceId: string;\n name: string;\n pin: string;\n release: string;\n dbName: string;\n}\n\ninterface RunOptions {\n /** Whether to enforce the lockout window before attempting login. */\n checkLock: boolean;\n /** Surface a {@link StorageFormatError} as `needs-migration` (initial login only). */\n surfaceMigration: boolean;\n /** User-facing message used when an unexpected error occurs. */\n errorMessage: string;\n /** Console label used when logging an unexpected error. */\n errorLabel: string;\n}\n\n/**\n * Service encapsulating all student authentication logic.\n */\nexport class AuthService {\n /**\n * Authenticate a student (initial login path).\n *\n * Enforces the lockout window and surfaces storage-format mismatches as\n * `needs-migration` so the caller can offer migration.\n */\n loginStudent(input: StudentLoginInput): Promise {\n return this.runLogin(input, {\n checkLock: true,\n surfaceMigration: true,\n errorMessage: 'Login failed. Please try again.',\n errorLabel: 'Student login error:',\n });\n }\n\n /**\n * Authenticate a student immediately after a successful data migration.\n *\n * Skips the lockout pre-check (the user just completed migration) and treats\n * any remaining error as a generic post-migration failure.\n */\n retryAfterMigration(input: StudentLoginInput): Promise {\n return this.runLogin(input, {\n checkLock: false,\n surfaceMigration: false,\n errorMessage: 'Login failed after migration. Please try again.',\n errorLabel: 'Post-migration login error:',\n });\n }\n\n /**\n * Whether a service ID already has an account with a usable PIN.\n *\n * Used by the login form to label its submit button \"Login\" or \"Create\", so\n * a first-time user is told they are about to create an account rather than\n * guessing why their credentials are not accepted. Accounts are keyed on\n * `{release, serviceId}`, so the name is not part of this check.\n *\n * Never throws: any storage problem resolves to `null` (\"unknown\"), leaving\n * the form to fall back to its neutral label rather than blocking the user.\n *\n * @param serviceId - Service ID being entered\n * @param release - Release the account would belong to\n * @param dbName - IndexedDB database name from page config\n * @returns true when a PIN-bearing record exists, false when it does not,\n * null when the lookup could not be completed\n */\n async isRegistered(serviceId: string, release: string, dbName: string): Promise {\n try {\n const storage = getStorageAdapter(dbName);\n await storage.init();\n const existing = await storage.getStudent(release, serviceId);\n if (!existing) {\n return false;\n }\n // A record awaiting PIN setup still needs the create-and-confirm flow\n return hasPinSet(existing) && !needsMigration(existing);\n } catch {\n return null;\n }\n }\n\n /**\n * Shared login implementation for both the initial and post-migration paths.\n */\n private async runLogin(input: StudentLoginInput, opts: RunOptions): Promise {\n const { serviceId, name, pin, release, dbName } = input;\n\n if (opts.checkLock) {\n const lockout = checkLockout(serviceId);\n if (lockout.isLocked) {\n return { kind: 'lockout', lockoutMs: lockout.remainingMs };\n }\n }\n\n try {\n const storage = getStorageAdapter(dbName);\n await storage.init();\n const existingStudent = await storage.getStudent(release, serviceId);\n\n if (existingStudent) {\n // Existing student needing PIN setup (legacy schema or never set a PIN)\n if (needsMigration(existingStudent) || !hasPinSet(existingStudent)) {\n const credential = await createPinCredential(pin);\n const updatedStudent = completePinSetup(existingStudent, credential);\n await storage.saveStudent(updatedStudent);\n return { kind: 'pin-created', serviceId, name, release };\n }\n\n // Existing student with a PIN — verify it\n const check = await verifyStoredPin(pin, existingStudent);\n if (!check.valid) {\n const state = recordFailedAttempt(serviceId);\n if (state.lockoutUntil) {\n const lockoutMs = new Date(state.lockoutUntil).getTime() - Date.now();\n return { kind: 'lockout', lockoutMs };\n }\n return { kind: 'bad-pin', remaining: getRemainingAttempts(serviceId) };\n }\n\n clearAttemptState(serviceId);\n // Legacy unsalted hash: replace it now that we know the PIN (SEC-07)\n if (check.upgrade) {\n await storage.saveStudent(upgradePinCredential(existingStudent, check.upgrade));\n }\n return { kind: 'pin-verified', serviceId, name, release };\n }\n\n // New student — derive a salted PIN verifier and create the record\n const credential = await createPinCredential(pin);\n const newStudent: StudentRecord = {\n schema: SCHEMA_VERSION,\n docId: '',\n release,\n serviceId,\n name,\n attempted: 0,\n correct: 0,\n updated: new Date().toISOString(),\n pages: {},\n ...credential,\n pinCreatedAt: new Date().toISOString(),\n };\n await storage.saveStudent(newStudent);\n return { kind: 'pin-created', serviceId, name, release };\n } catch (err) {\n if (opts.surfaceMigration && err instanceof StorageFormatError) {\n return { kind: 'needs-migration', error: err };\n }\n console.error(opts.errorLabel, err);\n return { kind: 'error', message: opts.errorMessage };\n }\n }\n}\n","/**\n * Build Info Component\n *\n * Displays a small info icon (i) that shows build information on hover.\n * Tooltip shows: app name and build date.\n *\n * @element qd-build-info\n *\n * @example\n * ```html\n * \n * ```\n */\n\nimport { LitElement, html, css } from 'lit';\nimport { customElement } from 'lit/decorators.js';\n\n// Type declaration for Vite build-time constant\ndeclare const __BUILD_DATE__: string;\n\n/**\n * Build info component with tooltip\n */\n@customElement('qd-build-info')\nexport class QdBuildInfo extends LitElement {\n static styles = css`\n :host {\n display: inline-block;\n position: relative;\n }\n\n .info-icon {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 16px;\n height: 16px;\n border-radius: 50%;\n background: #6c757d;\n color: white;\n font-size: 10px;\n font-weight: bold;\n font-style: italic;\n font-family: Georgia, serif;\n cursor: help;\n user-select: none;\n }\n\n .info-icon:hover {\n background: #5a6268;\n }\n\n .tooltip {\n position: absolute;\n top: 50%;\n right: 100%;\n transform: translateY(-50%);\n margin-right: 8px;\n padding: 8px 12px;\n background: #333;\n color: white;\n font-size: 11px;\n font-style: normal;\n font-family:\n system-ui,\n -apple-system,\n sans-serif;\n border-radius: 4px;\n white-space: nowrap;\n opacity: 0;\n visibility: hidden;\n transition:\n opacity 0.2s,\n visibility 0.2s;\n z-index: 1000;\n pointer-events: none;\n }\n\n .tooltip::after {\n content: '';\n position: absolute;\n top: 50%;\n left: 100%;\n transform: translateY(-50%);\n border: 5px solid transparent;\n border-left-color: #333;\n }\n\n .info-icon:hover + .tooltip,\n .info-icon:focus + .tooltip {\n opacity: 1;\n visibility: visible;\n }\n\n .tooltip-line {\n display: block;\n line-height: 1.4;\n }\n `;\n\n render() {\n const buildDate = typeof __BUILD_DATE__ !== 'undefined' ? __BUILD_DATE__ : 'Development';\n\n return html`\n i\n
                                        \n BrowserTest, from Deep Blue C Ltd\n Built ${buildDate}\n
                                        \n `;\n }\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n 'qd-build-info': QdBuildInfo;\n }\n}\n","/**\n * Base Modal Component\n *\n * Reusable modal with backdrop, keyboard handling, and focus trap.\n * Uses fixed positioning with high z-index for proper stacking.\n * Used as base for scores modal, password modal, and confirm dialogs.\n *\n * @element qd-modal\n * @fires {CustomEvent} qd:modal-close - Emitted when modal closes via Escape or backdrop click\n *\n * @slot - Default slot for modal content\n * @slot header - Optional header slot for modal title\n *\n * Feature: 007-lit-component-refactor\n */\n\nimport { LitElement, html, css } from 'lit';\nimport { customElement, property } from 'lit/decorators.js';\n\n// Track currently open modal for collision handling\n// Using globalThis to ensure state persists across module re-imports in test environments\nconst MODAL_STATE_KEY = '__qdModalCurrentRef__';\n\nfunction getCurrentModal(): QdModal | null {\n return ((globalThis as Record)[MODAL_STATE_KEY] as QdModal) ?? null;\n}\n\nfunction setCurrentModal(modal: QdModal | null): void {\n (globalThis as Record)[MODAL_STATE_KEY] = modal;\n}\n\n/**\n * Base modal component with common modal behavior\n * Moves entire element to document.body when open to escape stacking context issues\n */\n@customElement('qd-modal')\nexport class QdModal extends LitElement {\n static override styles = css`\n :host {\n display: contents;\n }\n\n .backdrop {\n display: none;\n }\n\n :host([open]) .backdrop {\n display: flex;\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba(0, 0, 0, 0.5);\n align-items: center;\n justify-content: center;\n z-index: 99999;\n font-family:\n system-ui,\n -apple-system,\n sans-serif;\n animation: qd-modal-fadeIn 0.15s ease-out;\n }\n\n @keyframes qd-modal-fadeIn {\n from {\n opacity: 0;\n }\n to {\n opacity: 1;\n }\n }\n\n .content {\n background: white;\n border-radius: 8px;\n box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);\n max-width: 90vw;\n max-height: 90vh;\n overflow: auto;\n animation: qd-modal-slideIn 0.15s ease-out;\n }\n\n @keyframes qd-modal-slideIn {\n from {\n transform: translateY(-20px);\n opacity: 0;\n }\n to {\n transform: translateY(0);\n opacity: 1;\n }\n }\n\n .header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 16px 20px;\n border-bottom: 1px solid #eee;\n font-weight: 600;\n font-size: 18px;\n }\n\n .header ::slotted(*) {\n margin: 0;\n }\n\n .close-button {\n background: none;\n border: none;\n cursor: pointer;\n padding: 4px 8px;\n font-size: 20px;\n color: #666;\n line-height: 1;\n border-radius: 4px;\n transition:\n background-color 0.2s,\n color 0.2s;\n margin-left: auto;\n }\n\n .close-button:hover {\n background: #f0f0f0;\n color: #333;\n }\n\n .close-button:focus {\n outline: 2px solid #0066cc;\n outline-offset: 2px;\n }\n\n .body {\n padding: 20px;\n }\n `;\n\n /**\n * Whether the modal is open\n */\n @property({ type: Boolean, reflect: true })\n open = false;\n\n /**\n * Whether the modal can be closed via Escape/backdrop click\n */\n @property({ type: Boolean })\n closable = true;\n\n /**\n * Previously focused element (for focus restoration)\n */\n private previouslyFocused: Element | null = null;\n\n /**\n * Original parent element (for restoration when closing)\n */\n private originalParent: ParentNode | null = null;\n\n /**\n * Original next sibling (to restore position in DOM)\n */\n private originalNextSibling: Node | null = null;\n\n /**\n * Whether we're currently in body (to prevent double-moves)\n */\n private isInBody = false;\n\n connectedCallback() {\n super.connectedCallback();\n document.addEventListener('keydown', this.handleKeyDown);\n }\n\n disconnectedCallback() {\n super.disconnectedCallback();\n document.removeEventListener('keydown', this.handleKeyDown);\n\n // Clean up if this was the open modal\n // But NOT if we're just moving to body (isInBody will be true during that move)\n if (getCurrentModal() === this && !this.isInBody) {\n setCurrentModal(null);\n }\n }\n\n override updated(changedProperties: Map) {\n if (changedProperties.has('open')) {\n if (this.open) {\n this.handleOpen();\n } else {\n this.handleClose();\n }\n }\n }\n\n /**\n * Move this element to document.body to escape stacking contexts\n */\n private moveToBody(): void {\n if (this.isInBody) return;\n\n // Store original location for restoration\n this.originalParent = this.parentNode;\n this.originalNextSibling = this.nextSibling;\n\n // Set flag BEFORE move so disconnectedCallback knows we're just relocating\n this.isInBody = true;\n\n // Move to body\n document.body.appendChild(this);\n }\n\n /**\n * Restore this element to its original position in the DOM\n */\n private restorePosition(): void {\n if (!this.isInBody || !this.originalParent) return;\n\n // Restore to original position\n if (this.originalNextSibling) {\n this.originalParent.insertBefore(this, this.originalNextSibling);\n } else {\n this.originalParent.appendChild(this);\n }\n\n this.originalParent = null;\n this.originalNextSibling = null;\n this.isInBody = false;\n }\n\n override render() {\n return html`\n
                                        \n
                                        \n
                                        \n \n ${this.closable\n ? html`\n ×\n `\n : ''}\n
                                        \n
                                        \n \n
                                        \n
                                        \n
                                        \n `;\n }\n\n /**\n * Open the modal\n */\n show() {\n this.open = true;\n }\n\n /**\n * Close the modal\n */\n close() {\n this.open = false;\n }\n\n /**\n * Handle modal opening\n */\n private handleOpen() {\n // Modal collision: close any existing open modal\n const currentModal = getCurrentModal();\n if (currentModal && currentModal !== this) {\n currentModal.close();\n }\n setCurrentModal(this);\n\n // Store currently focused element for restoration\n this.previouslyFocused = document.activeElement;\n\n // Move element to body to escape stacking contexts\n this.moveToBody();\n\n // Focus first element after render\n requestAnimationFrame(() => {\n this.focusFirstElement();\n });\n }\n\n /**\n * Handle modal closing\n */\n private handleClose() {\n if (getCurrentModal() === this) {\n setCurrentModal(null);\n }\n\n // Restore element to original position\n this.restorePosition();\n\n // Restore focus\n if (this.previouslyFocused instanceof HTMLElement) {\n this.previouslyFocused.focus();\n }\n }\n\n /**\n * Focus the first focusable element in the modal\n */\n private focusFirstElement() {\n const content = this.shadowRoot?.querySelector('.content');\n if (!content) return;\n\n // Check slotted content for focusable elements\n const slot = this.shadowRoot?.querySelector('slot:not([name])') as HTMLSlotElement;\n if (slot) {\n const assignedElements = slot.assignedElements({ flatten: true });\n for (const el of assignedElements) {\n const focusable = el.querySelector(\n 'button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])',\n );\n if (focusable) {\n focusable.focus();\n return;\n }\n // Check if element itself is focusable\n if (\n el instanceof HTMLElement &&\n el.matches('button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])')\n ) {\n el.focus();\n return;\n }\n }\n }\n\n // Fall back to close button\n const closeBtn = this.shadowRoot?.querySelector('.close-button');\n if (closeBtn) {\n closeBtn.focus();\n }\n }\n\n /**\n * Handle keyboard events\n */\n private handleKeyDown = (event: KeyboardEvent) => {\n if (event.key === 'Escape' && this.open && this.closable) {\n this.emitCloseEvent();\n this.close();\n }\n };\n\n /**\n * Handle backdrop click\n */\n private handleBackdropClick = () => {\n if (this.closable) {\n this.emitCloseEvent();\n this.close();\n }\n };\n\n /**\n * Handle close button click\n */\n private handleCloseClick = () => {\n this.emitCloseEvent();\n this.close();\n };\n\n /**\n * Stop propagation for content clicks\n */\n private stopPropagation = (event: Event) => {\n event.stopPropagation();\n };\n\n /**\n * Emit close event\n */\n private emitCloseEvent() {\n const event = new CustomEvent('qd:modal-close', {\n bubbles: true,\n composed: true,\n });\n this.dispatchEvent(event);\n }\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n 'qd-modal': QdModal;\n }\n}\n","/**\n * Password modal component\n *\n * Reusable password entry modal using qd-modal base.\n * Used by qd-login for instructor authentication.\n *\n * Feature: 007-lit-component-refactor\n *\n * @element qd-password-modal\n * @fires {CustomEvent<{password: string}>} qd:password-submit - Emitted on form submission\n * @fires {CustomEvent} close - Emitted when modal closes\n */\n\nimport { LitElement, html, css, nothing } from 'lit';\nimport { customElement, property, state, query } from 'lit/decorators.js';\nimport './qd-modal.js';\n\n@customElement('qd-password-modal')\nexport class QdPasswordModal extends LitElement {\n static override styles = css`\n :host {\n display: contents;\n }\n\n .password-form {\n display: flex;\n flex-direction: column;\n gap: 16px;\n padding: 8px 0;\n }\n\n .form-field {\n display: flex;\n flex-direction: column;\n gap: 4px;\n }\n\n label {\n font-size: 13px;\n font-weight: 500;\n color: #333;\n }\n\n input[type='password'] {\n padding: 8px 12px;\n border: 1px solid #ccc;\n border-radius: 4px;\n font-size: 14px;\n width: 100%;\n box-sizing: border-box;\n }\n\n input[type='password']:focus {\n outline: none;\n border-color: #0066cc;\n box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.1);\n }\n\n .error-message {\n color: #d32f2f;\n font-size: 12px;\n padding: 8px;\n background: #ffebee;\n border-radius: 4px;\n border-left: 3px solid #d32f2f;\n }\n\n .button-row {\n display: flex;\n gap: 8px;\n justify-content: flex-end;\n margin-top: 8px;\n }\n\n button {\n padding: 8px 16px;\n border: none;\n border-radius: 4px;\n font-size: 13px;\n font-weight: 500;\n cursor: pointer;\n transition: background-color 0.2s;\n }\n\n button[type='submit'] {\n background: #0066cc;\n color: white;\n }\n\n button[type='submit']:hover {\n background: #0052a3;\n }\n\n button[type='button'] {\n background: #e0e0e0;\n color: #333;\n }\n\n button[type='button']:hover {\n background: #d0d0d0;\n }\n `;\n\n /**\n * Whether modal is open\n */\n @property({ type: Boolean, reflect: true })\n open = false;\n\n /**\n * Modal title\n */\n @property({ type: String })\n title = 'Enter Password';\n\n /**\n * Error message to display\n */\n @property({ type: String })\n error = '';\n\n /**\n * Internal password value\n */\n @state()\n private password = '';\n\n /**\n * Reference to password input\n */\n @query('input[type=\"password\"]')\n private passwordInput!: HTMLInputElement;\n\n /**\n * Show the modal\n */\n show(): void {\n this.open = true;\n this.password = '';\n this.error = '';\n }\n\n /**\n * Close the modal\n */\n close(): void {\n this.open = false;\n this.password = '';\n this.error = '';\n this.dispatchEvent(new CustomEvent('close', { bubbles: true, composed: true }));\n }\n\n /**\n * Handle modal close from qd-modal\n */\n private handleModalClose = (): void => {\n this.close();\n };\n\n /**\n * Handle password input\n */\n private handleInput = (e: Event): void => {\n const input = e.target as HTMLInputElement;\n this.password = input.value;\n // Clear error on input\n if (this.error) {\n this.error = '';\n }\n };\n\n /**\n * Handle form submission\n */\n private handleSubmit = (e: Event): void => {\n e.preventDefault();\n\n if (!this.password.trim()) {\n return;\n }\n\n this.dispatchEvent(\n new CustomEvent('qd:password-submit', {\n detail: { password: this.password },\n bubbles: true,\n composed: true,\n }),\n );\n };\n\n /**\n * Handle cancel button\n */\n private handleCancel = (): void => {\n this.close();\n };\n\n /**\n * Focus password input when modal opens\n */\n override updated(changedProps: Map): void {\n if (changedProps.has('open') && this.open) {\n // Reset state when opening\n this.password = '';\n // Focus input after render\n void this.updateComplete.then(() => {\n this.passwordInput?.focus();\n });\n }\n }\n\n override render() {\n // Always render qd-modal so it can properly restore position when closing\n // Only render form content when open\n return html`\n \n ${this.title}\n\n ${this.open\n ? html`\n
                                        \n
                                        \n \n \n
                                        \n\n ${this.error ? html`
                                        ${this.error}
                                        ` : ''}\n\n
                                        \n \n \n
                                        \n
                                        \n `\n : nothing}\n
                                        \n `;\n }\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n 'qd-password-modal': QdPasswordModal;\n }\n}\n","/**\n * Instructor Login Component\n *\n * Encapsulates the instructor authentication flow: the \"Instructor\" button, the\n * password modal, and verification. Extracted from `qd-login` so the login\n * component only owns the student flow.\n *\n * @element qd-instructor-login\n * @fires {CustomEvent<{serviceId:'INSTRUCTOR', name:'Instructor', release:string, role:'instructor'}>} qd:login - On successful instructor auth\n */\n\nimport { LitElement, html, css } from 'lit';\nimport { customElement, state, property } from 'lit/decorators.js';\nimport { PIN_CONSTANTS } from '../types/contracts.js';\nimport { SessionService } from '../services/session.js';\nimport { readRelease } from '../config/dom-config-reader.js';\nimport { RateLimiter } from '../utils/security.js';\nimport { getExpectedInstructorHash } from '../services/auth/instructor-auth.js';\nimport { startInstructorSession } from '../services/auth/instructor-session.js';\nimport './qd-password-modal.js';\n\n/**\n * Instructor login button + password modal.\n */\n@customElement('qd-instructor-login')\nexport class QdInstructorLogin extends LitElement {\n /** Disable the button (e.g. while the parent form is submitting). */\n @property({ type: Boolean })\n disabled = false;\n\n @state()\n private showModal = false;\n\n @state()\n private error = '';\n\n // Same allowance as the student PIN policy (PIN_CONSTANTS.MAX_ATTEMPTS): the\n // third consecutive wrong password starts the exponential lockout.\n private rateLimiter = new RateLimiter(PIN_CONSTANTS.MAX_ATTEMPTS - 1);\n\n static styles = css`\n button {\n padding: 6px 12px;\n border: none;\n border-radius: 4px;\n font-size: 11px;\n font-weight: 500;\n cursor: pointer;\n transition: all 0.2s;\n white-space: nowrap;\n background: #6c757d;\n color: white;\n }\n\n button:hover:not(:disabled) {\n background: #5a6268;\n }\n\n button:disabled {\n cursor: not-allowed;\n opacity: 0.7;\n }\n `;\n\n render() {\n return html`\n \n\n \n `;\n }\n\n private open = (): void => {\n this.showModal = true;\n this.error = '';\n };\n\n private handleClose = (): void => {\n this.showModal = false;\n this.error = '';\n };\n\n private handleSubmit = (e: CustomEvent<{ password: string }>): void => {\n void this.login(e.detail.password);\n };\n\n /**\n * Verify the password and, on success, create the instructor session and\n * emit `qd:login`.\n */\n private async login(password: string): Promise {\n const expectedHash = getExpectedInstructorHash();\n if (!expectedHash) {\n this.error = 'Instructor password not configured';\n return;\n }\n\n // Rate limit guesses (same limiter as the toolbar unlock path)\n if (!this.rateLimiter.attempt()) {\n this.error = `Too many attempts. Try again in ${this.rateLimiter.getRemainingSeconds()}s`;\n return;\n }\n\n try {\n // Verifies the password and stores the secret that later page loads\n // re-check (SRD 013 SEC-01)\n if (!(await startInstructorSession(password))) {\n this.rateLimiter.recordFailure();\n this.error = 'Incorrect password';\n return;\n }\n this.rateLimiter.reset();\n\n const release = readRelease();\n\n const sessionService = new SessionService();\n sessionService.createSession('INSTRUCTOR', 'Instructor', release || '');\n\n this.dispatchEvent(\n new CustomEvent('qd:login', {\n detail: {\n serviceId: 'INSTRUCTOR',\n name: 'Instructor',\n release: release || '',\n role: 'instructor',\n },\n bubbles: true,\n composed: true,\n }),\n );\n\n this.showModal = false;\n this.error = '';\n } catch (err) {\n this.error = 'Login failed. Please try again.';\n console.error('Instructor login error:', err);\n }\n }\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n 'qd-instructor-login': QdInstructorLogin;\n }\n}\n","/**\n * Lockout Banner Component\n *\n * Self-contained countdown banner shown while a student account is locked out\n * after too many failed PIN attempts. Owns its own 1s timer and emits\n * `qd:lockout-expired` when the countdown reaches zero. Extracted from\n * `qd-login`.\n *\n * @element qd-lockout-banner\n * @fires {CustomEvent} qd:lockout-expired - When the lockout window ends\n */\n\nimport { LitElement, html, css } from 'lit';\nimport { customElement, state, property } from 'lit/decorators.js';\n\n/**\n * Countdown banner for PIN lockout.\n */\n@customElement('qd-lockout-banner')\nexport class QdLockoutBanner extends LitElement {\n /** Epoch milliseconds when the lockout ends (0 = not locked). */\n @property({ type: Number })\n untilMs = 0;\n\n @state()\n private seconds = 0;\n\n private interval: number | null = null;\n\n static styles = css`\n .lockout-message {\n width: 100%;\n color: #f57c00;\n font-size: 11px;\n margin-top: 3px;\n padding: 4px 8px;\n background: #fff3e0;\n border-radius: 3px;\n border-left: 3px solid #f57c00;\n }\n `;\n\n disconnectedCallback(): void {\n super.disconnectedCallback();\n this.clearTimer();\n }\n\n willUpdate(changed: Map): void {\n if (changed.has('untilMs')) {\n // Reflect the remaining seconds in the same render the property changes.\n const remainingMs = this.untilMs - Date.now();\n this.seconds = remainingMs > 0 ? Math.ceil(remainingMs / 1000) : 0;\n }\n }\n\n updated(changed: Map): void {\n if (changed.has('untilMs')) {\n this.startTimer();\n }\n }\n\n render() {\n if (this.seconds <= 0) {\n return html``;\n }\n return html`
                                        \n Too many attempts. Try again in ${this.seconds}s\n
                                        `;\n }\n\n /** (Re)start the 1s countdown timer based on the current `seconds`. */\n private startTimer(): void {\n this.clearTimer();\n if (this.seconds <= 0) {\n return;\n }\n\n this.interval = window.setInterval(() => {\n this.seconds--;\n if (this.seconds <= 0) {\n this.clearTimer();\n this.dispatchEvent(\n new CustomEvent('qd:lockout-expired', { bubbles: true, composed: true }),\n );\n }\n }, 1000);\n }\n\n private clearTimer(): void {\n if (this.interval) {\n clearInterval(this.interval);\n this.interval = null;\n }\n }\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n 'qd-lockout-banner': QdLockoutBanner;\n }\n}\n","/**\n * Help Trigger Component\n *\n * A small help icon button (?) that triggers contextual help popups.\n * Emits qd:help-open event when activated via click or keyboard (Enter/Space).\n *\n * @element qd-help-trigger\n * @fires {CustomEvent<{panelType: string}>} qd:help-open - Emitted when help is requested\n *\n * @example\n * ```html\n * \n * ```\n *\n * Feature: 008-user-guidance-popups\n */\n\nimport { LitElement, html, css } from 'lit';\nimport { customElement, property } from 'lit/decorators.js';\n\n/**\n * Help trigger button component\n */\n@customElement('qd-help-trigger')\nexport class QdHelpTrigger extends LitElement {\n static styles = css`\n :host {\n display: inline-block;\n }\n\n .help-icon {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 20px;\n height: 20px;\n border-radius: 50%;\n background: #0066cc;\n color: white;\n font-size: 12px;\n font-weight: bold;\n font-family:\n system-ui,\n -apple-system,\n sans-serif;\n cursor: pointer;\n border: none;\n padding: 0;\n transition: background 0.15s ease;\n }\n\n .help-icon:hover {\n background: #0052a3;\n }\n\n .help-icon:focus {\n outline: 2px solid #0066cc;\n outline-offset: 2px;\n }\n\n .help-icon:active {\n background: #004080;\n }\n `;\n\n /**\n * Which panel this trigger belongs to\n */\n @property({ type: String })\n panelType: 'login' | 'status' | 'instructor' = 'login';\n\n /**\n * Handle click/activation\n */\n private handleClick = () => {\n this.dispatchEvent(\n new CustomEvent('qd:help-open', {\n detail: { panelType: this.panelType },\n bubbles: true,\n composed: true,\n }),\n );\n };\n\n render() {\n return html`\n \n `;\n }\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n 'qd-help-trigger': QdHelpTrigger;\n }\n}\n","/**\n * Help Popup Component\n *\n * A modal popup that displays contextual help content.\n * Wraps qd-modal to provide help-specific styling and behavior.\n *\n * @element qd-help-popup\n * @fires {CustomEvent} qd:modal-close - Emitted when popup closes\n *\n * @example\n * ```html\n * this.helpOpen = false}\n * >\n * ```\n *\n * Feature: 008-user-guidance-popups\n */\n\nimport { LitElement, nothing } from 'lit';\nimport { customElement, property, state } from 'lit/decorators.js';\n\n// Help popup styles for portal rendering\nconst HELP_POPUP_STYLES = `\n.qd-help-backdrop{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:99999;font-family:system-ui,-apple-system,sans-serif}\n.qd-help-content{background:#fff;border-radius:8px;box-shadow:0 4px 20px rgba(0,0,0,.3);max-width:450px;max-height:80vh;overflow:auto}\n.qd-help-header{display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid #eee}\n.qd-help-title{font-weight:600;font-size:18px;color:#333;margin:0}\n.qd-help-close{background:none;border:none;font-size:24px;color:#666;cursor:pointer;padding:0;line-height:1;width:28px;height:28px;display:flex;align-items:center;justify-content:center;border-radius:4px}\n.qd-help-close:hover{background:#f0f0f0;color:#333}\n.qd-help-close:focus{outline:2px solid #0066cc;outline-offset:2px}\n.qd-help-body{padding:20px;line-height:1.6;color:#444}\n.qd-help-body h3{margin-top:0;margin-bottom:12px;color:#333;font-size:16px}\n.qd-help-body p{margin:0 0 12px 0}\n.qd-help-body p:last-child{margin-bottom:0}\n.qd-help-body strong{color:#333}`;\n\n/**\n * Help popup modal component\n */\n@customElement('qd-help-popup')\nexport class QdHelpPopup extends LitElement {\n /**\n * Style element for help popup CSS (injected once)\n */\n private static styleElement: HTMLStyleElement | null = null;\n\n /**\n * Portal element appended to body\n */\n private portalElement: HTMLDivElement | null = null;\n\n /**\n * Previously focused element for restoration\n */\n private previouslyFocused: Element | null = null;\n\n /**\n * Whether the popup is open\n */\n @property({ type: Boolean, reflect: true })\n open = false;\n\n /**\n * Popup title\n */\n @property({ type: String })\n title = 'Help';\n\n /**\n * HTML content to display (from readHelpContent)\n */\n @property({ type: String })\n content = '';\n\n /**\n * Track internal open state for portal management\n */\n @state()\n private _isOpen = false;\n\n connectedCallback() {\n super.connectedCallback();\n document.addEventListener('keydown', this.handleKeyDown);\n this.ensureStyles();\n }\n\n disconnectedCallback() {\n super.disconnectedCallback();\n document.removeEventListener('keydown', this.handleKeyDown);\n this.removePortal();\n }\n\n updated(changedProperties: Map) {\n if (changedProperties.has('open')) {\n if (this.open && !this._isOpen) {\n this.handleOpen();\n } else if (!this.open && this._isOpen) {\n this.handleClose();\n }\n }\n }\n\n /**\n * Ensure help popup styles are added to document head (once)\n */\n private ensureStyles() {\n if (!QdHelpPopup.styleElement) {\n QdHelpPopup.styleElement = document.createElement('style');\n QdHelpPopup.styleElement.textContent = HELP_POPUP_STYLES;\n document.head.appendChild(QdHelpPopup.styleElement);\n }\n }\n\n /**\n * Create and show the portal\n */\n private createPortal() {\n this.removePortal();\n\n // Create backdrop\n this.portalElement = document.createElement('div');\n this.portalElement.className = 'qd-help-backdrop';\n this.portalElement.addEventListener('click', this.handleBackdropClick);\n\n // Create content container\n const contentEl = document.createElement('div');\n contentEl.className = 'qd-help-content';\n contentEl.setAttribute('role', 'dialog');\n contentEl.setAttribute('aria-modal', 'true');\n contentEl.setAttribute('aria-labelledby', 'qd-help-title');\n contentEl.addEventListener('click', this.stopPropagation);\n\n // Create header\n const headerEl = document.createElement('div');\n headerEl.className = 'qd-help-header';\n\n const titleEl = document.createElement('h2');\n titleEl.className = 'qd-help-title';\n titleEl.id = 'qd-help-title';\n titleEl.textContent = this.title;\n\n const closeBtn = document.createElement('button');\n closeBtn.className = 'qd-help-close';\n closeBtn.setAttribute('aria-label', 'Close');\n closeBtn.innerHTML = '×';\n closeBtn.addEventListener('click', this.handleCloseClick);\n\n headerEl.appendChild(titleEl);\n headerEl.appendChild(closeBtn);\n\n // Create body\n const bodyEl = document.createElement('div');\n bodyEl.className = 'qd-help-body';\n bodyEl.innerHTML = this.content;\n\n contentEl.appendChild(headerEl);\n contentEl.appendChild(bodyEl);\n this.portalElement.appendChild(contentEl);\n document.body.appendChild(this.portalElement);\n\n // Focus close button\n requestAnimationFrame(() => {\n closeBtn.focus();\n });\n }\n\n /**\n * Remove portal from DOM\n */\n private removePortal() {\n if (this.portalElement) {\n this.portalElement.remove();\n this.portalElement = null;\n }\n }\n\n /**\n * Handle opening\n */\n private handleOpen() {\n this._isOpen = true;\n this.previouslyFocused = document.activeElement;\n this.createPortal();\n }\n\n /**\n * Handle closing\n */\n private handleClose() {\n this._isOpen = false;\n this.removePortal();\n\n // Restore focus\n if (this.previouslyFocused instanceof HTMLElement) {\n this.previouslyFocused.focus();\n }\n }\n\n /**\n * Handle keyboard events\n */\n private handleKeyDown = (event: KeyboardEvent) => {\n if (event.key === 'Escape' && this._isOpen) {\n this.close();\n }\n };\n\n /**\n * Handle backdrop click\n */\n private handleBackdropClick = () => {\n this.close();\n };\n\n /**\n * Handle close button click\n */\n private handleCloseClick = () => {\n this.close();\n };\n\n /**\n * Stop propagation for content clicks\n */\n private stopPropagation = (event: Event) => {\n event.stopPropagation();\n };\n\n /**\n * Close the popup and emit event\n */\n close() {\n this.open = false;\n this.dispatchEvent(\n new CustomEvent('qd:modal-close', {\n bubbles: true,\n composed: true,\n }),\n );\n }\n\n render() {\n // Portal renders to body, component renders nothing\n return nothing;\n }\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n 'qd-help-popup': QdHelpPopup;\n }\n}\n","/**\n * Obfuscation Migration Utility\n *\n * Provides functions to migrate IndexedDB data between plain and obfuscated formats.\n * Call from browser console or test setup.\n */\n\nimport type { StudentRecord } from '../../types/contracts.js';\nimport { encode, decode, deriveKey, isObfuscated } from './obfuscation.js';\nimport { info as logInfo, warn as logWarn, error as logError } from '../../utils/logger.js';\n\n/** Migration direction */\nexport type ObfuscationMigrationDirection = 'encrypt' | 'decrypt';\n\n/** Migration options */\nexport interface ObfuscationMigrationOptions {\n /** Release ID for key derivation */\n releaseId: string;\n /** If true, report what would change without modifying data */\n dryRun?: boolean;\n}\n\n/** Migration result */\nexport interface ObfuscationMigrationResult {\n /** Number of records migrated */\n migrated: number;\n /** Number of records skipped (already in target format) */\n skipped: number;\n /** Errors encountered during migration */\n errors: Array<{ key: string; error: string }>;\n /** Total time in milliseconds */\n durationMs: number;\n}\n\n/** Object store name for students */\nconst STORE_STUDENTS = 'students';\n\n/**\n * Migrate storage between plain and obfuscated formats\n *\n * @param dbName - IndexedDB database name\n * @param direction - 'encrypt' to obfuscate, 'decrypt' to restore plain\n * @param options - Migration options including releaseId and optional dryRun\n * @returns Migration result with counts and any errors\n */\nexport async function migrateObfuscation(\n dbName: string,\n direction: ObfuscationMigrationDirection,\n options: ObfuscationMigrationOptions,\n): Promise {\n const startTime = performance.now();\n const result: ObfuscationMigrationResult = {\n migrated: 0,\n skipped: 0,\n errors: [],\n durationMs: 0,\n };\n\n const { releaseId, dryRun = false } = options;\n const obfKey = deriveKey(releaseId);\n\n logInfo(`Starting obfuscation migration: direction=${direction}, dryRun=${dryRun}`);\n\n // Open database\n const db = await openDatabase(dbName);\n\n try {\n // Get all records\n const allRecords = await getAllRawRecords(db);\n\n for (const { key, value } of allRecords) {\n try {\n const currentlyObfuscated = isObfuscated(value);\n\n if (direction === 'encrypt') {\n if (currentlyObfuscated) {\n result.skipped++;\n continue;\n }\n\n // Convert plain to obfuscated\n const plainRecord = value as StudentRecord;\n const obfuscatedValue = encode(plainRecord, obfKey);\n\n if (!dryRun) {\n await putRawRecord(db, key, obfuscatedValue);\n }\n result.migrated++;\n } else {\n // decrypt\n if (!currentlyObfuscated) {\n result.skipped++;\n continue;\n }\n\n // Convert obfuscated to plain\n const plainRecord = decode(value, obfKey);\n\n if (!dryRun) {\n await putRawRecord(db, key, plainRecord);\n }\n result.migrated++;\n }\n } catch (err) {\n const errorMessage = err instanceof Error ? err.message : String(err);\n result.errors.push({ key, error: errorMessage });\n logWarn(`Migration error for key ${key}: ${errorMessage}`);\n }\n }\n } finally {\n db.close();\n }\n\n result.durationMs = performance.now() - startTime;\n logInfo(\n `Migration complete: migrated=${result.migrated}, skipped=${result.skipped}, errors=${result.errors.length}, duration=${result.durationMs.toFixed(2)}ms`,\n );\n\n return result;\n}\n\n/**\n * Open IndexedDB database\n */\nasync function openDatabase(dbName: string): Promise {\n return new Promise((resolve, reject) => {\n const request = indexedDB.open(dbName);\n\n request.onsuccess = () => resolve(request.result);\n request.onerror = () => {\n logError(`Failed to open database: ${request.error?.message}`);\n reject(new Error(`Failed to open database: ${request.error?.message}`));\n };\n });\n}\n\n/**\n * Get all raw records from the students store\n */\nasync function getAllRawRecords(db: IDBDatabase): Promise> {\n return new Promise((resolve, reject) => {\n const transaction = db.transaction(STORE_STUDENTS, 'readonly');\n const store = transaction.objectStore(STORE_STUDENTS);\n const request = store.openCursor();\n const records: Array<{ key: string; value: unknown }> = [];\n\n request.onsuccess = () => {\n const cursor = request.result;\n if (cursor) {\n // cursor.key is IDBValidKey - could be string, number, Date, array, etc.\n // We use out-of-line keys which are always strings in this app\n const keyStr = typeof cursor.key === 'string' ? cursor.key : JSON.stringify(cursor.key);\n records.push({ key: keyStr, value: cursor.value });\n cursor.continue();\n } else {\n resolve(records);\n }\n };\n\n request.onerror = () => {\n reject(new Error(`Failed to read records: ${request.error?.message}`));\n };\n });\n}\n\n/**\n * Put a raw record into the students store\n */\nasync function putRawRecord(db: IDBDatabase, key: string, value: unknown): Promise {\n return new Promise((resolve, reject) => {\n const transaction = db.transaction(STORE_STUDENTS, 'readwrite');\n const store = transaction.objectStore(STORE_STUDENTS);\n const request = store.put(value, key);\n\n request.onsuccess = () => resolve();\n request.onerror = () => {\n reject(new Error(`Failed to save record: ${request.error?.message}`));\n };\n });\n}\n","/**\n * Styles for the component.\n *\n * Extracted from qd-migration-dialog.ts to keep the component focused on\n * behavior. Combined with the shared spinnerStyles at the component.\n */\n\nimport { css } from 'lit';\n\nexport const migrationDialogStyles = css`\n :host {\n display: contents;\n }\n\n .migration-content {\n display: flex;\n flex-direction: column;\n gap: 16px;\n padding: 8px 0;\n }\n\n .warning-banner {\n display: flex;\n align-items: flex-start;\n gap: 12px;\n padding: 12px;\n background: #fff3cd;\n border-radius: 4px;\n border-left: 4px solid #ffc107;\n }\n\n .warning-icon {\n font-size: 20px;\n line-height: 1;\n }\n\n .warning-text {\n flex: 1;\n }\n\n .warning-text strong {\n display: block;\n margin-bottom: 4px;\n color: #856404;\n }\n\n .format-info {\n font-size: 13px;\n color: #666;\n }\n\n .format-row {\n display: flex;\n gap: 8px;\n margin: 4px 0;\n }\n\n .format-label {\n font-weight: 500;\n min-width: 100px;\n }\n\n .format-value {\n font-family: monospace;\n background: #f5f5f5;\n padding: 2px 6px;\n border-radius: 3px;\n }\n\n .form-field {\n display: flex;\n flex-direction: column;\n gap: 4px;\n }\n\n label {\n font-size: 13px;\n font-weight: 500;\n color: #333;\n }\n\n input[type='password'] {\n padding: 8px 12px;\n border: 1px solid #ccc;\n border-radius: 4px;\n font-size: 14px;\n width: 100%;\n box-sizing: border-box;\n }\n\n input[type='password']:focus {\n outline: none;\n border-color: #0066cc;\n box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.1);\n }\n\n .error-message {\n color: #d32f2f;\n font-size: 12px;\n padding: 8px;\n background: #ffebee;\n border-radius: 4px;\n border-left: 3px solid #d32f2f;\n }\n\n .success-message {\n color: #2e7d32;\n font-size: 13px;\n padding: 12px;\n background: #e8f5e9;\n border-radius: 4px;\n border-left: 3px solid #4caf50;\n }\n\n .migrating-state {\n text-align: center;\n padding: 20px;\n }\n\n .button-row {\n display: flex;\n gap: 8px;\n justify-content: flex-end;\n margin-top: 8px;\n }\n\n button {\n padding: 8px 16px;\n border: none;\n border-radius: 4px;\n font-size: 13px;\n font-weight: 500;\n cursor: pointer;\n transition: background-color 0.2s;\n }\n\n button:disabled {\n opacity: 0.6;\n cursor: not-allowed;\n }\n\n button.primary {\n background: #0066cc;\n color: white;\n }\n\n button.primary:hover:not(:disabled) {\n background: #0052a3;\n }\n\n button.secondary {\n background: #e0e0e0;\n color: #333;\n }\n\n button.secondary:hover:not(:disabled) {\n background: #d0d0d0;\n }\n`;\n","/**\n * Shared CSS-in-JS style fragments for top-level Lit components.\n *\n * These are reusable `css` fragments that can be composed into a component's\n * `static styles` array. Only genuinely identical, single-source declarations\n * live here so that adopting a fragment never changes a component's rendering.\n *\n * Note: instructor sub-components share a separate, instructor-specific bundle\n * in `qd-instructor/shared-styles.ts`.\n */\n\nimport { css } from 'lit';\n\n/**\n * Loading spinner plus its keyframes. Adopted by components that show an\n * in-progress indicator (e.g. the migration dialog) and, going forward, the\n * reusable `` component.\n */\nexport const spinnerStyles = css`\n .spinner {\n display: inline-block;\n width: 24px;\n height: 24px;\n border: 3px solid #e0e0e0;\n border-top-color: #0066cc;\n border-radius: 50%;\n animation: spin 1s linear infinite;\n margin-bottom: 12px;\n }\n\n @keyframes spin {\n to {\n transform: rotate(360deg);\n }\n }\n`;\n","/**\n * Spinner Component\n *\n * Reusable loading indicator with encapsulated styles (Shadow DOM). Shares the\n * spinner CSS fragment from `shared-styles.ts` so its appearance matches the\n * inline spinners it replaces.\n *\n * @element qd-spinner\n */\n\nimport { LitElement, html } from 'lit';\nimport { customElement } from 'lit/decorators.js';\nimport { spinnerStyles } from './shared-styles.js';\n\n/**\n * Standalone loading spinner.\n */\n@customElement('qd-spinner')\nexport class QdSpinner extends LitElement {\n static override styles = [spinnerStyles];\n\n override render() {\n return html`
                                        `;\n }\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n 'qd-spinner': QdSpinner;\n }\n}\n","/**\n * Migration dialog component\n *\n * Shows when storage format mismatch is detected during login.\n * Allows instructor to migrate database to match current build mode.\n *\n * Feature: 009-encrypt-stored-data\n *\n * @element qd-migration-dialog\n * @fires {CustomEvent<{migrated: number, skipped: number}>} qd:migration-complete - Emitted on successful migration\n * @fires {CustomEvent} qd:migration-cancel - Emitted when user cancels\n */\n\nimport { LitElement, html, nothing } from 'lit';\nimport { customElement, property, state, query } from 'lit/decorators.js';\nimport { migrateObfuscation } from '../services/storage/obfuscation-migration.js';\nimport { ENCRYPT_STORAGE } from '../config/feature-flags.js';\nimport {\n getExpectedInstructorHash,\n verifyInstructorPassword,\n} from '../services/auth/instructor-auth.js';\nimport { migrationDialogStyles } from './qd-migration-dialog.styles.js';\nimport './qd-modal.js';\nimport './qd-spinner.js';\n\n/** Migration dialog state */\ntype MigrationState = 'password' | 'migrating' | 'error' | 'success';\n\n@customElement('qd-migration-dialog')\nexport class QdMigrationDialog extends LitElement {\n static override styles = [migrationDialogStyles];\n\n /**\n * Whether dialog is open\n */\n @property({ type: Boolean, reflect: true })\n open = false;\n\n /**\n * What format was expected based on ENCRYPT_STORAGE\n */\n @property({ type: String })\n expected: 'obfuscated' | 'plain' = 'plain';\n\n /**\n * What format was actually found\n */\n @property({ type: String })\n found: 'obfuscated' | 'plain' = 'plain';\n\n /**\n * Database name for migration\n */\n @property({ type: String })\n dbName = '';\n\n /**\n * Release ID for key derivation\n */\n @property({ type: String })\n releaseId = '';\n\n /**\n * Internal state\n */\n @state()\n private dialogState: MigrationState = 'password';\n\n /**\n * Password input value\n */\n @state()\n private password = '';\n\n /**\n * Error message\n */\n @state()\n private error = '';\n\n /**\n * Migration result\n */\n @state()\n private migrationResult: { migrated: number; skipped: number } | null = null;\n\n /**\n * Reference to password input\n */\n @query('input[type=\"password\"]')\n private passwordInput!: HTMLInputElement;\n\n /**\n * Reset state when dialog opens\n */\n override updated(changedProps: Map): void {\n if (changedProps.has('open') && this.open) {\n this.dialogState = 'password';\n this.password = '';\n this.error = '';\n this.migrationResult = null;\n void this.updateComplete.then(() => {\n this.passwordInput?.focus();\n });\n }\n }\n\n /**\n * Handle modal close\n */\n private handleModalClose = (): void => {\n this.dispatchEvent(\n new CustomEvent('qd:migration-cancel', {\n bubbles: true,\n composed: true,\n }),\n );\n };\n\n /**\n * Handle password input\n */\n private handleInput = (e: Event): void => {\n const input = e.target as HTMLInputElement;\n this.password = input.value;\n if (this.error) {\n this.error = '';\n }\n };\n\n /**\n * Handle form submission\n */\n private handleSubmit = async (e: Event): Promise => {\n e.preventDefault();\n\n if (!this.password.trim()) {\n return;\n }\n\n // Validate instructor password\n const isValid = await this.validatePassword(this.password);\n if (!isValid) {\n // Only set generic error if validatePassword didn't set a specific one\n if (!this.error) {\n this.error = 'Incorrect instructor password';\n }\n return;\n }\n\n // Start migration\n await this.runMigration();\n };\n\n /**\n * Validate instructor password against configured hash\n */\n private async validatePassword(password: string): Promise {\n if (!getExpectedInstructorHash()) {\n this.error = 'Instructor password not configured';\n return false;\n }\n return verifyInstructorPassword(password);\n }\n\n /**\n * Run the migration\n */\n private async runMigration(): Promise {\n this.dialogState = 'migrating';\n this.error = '';\n\n try {\n // Determine direction based on ENCRYPT_STORAGE flag\n const direction = ENCRYPT_STORAGE ? 'encrypt' : 'decrypt';\n\n const result = await migrateObfuscation(this.dbName, direction, {\n releaseId: this.releaseId,\n dryRun: false,\n });\n\n if (result.errors.length > 0) {\n this.dialogState = 'error';\n this.error = `Migration completed with ${result.errors.length} error(s). Some records may not have been migrated.`;\n return;\n }\n\n this.migrationResult = {\n migrated: result.migrated,\n skipped: result.skipped,\n };\n this.dialogState = 'success';\n } catch (err) {\n this.dialogState = 'error';\n this.error = `Migration failed: ${err instanceof Error ? err.message : 'Unknown error'}`;\n }\n }\n\n /**\n * Handle continue after success\n */\n private handleContinue = (): void => {\n this.dispatchEvent(\n new CustomEvent('qd:migration-complete', {\n detail: this.migrationResult,\n bubbles: true,\n composed: true,\n }),\n );\n };\n\n /**\n * Handle cancel button\n */\n private handleCancel = (): void => {\n this.dispatchEvent(\n new CustomEvent('qd:migration-cancel', {\n bubbles: true,\n composed: true,\n }),\n );\n };\n\n override render() {\n return html`\n \n Database Migration Required\n\n ${this.open ? this.renderContent() : nothing}\n \n `;\n }\n\n private renderContent() {\n switch (this.dialogState) {\n case 'password':\n return this.renderPasswordForm();\n case 'migrating':\n return this.renderMigrating();\n case 'error':\n return this.renderError();\n case 'success':\n return this.renderSuccess();\n }\n }\n\n private renderPasswordForm() {\n return html`\n
                                        \n
                                        \n ⚠\n
                                        \n Storage format mismatch detected\n
                                        \n
                                        \n Current data:\n ${this.found}\n
                                        \n
                                        \n Build expects:\n ${this.expected}\n
                                        \n
                                        \n
                                        \n
                                        \n\n

                                        Enter the instructor password to migrate all stored records to the new format.

                                        \n\n
                                        \n
                                        \n \n \n
                                        \n\n ${this.error ? html`
                                        ${this.error}
                                        ` : nothing}\n\n
                                        \n \n \n
                                        \n
                                        \n
                                        \n `;\n }\n\n private renderMigrating() {\n return html`\n
                                        \n
                                        \n \n

                                        Migrating database records...

                                        \n

                                        Please wait, do not close this window.

                                        \n
                                        \n
                                        \n `;\n }\n\n private renderError() {\n return html`\n
                                        \n
                                        ${this.error}
                                        \n\n
                                        \n \n \n
                                        \n
                                        \n `;\n }\n\n private renderSuccess() {\n return html`\n
                                        \n
                                        \n Migration completed successfully!
                                        \n \n ${this.migrationResult?.migrated ?? 0} record(s) migrated,\n ${this.migrationResult?.skipped ?? 0} already in correct format.\n \n
                                        \n\n
                                        \n \n
                                        \n
                                        \n `;\n }\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n 'qd-migration-dialog': QdMigrationDialog;\n }\n}\n","/**\n * Help Content Configuration\n *\n * Centralized help text for all panels. Edit this file to update help content.\n * Feature: 008-user-guidance-popups\n */\n\nexport type HelpPanelType = 'login' | 'status' | 'instructor';\n\nexport interface HelpContent {\n title: string;\n body: string;\n}\n\n/**\n * Help content for each panel type\n */\nexport const HELP_CONTENT: Record = {\n login: {\n title: 'Login Help',\n body: '

                                        Enter Name and Service ID to log in. Provide a new 4-digit PIN 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 Feedback page for more support.

                                        Instructors: click \"Instructor\" for instructor login page (password accompanies distribution).

                                        ',\n },\n\n status: {\n title: 'Student View',\n // Each colour has one meaning, matching the state calculator (SRD 013 UX-09)\n body: '

                                        Page badges in the contents:

                                        • Green: every question on the page answered correctly
                                        • Amber: started, with some questions unanswered or incorrect
                                        • Red: not started

                                        On a quiz page, each answer shows ✓ Correct or ✗ Incorrect. You can change an answer at any time.

                                        The Test progress panel counts the questions you have answered and how many are correct.

                                        ',\n },\n\n instructor: {\n title: 'Instructor Tools',\n body: '

                                        • Show current answers: Toggle for display of student answers for the current page.
                                        • View All Scores: View table scores for all students.
                                        • Reset PIN: Reset student PINs.
                                        • Export CSV: CSV download of all scores/answers.
                                        • Erase All Data: Clear all stored student data.

                                        ',\n },\n};\n\n/**\n * Get help content for a panel type\n */\nexport function getHelpContent(panelType: HelpPanelType): HelpContent {\n return HELP_CONTENT[panelType];\n}\n","/**\n * Login Component\n *\n * Presentational student-login view: renders the form and maps\n * {@link AuthService} results to UI state. The instructor flow and lockout\n * countdown live in the `` and ``\n * child components. Release is read from the document title.\n *\n * @element qd-login\n * @fires {CustomEvent<{serviceId,name,release,role}>} qd:login - On successful auth\n */\n\nimport { LitElement, html } from 'lit';\nimport { customElement, state, property } from 'lit/decorators.js';\nimport { loginStyles } from './qd-login.styles.js';\nimport { hasActiveSession, isInstructor } from '../utils/session-state.js';\nimport { STORAGE_KEYS, SESSION_TIMEOUT_MS } from '../types/contracts.js';\nimport {\n validateStudentForm,\n validateStudentFields,\n sanitizePinInput,\n type StudentFieldErrors,\n} from '../utils/validation-helpers.js';\nimport { SessionService } from '../services/session.js';\nimport { readRelease, readDbName } from '../config/dom-config-reader.js';\nimport { AuthService } from '../services/auth/auth-service.js';\nimport type { LoginResult } from '../services/auth/auth-service.js';\nimport './qd-build-info.js';\nimport './qd-instructor-login.js';\nimport './qd-lockout-banner.js';\nimport './qd-help-trigger.js';\nimport './qd-help-popup.js';\nimport './qd-migration-dialog.js';\nimport { getHelpContent } from '../config/help-content.js';\nimport { StorageFormatError } from '../services/storage/adapter-utils.js';\n\n/** Login event data */\ninterface LoginData {\n serviceId: string;\n name: string;\n release: string;\n role: 'student' | 'instructor';\n}\n\n/** Login component for student and instructor authentication */\n@customElement('qd-login')\nexport class QdLogin extends LitElement {\n /** Title text (configurable via init()) */\n @property({ type: String })\n title = 'Sonar Quiz System';\n\n /** Form field: Student name */\n @state() private name = '';\n /** Form field: Service ID (2-10 alphanumeric) */\n @state() private serviceId = '';\n /** Error message to display */\n @state() private errorMessage = '';\n /** Whether the form is currently submitting */\n @state() private isSubmitting = false;\n /** PIN input */\n @state() private pin = '';\n /** Epoch milliseconds when the current lockout ends (0 = not locked) */\n @state() private lockoutUntil = 0;\n /**\n * Fields the student has typed in or left, so validation only reports on\n * fields they have touched (SRD 013 UX-10). Submitting touches all three.\n */\n @state() private touched: ReadonlySet<'name' | 'serviceId' | 'pin'> = new Set();\n /**\n * Whether the entered service ID already has an account.\n * `null` means not looked up yet, or the lookup failed.\n */\n @state() private isRegistered: boolean | null = null;\n /** First PIN entry, held while the user re-types it to confirm (create flow) */\n @state() private pendingPin = '';\n /** Whether the help popup is open */\n @state() private helpOpen = false;\n /** Why the student was logged out, shown above the form (SRD 013 SEC-10) */\n @state() private sessionNotice = '';\n /** Whether the migration dialog is shown */\n @state() private showMigrationDialog = false;\n /** Storage format error that triggered the migration dialog */\n @state() private migrationError: StorageFormatError | null = null;\n /** Pending login data to retry after migration */\n @state() private pendingLoginData: {\n serviceId: string;\n name: string;\n release: string;\n pin: string;\n dbName: string;\n } | null = null;\n\n /** Student authentication service (storage/crypto/rate-limit logic) */\n private authService = new AuthService();\n\n /** Pending debounce for the \"is this service ID known?\" lookup */\n private registrationTimer?: number;\n\n static styles = loginStyles;\n\n connectedCallback() {\n super.connectedCallback();\n this.updateVisibility();\n this.takeSessionExpiredNotice();\n document.addEventListener('qd:logout', this.handleLogoutEvent);\n // Instructor login is emitted by the child ; hide on it.\n document.addEventListener('qd:login', this.handleLoginEvent);\n }\n\n disconnectedCallback() {\n super.disconnectedCallback();\n window.clearTimeout(this.registrationTimer);\n document.removeEventListener('qd:logout', this.handleLogoutEvent);\n document.removeEventListener('qd:login', this.handleLoginEvent);\n }\n\n /** Hide the form whenever a login succeeds (student or instructor). */\n private handleLoginEvent = (): void => {\n this.sessionNotice = '';\n this.updateVisibility();\n };\n\n /**\n * Show, once, why the last session ended if it timed out. The marker is\n * written by the session coordinator before it clears the session.\n */\n private takeSessionExpiredNotice(): void {\n if (sessionStorage.getItem(STORAGE_KEYS.SESSION_EXPIRED) === null) {\n return;\n }\n sessionStorage.removeItem(STORAGE_KEYS.SESSION_EXPIRED);\n const minutes = Math.round(SESSION_TIMEOUT_MS / 60000);\n this.sessionNotice = `Your session ended after ${minutes} minutes without activity. Log in again to continue.`;\n }\n\n /** Lifecycle: signal readiness once the shadow DOM is rendered. */\n firstUpdated() {\n this.setAttribute('data-ready', '');\n }\n\n /**\n * Show the form only when nobody is logged in. An instructor session hides\n * it too, so the form and the instructor panel never render together\n * (SRD 013 SEC-05).\n */\n private updateVisibility(): void {\n this.toggleAttribute('data-show', !hasActiveSession() && !isInstructor());\n }\n\n /** On logout, reset state and show the login form again. */\n private handleLogoutEvent = (): void => {\n this.name = '';\n this.serviceId = '';\n this.errorMessage = '';\n this.isSubmitting = false;\n this.pin = '';\n this.lockoutUntil = 0;\n this.touched = new Set();\n this.helpOpen = false;\n this.isRegistered = null;\n this.pendingPin = '';\n this.takeSessionExpiredNotice();\n this.updateVisibility();\n };\n\n /** Whether the account is currently within its lockout window. */\n private isLockedOut(): boolean {\n return this.lockoutUntil > Date.now();\n }\n\n /** Clear the lockout once the banner's countdown ends. */\n private handleLockoutExpired = (): void => {\n this.lockoutUntil = 0;\n };\n\n render() {\n return html`\n
                                        \n
                                        \n ${this.title}\n \n \n
                                        \n\n ${this.sessionNotice\n ? html`
                                        ${this.sessionNotice}
                                        `\n : ''}\n
                                        this.handleStudentLogin(e)}>\n this.handleNameInput(e)}\n @blur=${() => this.touch('name')}\n ?disabled=${this.isSubmitting}\n required\n />\n this.handleServiceIdInput(e)}\n @blur=${() => this.touch('serviceId')}\n ?disabled=${this.isSubmitting}\n pattern=\"[A-Za-z0-9]{2,10}\"\n title=\"2-10 alphanumeric characters\"\n required\n />\n this.handlePinInput(e)}\n @blur=${() => this.touch('pin')}\n ?disabled=${this.isSubmitting || this.isLockedOut()}\n required\n />\n \n ${this.submitLabel}\n \n \n
                                        \n ${this.errorMessage\n ? html`
                                        ${this.errorMessage}
                                        `\n : this.validationHint\n ? html`
                                        ${this.validationHint}
                                        `\n : ''}\n
                                        \n \n \n
                                        \n \n
                                        \n `;\n }\n\n private handleHelpOpen = (): void => {\n this.helpOpen = true;\n };\n private handleHelpClose = (): void => {\n this.helpOpen = false;\n };\n\n private handleNameInput(e: Event) {\n this.name = (e.target as HTMLInputElement).value;\n this.errorMessage = '';\n this.touch('name');\n }\n private handleServiceIdInput(e: Event) {\n this.serviceId = (e.target as HTMLInputElement).value;\n this.errorMessage = '';\n this.touch('serviceId');\n // The identity changed, so a PIN captured for the previous one is void\n this.pendingPin = '';\n this.scheduleRegistrationCheck();\n }\n\n /**\n * Debounced lookup of whether the entered service ID already has an account,\n * used to label the submit button \"Login\" or \"Create\".\n */\n private scheduleRegistrationCheck(): void {\n window.clearTimeout(this.registrationTimer);\n this.isRegistered = null;\n\n const serviceId = this.serviceId.trim();\n if (!/^[a-zA-Z0-9]{2,10}$/.test(serviceId)) {\n return;\n }\n\n this.registrationTimer = window.setTimeout(() => {\n void this.checkRegistration(serviceId);\n }, 300);\n }\n\n /** Resolve {@link isRegistered} for one service ID, ignoring stale answers. */\n private async checkRegistration(serviceId: string): Promise {\n let known: boolean | null = null;\n try {\n const release = readRelease();\n known = release\n ? await this.authService.isRegistered(serviceId, release, readDbName())\n : null;\n } catch {\n known = null;\n }\n // Discard a late answer for a service ID the user has since edited\n if (serviceId === this.serviceId.trim()) {\n this.isRegistered = known;\n }\n }\n\n /**\n * Label for the submit button.\n *\n * A first-time user sees \"Create\" rather than a \"Login\" that cannot succeed,\n * and \"Confirm\" while re-entering their new PIN. Unknown registration falls\n * back to \"Login\".\n */\n private get submitLabel(): string {\n if (this.pendingPin) {\n return 'Confirm';\n }\n return this.isRegistered === false ? 'Create' : 'Login';\n }\n private handlePinInput(e: Event) {\n // Filter to digits only using validation helper\n this.pin = sanitizePinInput((e.target as HTMLInputElement).value);\n this.errorMessage = '';\n this.touch('pin');\n }\n\n /** Mark a field as touched so its validation message may show. */\n private touch(field: 'name' | 'serviceId' | 'pin'): void {\n if (!this.touched.has(field)) {\n this.touched = new Set([...this.touched, field]);\n }\n }\n\n /** Validation messages for touched fields only. */\n private get fieldErrors(): StudentFieldErrors {\n const all = validateStudentFields(this.name, this.serviceId, this.pin);\n const shown: StudentFieldErrors = {};\n for (const field of this.touched) {\n if (all[field]) {\n shown[field] = all[field];\n }\n }\n return shown;\n }\n\n /** Whether the student form passes validation. */\n private isValid(): boolean {\n return validateStudentForm(this.name, this.serviceId, this.pin).length === 0;\n }\n\n /**\n * First outstanding validation problem, for display while the user types.\n *\n * The Login button is disabled until the form is valid, which on its own\n * leaves no way to discover what is missing: the submit handler's error\n * message is unreachable because submitting is exactly what is blocked.\n * This surfaces the same message live instead. A pristine form stays quiet\n * so the user is not scolded before typing anything.\n *\n * @returns The first validation error, or an empty string when the form is\n * untouched or valid\n */\n private get validationHint(): string {\n if (this.pendingPin) {\n return 'Re-enter your PIN to confirm';\n }\n if (!this.name && !this.serviceId && !this.pin) {\n return '';\n }\n // Every outstanding problem at once, not just the first (UX-10), so the\n // disabled Login button is always explained. Only touched fields are\n // also marked invalid, so the student is not scolded field by field.\n return Object.values(validateStudentFields(this.name, this.serviceId, this.pin)).join(' · ');\n }\n\n /**\n * Handle student login: validate, resolve release/db-name, delegate to\n * {@link AuthService}, and apply the result (or open the migration dialog).\n */\n private async handleStudentLogin(e: Event) {\n e.preventDefault();\n\n if (!this.isValid()) {\n this.touched = new Set(['name', 'serviceId', 'pin']);\n this.errorMessage = 'Please enter name, service ID, and 4-digit PIN';\n return;\n }\n\n // The debounced lookup may not have answered yet (fast typist, fast click),\n // so settle it here rather than letting timing decide whether an account\n // is created with or without confirmation.\n if (this.isRegistered === null && !this.pendingPin) {\n await this.checkRegistration(this.serviceId.trim());\n }\n\n // Creating an account takes the PIN twice, so a typo cannot silently\n // become the stored PIN and lock the student out of their own record.\n if (this.pendingPin) {\n if (this.pin !== this.pendingPin) {\n this.pendingPin = '';\n this.pin = '';\n this.errorMessage = 'PINs did not match. Please enter your PIN again.';\n return;\n }\n this.pendingPin = '';\n } else if (this.isRegistered === false) {\n this.pendingPin = this.pin;\n this.pin = '';\n this.errorMessage = '';\n return;\n }\n\n this.isSubmitting = true;\n this.errorMessage = '';\n\n const release = readRelease();\n if (!release) {\n this.errorMessage = 'Release not found (missing publication title element)';\n this.isSubmitting = false;\n return;\n }\n\n const serviceId = this.serviceId.trim();\n const name = this.name.trim();\n const dbName = readDbName();\n const pin = this.pin;\n\n const result = await this.authService.loginStudent({ serviceId, name, pin, release, dbName });\n\n if (result.kind === 'needs-migration') {\n this.migrationError = result.error;\n this.pendingLoginData = { serviceId, name, release, pin, dbName };\n this.showMigrationDialog = true;\n this.isSubmitting = false;\n return;\n }\n\n this.applyLoginResult(result);\n }\n\n /** Map an {@link AuthService} result to session/events/UI state. */\n private applyLoginResult(result: LoginResult): void {\n switch (result.kind) {\n case 'pin-created':\n // No \"PIN stored\" dialog: the student has just confirmed the PIN and\n // the status panel shows they are in (SRD 013 UX-02)\n this.dispatchPinEvent('qd:pin-created', result.serviceId);\n this.completeLogin(result.serviceId, result.name, result.release);\n break;\n case 'pin-verified':\n this.dispatchPinEvent('qd:pin-verified', result.serviceId);\n this.completeLogin(result.serviceId, result.name, result.release);\n break;\n case 'lockout':\n this.lockoutUntil = Date.now() + result.lockoutMs;\n this.errorMessage = '';\n this.isSubmitting = false;\n break;\n case 'bad-pin':\n this.errorMessage = `Incorrect PIN. ${result.remaining} attempt${\n result.remaining !== 1 ? 's' : ''\n } remaining`;\n this.pin = '';\n this.isSubmitting = false;\n break;\n case 'error':\n this.errorMessage = result.message;\n this.isSubmitting = false;\n break;\n }\n }\n\n /** Dispatch a PIN lifecycle event (`qd:pin-created` / `qd:pin-verified`). */\n private dispatchPinEvent(name: 'qd:pin-created' | 'qd:pin-verified', serviceId: string): void {\n this.dispatchEvent(\n new CustomEvent(name, {\n detail: { serviceId, timestamp: new Date().toISOString() },\n bubbles: true,\n composed: true,\n }),\n );\n }\n\n /** On successful migration, retry login with the pending (validated) data. */\n private handleMigrationComplete = (): void => {\n this.showMigrationDialog = false;\n this.migrationError = null;\n\n if (this.pendingLoginData) {\n const { serviceId, name, release } = this.pendingLoginData;\n this.pendingLoginData = null;\n void this.retryLoginAfterMigration(serviceId, name, release);\n }\n };\n\n /** Retry login after migration (skips the lockout pre-check). */\n private async retryLoginAfterMigration(\n serviceId: string,\n name: string,\n release: string,\n ): Promise {\n this.isSubmitting = true;\n this.errorMessage = '';\n\n const result = await this.authService.retryAfterMigration({\n serviceId,\n name,\n pin: this.pin,\n release,\n dbName: readDbName(),\n });\n\n this.applyLoginResult(result);\n }\n\n /** On migration cancel, show a contact-instructor message. */\n private handleMigrationCancel = (): void => {\n this.showMigrationDialog = false;\n this.migrationError = null;\n this.pendingLoginData = null;\n this.errorMessage = 'Data migration cancelled. Please contact your instructor for assistance.';\n this.isSubmitting = false;\n };\n\n /** Create the student session, emit `qd:login`, and hide the form. */\n private completeLogin(serviceId: string, name: string, release: string): void {\n new SessionService().createSession(serviceId, name, release);\n\n const loginData: LoginData = { serviceId, name, release, role: 'student' };\n this.dispatchEvent(\n new CustomEvent('qd:login', { detail: loginData, bubbles: true, composed: true }),\n );\n\n this.pin = '';\n this.isSubmitting = false;\n this.updateVisibility();\n }\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n 'qd-login': QdLogin;\n }\n}\n","/**\n * Status Component\n *\n * Compact single-line display of student quiz progress and logout button.\n * Shows: \"X/Y Correct (Z%)\" format.\n *\n * @element qd-status\n * @fires {CustomEvent} qd:logout - Emitted when user clicks logout\n *\n * @example\n * ```html\n * \n * ```\n */\n\nimport { LitElement, html, css } from 'lit';\nimport { customElement, state } from 'lit/decorators.js';\nimport { STORAGE_KEYS } from '../types/contracts.js';\nimport type { SessionCache, SessionData } from '../types/contracts.js';\nimport { getJSON } from '../utils/storage-helpers.js';\nimport { isStudentLoggedIn } from '../utils/session-state.js';\nimport { calculateProgressIndicator, formatProgress } from '../utils/calculation-helpers.js';\nimport { readQuestionTotal } from '../config/dom-config-reader.js';\nimport { SessionService } from '../services/session.js';\nimport './qd-build-info.js';\nimport './qd-help-trigger.js';\nimport './qd-help-popup.js';\nimport { getHelpContent } from '../config/help-content.js';\n\n/**\n * Status panel component for student progress tracking\n */\n@customElement('qd-status')\nexport class QdStatus extends LitElement {\n /**\n * Questions answered, from the stored record. Unlike the old \"registered\n * questions\" figure it does not change with the page being viewed\n * (SRD 013 SCO-02).\n */\n @state()\n private answered = 0;\n\n /**\n * Total correct answers\n */\n @state()\n private correct = 0;\n\n /**\n * Publication question total from #qd-question-total, when known (SCO-03)\n */\n @state()\n private questionTotal: number | undefined = undefined;\n\n /**\n * Overall status indicator color\n */\n @state()\n private statusColor: 'red' | 'amber' | 'green' = 'red';\n\n /**\n * Student name\n */\n @state()\n private name = '';\n\n /**\n * Service ID (last 4 digits displayed)\n */\n @state()\n private serviceId = '';\n\n /**\n * Whether help popup is open\n */\n @state()\n private helpOpen = false;\n\n static styles = css`\n :host {\n display: none; /* Hidden by default, shown when logged in */\n font-family:\n system-ui,\n -apple-system,\n sans-serif;\n }\n\n :host([data-show]) {\n display: block;\n }\n\n .status-panel {\n display: flex;\n flex-direction: column;\n gap: 4px;\n padding: 6px 12px;\n background: #fff;\n border: 1px solid #ddd;\n border-radius: 4px;\n }\n\n .top-row {\n display: flex;\n align-items: center;\n gap: 8px;\n }\n\n .bottom-row {\n display: flex;\n align-items: center;\n gap: 8px;\n }\n\n .user-info {\n font-size: 13px;\n color: #333;\n white-space: nowrap;\n }\n\n .user-label {\n font-weight: 500;\n color: #555;\n }\n\n .status-indicator {\n width: 12px;\n height: 12px;\n border-radius: 50%;\n flex-shrink: 0;\n }\n\n .status-indicator.red {\n background: #d32f2f;\n }\n\n .status-indicator.amber {\n background: #ff9800;\n }\n\n .status-indicator.green {\n background: #4caf50;\n }\n\n .progress-label {\n font-size: 13px;\n font-weight: 500;\n color: #555;\n white-space: nowrap;\n }\n\n .progress-text {\n font-size: 13px;\n color: #333;\n white-space: nowrap;\n }\n\n .logout-button {\n padding: 5px 10px;\n background: #d32f2f;\n color: white;\n border: none;\n border-radius: 3px;\n font-size: 12px;\n font-weight: 500;\n cursor: pointer;\n transition: background 0.2s;\n white-space: nowrap;\n }\n\n .logout-button:hover {\n background: #b71c1c;\n }\n `;\n\n connectedCallback() {\n super.connectedCallback();\n this.updateVisibility();\n this.loadCache();\n\n // Listen for state changes and login/logout\n document.addEventListener('qd:state-changed', this.handleStateChanged);\n document.addEventListener('qd:login', this.handleLogin);\n document.addEventListener('qd:logout', this.handleLogoutEvent);\n // Listen for cache rebuild (fires after async IndexedDB load completes)\n document.addEventListener('qd:cache-rebuild', this.handleCacheRebuild);\n }\n\n disconnectedCallback() {\n super.disconnectedCallback();\n document.removeEventListener('qd:state-changed', this.handleStateChanged);\n document.removeEventListener('qd:login', this.handleLogin);\n document.removeEventListener('qd:logout', this.handleLogoutEvent);\n document.removeEventListener('qd:cache-rebuild', this.handleCacheRebuild);\n }\n\n render() {\n const last4 = this.serviceId.slice(-4);\n return html`\n
                                        \n
                                        \n \n Test progress:\n ${this.name} **${last4}\n \n \n \n \n
                                        \n
                                        \n
                                        \n
                                        \n ${formatProgress(this.answered, this.correct, this.questionTotal)}\n
                                        \n
                                        \n
                                        \n \n `;\n }\n\n /**\n * Load cache from storage and update state\n */\n private loadCache() {\n // Load session data for name/serviceId\n const session = getJSON(STORAGE_KEYS.SESSION);\n if (session) {\n this.name = session.name || '';\n this.serviceId = session.serviceId || '';\n } else {\n this.name = '';\n this.serviceId = '';\n }\n\n this.questionTotal = readQuestionTotal();\n const cache = getJSON(STORAGE_KEYS.CACHE);\n this.answered = cache?.totals.answered ?? 0;\n this.correct = cache?.totals.correct ?? 0;\n this.statusColor = calculateProgressIndicator(this.answered, this.correct, this.questionTotal);\n }\n\n /**\n * Update visibility based on session state\n * Show only if logged in as student (not instructor)\n */\n private updateVisibility() {\n this.toggleAttribute('data-show', isStudentLoggedIn());\n }\n\n /**\n * Handle state changed event\n */\n private handleStateChanged = () => {\n this.loadCache();\n };\n\n /**\n * Handle login event\n */\n private handleLogin = () => {\n this.updateVisibility();\n this.loadCache();\n };\n\n /**\n * Handle cache rebuild event (fired after async IndexedDB load completes)\n */\n private handleCacheRebuild = () => {\n this.loadCache();\n };\n\n /**\n * Handle logout event\n */\n private handleLogoutEvent = () => {\n this.updateVisibility();\n };\n\n /**\n * Handle help open event\n */\n private handleHelpOpen = (): void => {\n this.helpOpen = true;\n };\n\n /**\n * Handle help close event\n */\n private handleHelpClose = (): void => {\n this.helpOpen = false;\n };\n\n /**\n * Handle logout button click\n */\n private handleLogout() {\n const session = getJSON(STORAGE_KEYS.SESSION);\n\n // Clear session from storage\n const sessionService = new SessionService();\n sessionService.clearSession();\n\n const event = new CustomEvent('qd:logout', {\n detail: {\n serviceId: session?.serviceId || 'unknown',\n },\n bubbles: true,\n composed: true,\n });\n this.dispatchEvent(event);\n }\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n 'qd-status': QdStatus;\n }\n}\n","/**\n * Shared styles for instructor components\n * CSS-in-JS styles used across qd-instructor sub-components\n */\n\nimport { css } from 'lit';\n\n/**\n * Common styles shared across all instructor sub-components\n */\nexport const sharedStyles = css`\n :host {\n display: inline-block;\n font-family:\n system-ui,\n -apple-system,\n sans-serif;\n font-size: 14px;\n line-height: 1.5;\n }\n\n /* When showing modal, host should not constrain size */\n :host([showmodal]) {\n display: block;\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n pointer-events: none; /* Let clicks through except on modal */\n }\n\n :host([showmodal]) .modal-overlay {\n pointer-events: auto; /* Re-enable on overlay */\n }\n\n .instructor-panel {\n display: flex;\n flex-direction: row;\n align-items: center;\n gap: 8px;\n }\n\n .instructor-title {\n font-weight: 600;\n font-size: 14px;\n color: var(--qd-text-on-dark, #fff);\n margin-right: 8px;\n }\n\n .toggle-label {\n display: flex;\n align-items: center;\n gap: 6px;\n cursor: pointer;\n font-size: 13px;\n color: var(--qd-text-on-dark, #fff);\n user-select: none;\n }\n\n .toggle-label input[type='checkbox'] {\n width: 16px;\n height: 16px;\n cursor: pointer;\n }\n\n button {\n padding: 8px 16px;\n border: 1px solid #ccc;\n border-radius: 4px;\n background: #fff;\n cursor: pointer;\n font-size: 14px;\n transition: all 0.2s;\n }\n\n button:hover {\n background: #f5f5f5;\n border-color: #999;\n }\n\n button:active {\n background: #e5e5e5;\n }\n\n button:disabled {\n opacity: 0.5;\n cursor: not-allowed;\n }\n\n button.compact {\n padding: 6px 12px;\n font-size: 13px;\n }\n\n /* #0062cc keeps white text above WCAG AA 4.5:1 (#007bff was 3.98:1; SRD 013 A11Y-04) */\n button.primary {\n background: #0062cc;\n color: white;\n border-color: #0062cc;\n }\n\n button.primary:hover {\n background: #004c9e;\n border-color: #004c9e;\n }\n\n button.secondary {\n background: #ff9800;\n color: white;\n border-color: #ff9800;\n }\n\n button.secondary:hover {\n background: #f57c00;\n border-color: #f57c00;\n }\n\n button.danger {\n background: #dc3545;\n color: white;\n border-color: #dc3545;\n }\n\n button.danger:hover {\n background: #c82333;\n border-color: #c82333;\n }\n\n /*\n * Destructive actions sit apart from routine ones and stay quiet until used\n * (SRD 013 UX-08): an outline on the dark toolbar, filled only on hover.\n */\n .danger-zone {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n padding-left: 12px;\n margin-left: 4px;\n border-left: 1px solid rgba(255, 255, 255, 0.35);\n }\n\n button.outline-danger {\n background: transparent;\n color: #ffcdd2;\n border-color: #e57373;\n }\n\n button.outline-danger:hover {\n background: #c62828;\n color: white;\n border-color: #c62828;\n }\n\n button.logout {\n background: #6c757d;\n color: white;\n border-color: #6c757d;\n }\n\n button.logout:hover {\n background: #5a6268;\n border-color: #5a6268;\n }\n\n input,\n textarea {\n padding: 8px;\n border: 1px solid #ccc;\n border-radius: 4px;\n font-size: 14px;\n font-family: inherit;\n }\n\n input:focus,\n textarea:focus {\n outline: none;\n border-color: #007bff;\n box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);\n }\n\n .error {\n color: #dc3545;\n font-size: 12px;\n margin-top: 4px;\n }\n\n .success {\n color: #28a745;\n font-size: 12px;\n margin-top: 4px;\n }\n\n table {\n width: 100%;\n border-collapse: collapse;\n margin: 16px 0;\n }\n\n th,\n td {\n padding: 8px;\n text-align: left;\n border-bottom: 1px solid #ddd;\n color: #333; /* Explicit dark text */\n }\n\n th {\n background: #f5f5f5;\n font-weight: 600;\n color: #000; /* Explicit black for headers */\n }\n\n tr:hover {\n background: #f9f9f9;\n }\n\n .correct {\n color: #28a745;\n }\n\n .incorrect {\n color: #dc3545;\n }\n\n .modal-overlay {\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba(0, 0, 0, 0.5);\n display: flex;\n align-items: center;\n justify-content: center;\n z-index: var(--qd-modal-overlay-z-index, 9999);\n pointer-events: auto; /* Ensure overlay catches all clicks */\n }\n\n .modal-content {\n position: relative;\n background: white;\n padding: 24px;\n border-radius: 8px;\n max-width: 800px;\n max-height: 80vh;\n overflow: auto;\n box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);\n z-index: var(--qd-modal-z-index, 10000);\n color: #333; /* Explicit dark text color */\n }\n\n .modal-header {\n display: flex;\n justify-content: space-between;\n align-items: center;\n margin-bottom: 16px;\n }\n\n .modal-title {\n font-size: 18px;\n font-weight: 600;\n margin: 0;\n color: #000; /* Explicit black for title */\n }\n\n .close-button {\n padding: 4px 8px;\n border: none;\n background: transparent;\n font-size: 20px;\n cursor: pointer;\n color: #666;\n }\n\n .close-button:hover {\n color: #000;\n }\n`;\n","/**\n * Instructor unlock component with password verification and rate limiting\n */\n\nimport { LitElement, html } from 'lit';\nimport { customElement, state } from 'lit/decorators.js';\nimport { sharedStyles } from './shared-styles.js';\nimport { RateLimiter } from '../../utils/security.js';\nimport { getExpectedInstructorHash } from '../../services/auth/instructor-auth.js';\nimport { startInstructorSession } from '../../services/auth/instructor-session.js';\nimport { PIN_CONSTANTS } from '../../types/contracts.js';\nimport { dispatchEventOn } from '../../utils/event-helpers.js';\n\n/**\n * Password unlock UI with rate limiting for instructor access\n *\n * Features:\n * - Password input with masked field\n * - Rate limiting: 2s, 4s, 8s, 16s, 30s lockout on failures\n * - Constant-time password comparison\n * - Emits 'qd:instructor-unlock' on success\n *\n * @fires qd:instructor-unlock - Emitted when password verified successfully\n */\n@customElement('qd-instructor-unlock')\nexport class QdInstructorUnlock extends LitElement {\n static override styles = sharedStyles;\n\n @state()\n private password = '';\n\n @state()\n private error = '';\n\n @state()\n private remainingSeconds = 0;\n\n // Same allowance as the student PIN policy (PIN_CONSTANTS.MAX_ATTEMPTS): the\n // third consecutive wrong password starts the exponential lockout.\n private rateLimiter = new RateLimiter(PIN_CONSTANTS.MAX_ATTEMPTS - 1);\n private countdownInterval?: number;\n\n override disconnectedCallback(): void {\n super.disconnectedCallback();\n if (this.countdownInterval) {\n window.clearInterval(this.countdownInterval);\n }\n }\n\n private handlePasswordInput = (e: Event): void => {\n const input = e.target as HTMLInputElement;\n this.password = input.value;\n this.error = '';\n };\n\n private handleSubmit = async (e: Event): Promise => {\n e.preventDefault();\n\n // Check rate limit\n const allowed = this.rateLimiter.attempt();\n if (!allowed) {\n this.remainingSeconds = this.rateLimiter.getRemainingSeconds();\n this.startCountdown();\n this.error = `Too many attempts. Try again in ${this.remainingSeconds}s`;\n return;\n }\n\n // Validate password\n try {\n // Same config element and hash format as the login modal (#qd-instructor-hash,\n // SHA-256 truncated to 12 hex chars) so the two unlock paths cannot disagree.\n const expectedHash = getExpectedInstructorHash();\n if (!expectedHash) {\n throw new Error('Instructor password hash not configured');\n }\n // Verifies against the configured hash and stores the secret that later\n // page loads re-check (SRD 013 SEC-01)\n const valid = await startInstructorSession(this.password);\n\n if (valid) {\n // Success - reset limiter and emit event\n this.rateLimiter.reset();\n this.password = '';\n this.error = '';\n dispatchEventOn(this, 'qd:instructor-unlock', {});\n } else {\n // Failure - count it (this path previously never recorded failures, so\n // the limiter never engaged) and show the error\n this.rateLimiter.recordFailure();\n this.error = 'Invalid password';\n this.password = '';\n }\n } catch {\n this.error = 'Authentication failed';\n this.password = '';\n }\n };\n\n private startCountdown(): void {\n if (this.countdownInterval) {\n window.clearInterval(this.countdownInterval);\n }\n\n this.countdownInterval = window.setInterval(() => {\n this.remainingSeconds = this.rateLimiter.getRemainingSeconds();\n if (this.remainingSeconds === 0) {\n if (this.countdownInterval) {\n window.clearInterval(this.countdownInterval);\n this.countdownInterval = undefined;\n }\n this.error = '';\n } else {\n this.error = `Too many attempts. Try again in ${this.remainingSeconds}s`;\n }\n }, 1000);\n }\n\n override render() {\n const isLocked = this.remainingSeconds > 0;\n\n return html`\n
                                        \n

                                        Instructor Access

                                        \n

                                        Enter the instructor password to unlock administrative features.

                                        \n\n
                                        \n
                                        \n \n \n
                                        \n\n ${this.error\n ? html`
                                        ${this.error}
                                        `\n : ''}\n\n \n
                                        \n
                                        \n `;\n }\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n 'qd-instructor-unlock': QdInstructorUnlock;\n }\n}\n","/**\n * Scores Modal Component\n *\n * Displays student scores in a modal with expandable per-page breakdown.\n * Uses qd-modal as base for modal behavior.\n *\n * @element qd-scores-modal\n * @fires {CustomEvent} close - Emitted when modal closes\n * @fires {CustomEvent} qd:modal-close - Bubbles from qd-modal\n *\n * Feature: 007-lit-component-refactor\n */\n\nimport { LitElement, html, css } from 'lit';\nimport { customElement, property } from 'lit/decorators.js';\nimport type { StudentRecord, AnalysisData } from '../types/contracts.js';\nimport { countFilledCells } from '../services/state-calculator.js';\nimport './qd-modal.js';\nimport { summariseFirstAttempts } from '../services/scores-service.js';\nimport { readQuestionTotal } from '../config/dom-config-reader.js';\n\ninterface StudentSummary {\n serviceId: string;\n name: string;\n attempted: number;\n correct: number;\n percentage: number;\n}\n\n/**\n * Modal component for displaying student scores with expandable details\n */\n@customElement('qd-scores-modal')\nexport class QdScoresModal extends LitElement {\n /**\n * Whether the modal is open\n */\n @property({ type: Boolean, reflect: true })\n open = false;\n\n /**\n * Student records to display\n */\n @property({ type: Array })\n students: StudentRecord[] = [];\n\n // Styles are in light DOM (render method) since content is slotted into qd-modal which moves to body\n static styles = css`\n :host {\n display: contents;\n }\n `;\n\n render() {\n // Styles must be in light DOM since content is slotted into qd-modal which moves to body\n return html`\n \n Student Scores\n \n
                                        \n ${this.students.length === 0\n ? html`

                                        No student data available.

                                        `\n : this.renderScoresTable()}\n
                                        \n
                                        \n `;\n }\n\n private renderScoresTable() {\n const sortedStudents = [...this.students].sort((a, b) => a.name.localeCompare(b.name));\n\n return html`\n \n \n \n \n \n \n \n \n \n \n \n \n ${sortedStudents.map((student) => this.renderStudentRow(student))}\n \n
                                        StudentService IDAnsweredCorrectRight first timeAnswers
                                        \n `;\n }\n\n private renderStudentRow(student: StudentRecord) {\n const summary = this.calculateSummary(student);\n const pages = Object.entries(student.pages);\n\n return html`\n \n ${summary.name}\n ${summary.serviceId}\n ${this.answeredText(summary.attempted)}\n \n ${summary.attempted > 0 ? `${summary.correct} (${summary.percentage}% of answered)` : '0'}\n \n ${this.firstTimeText(student)}\n \n ${pages.length === 0\n ? html`—`\n : html`\n
                                        \n ${pages.map(\n ([pageId, pageData]) => html`\n
                                        \n ${pageId}\n
                                        \n ${pageData.analysis\n ? html`${this.analysisSummary(pageData.analysis)}`\n : ''}\n ${pageData.answers.map((answer, idx) =>\n // Skipped questions are padded with empty\n // placeholders; they are not wrong answers\n !answer || answer.answer.trim() === ''\n ? ''\n : html`\n \n Q${idx + 1}:\n ${answer?.answer ?? '—'}${answer?.attempts &&\n answer.attempts > 1\n ? ` (${answer.attempts} tries)`\n : ''}\n \n `,\n )}\n
                                        \n
                                        \n `,\n )}\n
                                        \n `}\n \n \n `;\n }\n\n /**\n * \"2 of 6 cells\" for a page's analysis table, so analysis work shows in the\n * scores view (SRD 013 ANA-05). Older records lack the cell count.\n */\n private analysisSummary(analysis: AnalysisData): string {\n const filled = countFilledCells(analysis);\n return analysis.totalCells !== undefined\n ? `Analysis: ${filled} of ${analysis.totalCells} cells`\n : `Analysis: ${filled} cell${filled === 1 ? '' : 's'}`;\n }\n\n /**\n * Questions answered, against the publication total when it is known\n * (SRD 013 SCO-04). Scores used to divide by answered questions only, so\n * one right answer read as 100%.\n */\n private answeredText(answered: number): string {\n const total = readQuestionTotal();\n return total !== undefined ? `${answered} of ${total}` : String(answered);\n }\n\n /**\n * Right-first-time count. Answers from before attempts were tracked show\n * as unknown rather than guessed (SCO-04, SCO-07).\n */\n private firstTimeText(student: StudentRecord): string {\n const { tracked, correctFirstTime, untracked } = summariseFirstAttempts(student);\n if (tracked === 0) {\n return '–';\n }\n const text = `${correctFirstTime} of ${tracked}`;\n return untracked > 0\n ? `${text} (${untracked} older answer${untracked === 1 ? '' : 's'} –)`\n : text;\n }\n\n private getScoreClass(summary: StudentSummary): string {\n if (summary.attempted === 0) return '';\n if (summary.percentage === 100) return 'score-perfect';\n if (summary.percentage === 0) return 'score-zero';\n return '';\n }\n\n private calculateSummary(student: StudentRecord): StudentSummary {\n const percentage =\n student.attempted > 0 ? Math.round((student.correct / student.attempted) * 100) : 0;\n\n return {\n serviceId: student.serviceId,\n name: student.name,\n attempted: student.attempted,\n correct: student.correct,\n percentage,\n };\n }\n\n private handleModalClose = () => {\n this.open = false;\n this.dispatchEvent(new CustomEvent('close'));\n };\n\n /**\n * Open the modal\n */\n show() {\n this.open = true;\n }\n\n /**\n * Close the modal\n */\n close() {\n this.open = false;\n }\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n 'qd-scores-modal': QdScoresModal;\n }\n}\n","/**\n * Scores calculation service\n * Calculates student scores and summaries from StudentRecord data\n */\n\nimport type { StudentRecord } from '../types/contracts.js';\n\nexport interface StudentSummary {\n serviceId: string;\n name: string;\n attempted: number;\n correct: number;\n percentage: number;\n}\n\nexport interface PageSummary {\n pageId: string;\n attempted: number;\n correct: number;\n percentage: number;\n}\n\n/** Right-first-time figures for one student (SRD 013 SCO-04). */\nexport interface FirstAttemptSummary {\n /** Answered questions whose attempts are tracked (answered since v3) */\n tracked: number;\n /** Tracked questions answered correctly at the first attempt */\n correctFirstTime: number;\n /** Answered questions from before v3, whose first attempt is unknown */\n untracked: number;\n}\n\n/**\n * Count right-first-time answers across a student's pages.\n *\n * @param student - Student record\n * @returns Tracked, correct-first-time and untracked counts\n */\nexport function summariseFirstAttempts(student: StudentRecord): FirstAttemptSummary {\n let tracked = 0;\n let correctFirstTime = 0;\n let untracked = 0;\n for (const page of Object.values(student.pages)) {\n for (const answer of page.answers ?? []) {\n if (!answer || answer.answer.trim() === '') continue;\n if (answer.firstSuccess === undefined) {\n untracked++;\n } else {\n tracked++;\n if (answer.firstSuccess) correctFirstTime++;\n }\n }\n }\n return { tracked, correctFirstTime, untracked };\n}\n\n/**\n * Service for calculating student scores and summaries\n */\nexport class ScoresService {\n /**\n * Calculate summary statistics for a single student\n */\n calculateStudentSummary(student: StudentRecord): StudentSummary {\n const percentage =\n student.attempted > 0 ? Math.round((student.correct / student.attempted) * 100) : 0;\n\n return {\n serviceId: student.serviceId,\n name: student.name,\n attempted: student.attempted,\n correct: student.correct,\n percentage,\n };\n }\n\n /**\n * Calculate summary statistics for a specific page\n */\n calculatePageSummary(pageId: string, student: StudentRecord): PageSummary {\n const pageData = student.pages[pageId];\n if (!pageData) {\n return {\n pageId,\n attempted: 0,\n correct: 0,\n percentage: 0,\n };\n }\n\n const answers = pageData.answers || [];\n const attempted = answers.filter((a) => a !== null).length;\n const correct = answers.filter((a) => a?.success === true).length;\n const percentage = attempted > 0 ? Math.round((correct / attempted) * 100) : 0;\n\n return {\n pageId,\n attempted,\n correct,\n percentage,\n };\n }\n\n /**\n * Get all page summaries for a student\n */\n getPageSummaries(student: StudentRecord): PageSummary[] {\n const pageIds = Object.keys(student.pages);\n return pageIds.map((pageId) => this.calculatePageSummary(pageId, student));\n }\n\n /**\n * Sort students by name (alphabetical)\n */\n sortStudentsByName(students: StudentRecord[]): StudentRecord[] {\n return [...students].sort((a, b) => a.name.localeCompare(b.name));\n }\n\n /**\n * Sort students by percentage (descending)\n */\n sortStudentsByPercentage(students: StudentRecord[]): StudentRecord[] {\n return [...students].sort((a, b) => {\n const percentA = a.attempted > 0 ? (a.correct / a.attempted) * 100 : 0;\n const percentB = b.attempted > 0 ? (b.correct / b.attempted) * 100 : 0;\n return percentB - percentA;\n });\n }\n}\n","/**\n * Instructor scores view component\n * Displays student scores with expandable per-page breakdown\n *\n * Refactored to use qd-scores-modal component.\n * Feature: 007-lit-component-refactor\n */\n\nimport { LitElement, html } from 'lit';\nimport { customElement, property } from 'lit/decorators.js';\nimport { sharedStyles } from './shared-styles.js';\nimport type { StudentRecord } from '../../types/contracts.js';\nimport '../qd-scores-modal.js';\n\n/**\n * Scores table component showing all student progress\n *\n * Features:\n * - Summary view with attempted/correct/percentage\n * - Expandable per-student breakdown\n * - Color-coded correct/incorrect answers\n * - Modal display with close button\n *\n * Now delegates to qd-scores-modal component.\n */\n@customElement('qd-instructor-scores')\nexport class QdInstructorScores extends LitElement {\n static override styles = sharedStyles;\n\n @property({ type: Array })\n students: StudentRecord[] = [];\n\n @property({ type: Boolean })\n showModal = false;\n\n private handleClose = () => {\n this.dispatchEvent(new CustomEvent('close'));\n };\n\n override render() {\n return html`\n \n `;\n }\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n 'qd-instructor-scores': QdInstructorScores;\n }\n}\n","/**\n * Instructor CSV export component\n * Generates and downloads CSV export of all student data\n */\n\nimport { LitElement, html } from 'lit';\nimport { customElement, property } from 'lit/decorators.js';\nimport { sharedStyles } from './shared-styles.js';\nimport { countFilledCells } from '../../services/state-calculator.js';\nimport type { StudentRecord } from '../../types/contracts.js';\n\n/**\n * CSV export controls for instructor\n *\n * Features:\n * - Generates RFC 4180 compliant CSV\n * - Includes all student answers with timestamps\n * - Downloads as file with timestamp in filename\n * - Proper escaping of special characters\n */\n@customElement('qd-instructor-export')\nexport class QdInstructorExport extends LitElement {\n static override styles = sharedStyles;\n\n @property({ type: Array })\n students: StudentRecord[] = [];\n\n private escapeCSVField(field: string | number | boolean): string {\n let str = String(field);\n\n // Formula injection: a field beginning with = + - @ or a control character is\n // executed as a formula by Excel/Sheets. Student names and free-text answers are\n // untrusted, so prefix a single quote to force text interpretation.\n if (/^[=+\\-@\\t\\r]/.test(str)) {\n str = `'${str}`;\n }\n\n // If field contains comma, quote, or newline, wrap in quotes and escape quotes\n if (str.includes(',') || str.includes('\"') || str.includes('\\n')) {\n return `\"${str.replace(/\"/g, '\"\"')}\"`;\n }\n return str;\n }\n\n /**\n * Build the CSV.\n *\n * The first eight columns keep their original order so existing\n * spreadsheets still line up; new columns are appended (SRD 013 ANA-02).\n * Analysis entries get one row each, with the row label (or the cell key\n * when no label was stored) in the Question Index column, an empty Success\n * and the table's last-edited time (SRD 013 ANA-01).\n */\n generateCSV(): string {\n const rows: string[] = [];\n\n // Header row\n rows.push(\n 'Service ID,Name,Release,Page ID,Question Index,Answer,Success,Timestamp,Kind,Attempts,First Attempt Correct',\n );\n\n // Data rows\n for (const student of this.students) {\n const who = [student.serviceId, student.name, student.release];\n for (const [pageId, pageData] of Object.entries(student.pages)) {\n const answers = pageData.answers || [];\n answers.forEach((answer, index) => {\n // Skipped questions are padded with empty placeholders; not answers\n if (answer && answer.answer.trim() !== '') {\n rows.push(\n this.csvRow([\n ...who,\n pageId,\n index,\n answer.answer,\n answer.success,\n answer.timestamp,\n 'quiz',\n // Empty for answers from before attempts were tracked (SCO-06, SCO-07)\n answer.attempts ?? '',\n answer.firstSuccess ?? '',\n ]),\n );\n }\n });\n\n const analysis = pageData.analysis;\n if (analysis) {\n for (const [cellKey, text] of Object.entries(analysis.cells)) {\n if (text.trim() === '') continue;\n rows.push(\n this.csvRow([\n ...who,\n pageId,\n analysis.labels?.[cellKey] ?? cellKey,\n text,\n '',\n analysis.lastEdited ?? '',\n 'analysis',\n '',\n '',\n ]),\n );\n }\n }\n }\n }\n\n return rows.join('\\n');\n }\n\n private csvRow(fields: (string | number | boolean)[]): string {\n return fields.map((field) => this.escapeCSVField(field)).join(',');\n }\n\n private handleExport = (): void => {\n const csv = this.generateCSV();\n const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });\n const url = URL.createObjectURL(blob);\n\n // Create download link\n const link = document.createElement('a');\n link.href = url;\n\n // Generate filename with timestamp\n const now = new Date();\n const timestamp = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);\n link.download = `quiz-data-${timestamp}.csv`;\n\n // Trigger download\n document.body.appendChild(link);\n link.click();\n document.body.removeChild(link);\n\n // Clean up\n URL.revokeObjectURL(url);\n };\n\n override render() {\n // Check if any student has answered at least one question (FR-006)\n // Analysis entries count as data too (SRD 013 ANA-01)\n const hasData =\n this.students.length > 0 &&\n this.students.some(\n (student) =>\n student.attempted > 0 ||\n Object.values(student.pages).some((page) => countFilledCells(page.analysis) > 0),\n );\n\n const tooltip = hasData\n ? `Export ${this.students.length} student${this.students.length === 1 ? '' : 's'} to CSV`\n : this.students.length > 0\n ? 'No answers to export (students have not answered any questions)'\n : 'No data to export';\n\n return html`\n \n Export CSV\n \n `;\n }\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n 'qd-instructor-export': QdInstructorExport;\n }\n}\n","/**\n * Instructor data management component\n * Handles clearing/backing up student data\n */\n\nimport { LitElement, html, render } from 'lit';\nimport { customElement, state } from 'lit/decorators.js';\nimport { sharedStyles } from './shared-styles.js';\nimport { clearQuizData } from '../../utils/storage-helpers.js';\nimport { getStorageService } from '../../services/storage-service.js';\nimport { dispatchEventOn } from '../../utils/event-helpers.js';\nimport {\n confirmInstructorPassword,\n reauthErrorMessage,\n} from '../../services/auth/instructor-session.js';\n\n/**\n * Data management controls for instructor\n *\n * Features:\n * - Clear all quiz data with confirmation\n * - Safety confirmation dialog\n * - Emits 'qd:data-cleared' event on success\n *\n * @fires qd:data-cleared - Emitted when all data successfully cleared\n */\n@customElement('qd-instructor-manage')\nexport class QdInstructorManage extends LitElement {\n static override styles = sharedStyles;\n\n @state()\n private showConfirmDialog = false;\n\n @state()\n private confirmText = '';\n\n /** Instructor password, re-entered for this action (SRD 013 SEC-03) */\n @state()\n private password = '';\n\n /** Whether the password re-check is in progress */\n @state()\n private checking = false;\n\n @state()\n private error = '';\n\n @state()\n private success = '';\n\n private modalContainer: HTMLDivElement | null = null;\n\n override disconnectedCallback(): void {\n super.disconnectedCallback();\n this.removeModalFromBody();\n document.removeEventListener('keydown', this.handleKeydown);\n }\n\n /** Close the dialog on Escape, like the other dialogs (SRD 013 UX-07). */\n private handleKeydown = (e: KeyboardEvent): void => {\n if (e.key === 'Escape' && this.showConfirmDialog) {\n this.handleCancelClear();\n }\n };\n\n override updated(changedProperties: Map): void {\n super.updated(changedProperties);\n if (changedProperties.has('showConfirmDialog')) {\n if (this.showConfirmDialog) {\n this.renderModalToBody();\n document.addEventListener('keydown', this.handleKeydown);\n this.modalContainer?.querySelector('input')?.focus();\n } else {\n this.removeModalFromBody();\n document.removeEventListener('keydown', this.handleKeydown);\n }\n }\n // Re-render the modal when its inputs or messages change while it is open\n if (\n this.showConfirmDialog &&\n (changedProperties.has('confirmText') ||\n changedProperties.has('password') ||\n changedProperties.has('checking') ||\n changedProperties.has('error'))\n ) {\n this.renderModalToBody();\n }\n }\n\n private renderModalToBody(): void {\n if (!this.modalContainer) {\n this.modalContainer = document.createElement('div');\n this.modalContainer.className = 'qd-manage-modal-container';\n document.body.appendChild(this.modalContainer);\n }\n render(this.renderConfirmDialog(), this.modalContainer);\n }\n\n private removeModalFromBody(): void {\n if (this.modalContainer) {\n this.modalContainer.remove();\n this.modalContainer = null;\n }\n }\n\n private handleClearRequest = (): void => {\n this.showConfirmDialog = true;\n this.confirmText = '';\n this.password = '';\n this.error = '';\n this.success = '';\n };\n\n private handleCancelClear = (): void => {\n this.showConfirmDialog = false;\n this.confirmText = '';\n this.password = '';\n this.error = '';\n };\n\n private handleConfirmInput = (e: Event): void => {\n const input = e.target as HTMLInputElement;\n this.confirmText = input.value;\n };\n\n private handlePasswordInput = (e: Event): void => {\n this.password = (e.target as HTMLInputElement).value;\n this.error = '';\n };\n\n private handleConfirmClear = async (): Promise => {\n // Require exact match\n if (this.confirmText !== 'DELETE ALL DATA') {\n this.error = 'Confirmation text does not match';\n return;\n }\n\n // Re-enter the instructor password at the moment of the action, so a\n // faked or abandoned instructor session cannot erase the class (SEC-03)\n this.checking = true;\n const reauth = await confirmInstructorPassword(this.password);\n this.checking = false;\n if (!reauth.ok) {\n this.error = reauthErrorMessage(reauth);\n this.password = '';\n return;\n }\n\n try {\n // Clear persisted data (IndexedDB: students, backups, audit log) ...\n await getStorageService().clearAll();\n // ... then the session cache (sessionStorage qd/* keys)\n clearQuizData();\n\n // Emit event\n dispatchEventOn(this, 'qd:data-cleared', {});\n\n // Show success\n this.success = 'All quiz data cleared successfully';\n this.showConfirmDialog = false;\n this.confirmText = '';\n this.password = '';\n this.error = '';\n\n // Clear success message after 3 seconds\n setTimeout(() => {\n this.success = '';\n }, 3000);\n } catch {\n this.error = 'Failed to clear data';\n }\n };\n\n override render() {\n return html`\n \n Erase All Data\n \n\n ${this.success\n ? html`\n \n ${this.success}\n
                                  \n `\n : ''}\n `;\n }\n\n private renderConfirmDialog() {\n const isValid =\n this.confirmText === 'DELETE ALL DATA' && this.password.length > 0 && !this.checking;\n\n return html`\n {\n if (e.target === e.currentTarget) this.handleCancelClear();\n }}\n >\n e.stopPropagation()}\n >\n \n \n Confirm Data Deletion\n

                                  \n \n ✕\n \n \n\n

                                  \n ⚠️ This will permanently delete all student quiz data, answers, and progress.\n

                                  \n\n

                                  \n This action cannot be undone. All students will need to start over.\n

                                  \n\n \n\n \n\n \n\n \n\n ${this.error\n ? html`
                                  \n ${this.error}\n
                                  `\n : ''}\n\n
                                  \n \n Cancel\n \n \n Delete All Data\n \n
                                  \n \n \n `;\n }\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n 'qd-instructor-manage': QdInstructorManage;\n }\n}\n","/**\n * PIN reset service.\n *\n * Encapsulates the storage init, PIN reset, save, and audit-event construction\n * for an instructor-initiated PIN reset. Extracted from `qd-pin-reset-dialog`\n * so the dialog only renders the result.\n */\n\nimport type { StudentRecord, PinResetEvent } from '../types/contracts.js';\nimport { getStorageAdapter } from './storage/indexeddb.js';\nimport { resetPin } from './storage/migration.js';\nimport { readDbName, CONFIG_IDS } from '../config/dom-config-reader.js';\n\n/**\n * Result of a PIN reset.\n */\nexport interface PinResetResult {\n ok: boolean;\n /** User-facing error message when `ok` is false. */\n error?: string;\n /** The updated student record when `ok` is true. */\n updated?: StudentRecord;\n}\n\n/**\n * Reset a student's PIN and write an audit event.\n *\n * @param student - Student whose PIN to reset\n * @returns Success with the updated record, or failure with a message\n */\nexport async function resetStudentPin(student: StudentRecord): Promise {\n const dbName = readDbName();\n if (!dbName) {\n return {\n ok: false,\n error: `Database name not configured. Add dbName to page.`,\n };\n }\n\n try {\n const storage = getStorageAdapter(dbName);\n await storage.init();\n\n const updated = resetPin(student);\n await storage.saveStudent(updated);\n\n const auditEvent: PinResetEvent = {\n eventId: crypto.randomUUID(),\n serviceId: student.serviceId,\n resetBy: 'instructor',\n resetAt: new Date().toISOString(),\n release: student.release,\n };\n await storage.saveAuditEvent(auditEvent);\n\n return { ok: true, updated };\n } catch (err) {\n console.error('PIN reset error:', err);\n return { ok: false, error: 'Failed to reset PIN. Please try again.' };\n }\n}\n","/**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n */\nconst t={ATTRIBUTE:1,CHILD:2,PROPERTY:3,BOOLEAN_ATTRIBUTE:4,EVENT:5,ELEMENT:6},e=t=>(...e)=>({_$litDirective$:t,values:e});class i{constructor(t){}get _$AU(){return this._$AM._$AU}_$AT(t,e,i){this._$Ct=t,this._$AM=e,this._$Ci=i}_$AS(t,e){return this.update(t,e)}update(t,e){return this.render(...e)}}export{i as Directive,t as PartType,e as directive};\n//# sourceMappingURL=directive.js.map\n","import{nothing as t,noChange as i}from\"../lit-html.js\";import{Directive as r,PartType as s,directive as n}from\"../directive.js\";\n/**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n */class e extends r{constructor(i){if(super(i),this.it=t,i.type!==s.CHILD)throw Error(this.constructor.directiveName+\"() can only be used in child bindings\")}render(r){if(r===t||null==r)return this._t=void 0,this.it=r;if(r===i)return r;if(\"string\"!=typeof r)throw Error(this.constructor.directiveName+\"() called with a non-string value\");if(r===this.it)return this._t;this.it=r;const s=[r];return s.raw=s,this._t={_$litType$:this.constructor.resultType,strings:s,values:[]}}}e.directiveName=\"unsafeHTML\",e.resultType=1;const o=n(e);export{e as UnsafeHTMLDirective,o as unsafeHTML};\n//# sourceMappingURL=unsafe-html.js.map\n","/**\n * Confirmation dialog component\n *\n * Reusable confirmation modal using qd-modal base.\n * Supports confirm/cancel buttons with optional destructive styling.\n *\n * Feature: 007-lit-component-refactor\n *\n * @element qd-confirm-dialog\n * @fires {CustomEvent} qd:confirm - Emitted when confirm button is clicked\n * @fires {CustomEvent} qd:cancel - Emitted when cancel button is clicked or dialog is dismissed\n */\n\nimport { LitElement, html, css, nothing } from 'lit';\nimport { customElement, property, state } from 'lit/decorators.js';\nimport { unsafeHTML } from 'lit/directives/unsafe-html.js';\nimport './qd-modal.js';\n\n/** Inline styles for the slotted dialog body (see the note on `styles`). */\nconst CONTENT_STYLES = {\n content: 'padding: 8px 0;',\n message: 'font-size: 14px; color: #333; line-height: 1.5; margin-bottom: 16px;',\n label: 'display: block; font-size: 13px; color: #333; margin: 0 0 4px;',\n input:\n 'width: 100%; box-sizing: border-box; padding: 8px 10px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px; margin-bottom: 16px;',\n row: 'display: flex; gap: 8px; justify-content: flex-end;',\n button:\n 'padding: 8px 16px; border: none; border-radius: 4px; font-size: 13px; font-weight: 500; cursor: pointer;',\n cancel: 'background: #e0e0e0; color: #333;',\n confirm: 'background: #0066cc; color: white;',\n destructive: 'background: #d32f2f; color: white;',\n disabled: 'background: #ccc; color: white; cursor: not-allowed;',\n} as const;\n\n@customElement('qd-confirm-dialog')\nexport class QdConfirmDialog extends LitElement {\n /*\n * The dialog body is slotted into , which moves itself to\n * document.body when it opens. Slotted light DOM then leaves this\n * component's shadow root, so shadow-scoped styles stop applying and the\n * dialog rendered with browser-default buttons (SRD 013 UX-01). The body is\n * therefore styled inline (see CONTENT_STYLES), which survives the move and\n * adds no global CSS.\n */\n static override styles = css`\n :host {\n display: contents;\n }\n `;\n\n /**\n * Whether dialog is open\n */\n @property({ type: Boolean, reflect: true })\n open = false;\n\n /**\n * Dialog title\n */\n @property({ type: String })\n title = 'Confirm';\n\n /**\n * Message to display (supports HTML)\n */\n @property({ type: String })\n message = '';\n\n /**\n * Text for confirm button\n */\n @property({ type: String })\n confirmText = 'Confirm';\n\n /**\n * Text for cancel button\n */\n @property({ type: String })\n cancelText = 'Cancel';\n\n /**\n * Whether this is a destructive action (red confirm button)\n */\n @property({ type: Boolean })\n destructive = false;\n\n /**\n * Ask for the instructor password before confirming (SRD 013 SEC-03).\n * The typed password is sent in the `qd:confirm` event detail; the caller\n * verifies it.\n */\n @property({ type: Boolean })\n requirePassword = false;\n\n /** Password typed into the dialog when {@link requirePassword} is set */\n @state()\n private password = '';\n\n /**\n * Show the dialog\n */\n show(): void {\n this.open = true;\n }\n\n /**\n * Close the dialog\n */\n close(): void {\n this.open = false;\n }\n\n /**\n * Handle modal close from qd-modal (backdrop click, Escape)\n */\n private handleModalClose = (): void => {\n this.close();\n this.dispatchEvent(\n new CustomEvent('qd:cancel', {\n bubbles: true,\n composed: true,\n }),\n );\n };\n\n /**\n * Handle confirm button click\n */\n private handleConfirm = (): void => {\n if (this.requirePassword && !this.password) {\n return;\n }\n const password = this.password;\n this.password = '';\n this.close();\n this.dispatchEvent(\n new CustomEvent('qd:confirm', {\n detail: this.requirePassword ? { password } : undefined,\n bubbles: true,\n composed: true,\n }),\n );\n };\n\n private handlePasswordInput = (e: Event): void => {\n this.password = (e.target as HTMLInputElement).value;\n };\n\n /**\n * Handle cancel button click\n */\n private handleCancel = (): void => {\n this.password = '';\n this.close();\n this.dispatchEvent(\n new CustomEvent('qd:cancel', {\n bubbles: true,\n composed: true,\n }),\n );\n };\n\n /** Confirm stays disabled until a required password has been typed. */\n private get confirmBlocked(): boolean {\n return this.requirePassword && !this.password;\n }\n\n override render() {\n return html`\n \n ${this.title}\n\n
                                  \n
                                  ${unsafeHTML(this.message)}
                                  \n\n ${this.requirePassword\n ? html`Instructor password\n e.key === 'Enter' && this.handleConfirm()}\n style=${CONTENT_STYLES.input}\n />`\n : nothing}\n\n
                                  \n ${this.cancelText\n ? html`\n ${this.cancelText}\n `\n : nothing}\n \n ${this.confirmText}\n \n
                                  \n
                                  \n
                                  \n `;\n }\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n 'qd-confirm-dialog': QdConfirmDialog;\n }\n}\n","/**\n * Student Table Component\n *\n * Reusable, searchable table of students that emits a per-row action event.\n * Shadow-DOM-isolated (styles in `static styles`, auto-escaped bindings).\n * Extracted from `qd-pin-reset-dialog` for reuse.\n *\n * @element qd-student-table\n * @fires {CustomEvent} select - Emitted when a row's action button is clicked\n */\n\nimport { LitElement, html, css } from 'lit';\nimport { customElement, property, state } from 'lit/decorators.js';\nimport type { StudentRecord } from '../types/contracts.js';\n\n/**\n * Searchable student list with a per-row action button.\n */\n@customElement('qd-student-table')\nexport class QdStudentTable extends LitElement {\n /** Students to display. */\n @property({ type: Array })\n students: StudentRecord[] = [];\n\n /** Label for the per-row action button. */\n @property({ type: String })\n actionLabel = 'Select';\n\n @state()\n private searchText = '';\n\n static override styles = css`\n :host {\n display: block;\n }\n\n .search-input {\n width: 100%;\n box-sizing: border-box;\n padding: 8px 12px;\n border: 1px solid #ccc;\n border-radius: 4px;\n margin-bottom: 12px;\n font-size: 12px;\n }\n\n .search-input:focus {\n outline: none;\n border-color: #0066cc;\n box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.1);\n }\n\n .student-table-container {\n max-height: 300px;\n overflow-y: auto;\n border: 1px solid #e0e0e0;\n border-radius: 4px;\n }\n\n .student-table {\n width: 100%;\n border-collapse: collapse;\n font-size: 12px;\n }\n\n .student-table th {\n text-align: left;\n padding: 8px 12px;\n background: #f5f5f5;\n border-bottom: 1px solid #e0e0e0;\n font-weight: 500;\n position: sticky;\n top: 0;\n }\n\n .student-table td {\n padding: 6px 12px;\n border-bottom: 1px solid #f0f0f0;\n }\n\n .student-table tbody tr:nth-child(even) {\n background: #f8f8f8;\n }\n\n .student-table tbody tr:hover {\n background: #f0f0f0;\n }\n\n .student-table tr:last-child td {\n border-bottom: none;\n }\n\n .action-btn {\n background: #ff5722;\n color: white;\n border: none;\n border-radius: 4px;\n padding: 4px 8px;\n font-size: 10px;\n cursor: pointer;\n }\n\n .action-btn:hover {\n background: #e64a19;\n }\n\n .empty-message {\n padding: 16px;\n text-align: center;\n color: #666;\n font-size: 12px;\n }\n `;\n\n private get filteredStudents(): StudentRecord[] {\n const search = this.searchText.toLowerCase().trim();\n if (!search) {\n return this.students;\n }\n return this.students.filter(\n (s) => s.name.toLowerCase().includes(search) || s.serviceId.toLowerCase().includes(search),\n );\n }\n\n private handleSearchInput = (e: Event): void => {\n this.searchText = (e.target as HTMLInputElement).value;\n };\n\n private emitSelect(student: StudentRecord): void {\n this.dispatchEvent(\n new CustomEvent('select', { detail: student, bubbles: true, composed: true }),\n );\n }\n\n override render() {\n const filtered = this.filteredStudents;\n return html`\n \n
                                  \n ${filtered.length === 0\n ? html`
                                  \n ${this.searchText ? 'No matching students' : 'No students found'}\n
                                  `\n : html`\n \n \n \n \n \n \n \n \n ${filtered.map(\n (s) =>\n html`\n \n \n \n `,\n )}\n \n
                                  NameService ID${this.actionLabel}
                                  ${s.name}${s.serviceId}\n \n
                                  `}\n
                                  \n `;\n }\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n 'qd-student-table': QdStudentTable;\n }\n}\n","/**\n * PIN Reset Dialog Component\n *\n * Modal dialog for instructors to reset student PINs.\n * Shows student list with search and reset confirmation.\n * Uses qd-modal base for consistent modal behavior.\n *\n * @element qd-pin-reset-dialog\n * @fires {CustomEvent<{serviceId: string}>} qd:pin-reset - Emitted when PIN is reset\n * @fires {CustomEvent} close - Emitted when dialog is closed\n *\n * Feature: 007-lit-component-refactor\n */\n\nimport { LitElement, html, css, nothing } from 'lit';\nimport { customElement, property, state } from 'lit/decorators.js';\nimport type { StudentRecord } from '../types/contracts.js';\nimport { resetStudentPin } from '../services/pin-reset-service.js';\nimport './qd-modal.js';\nimport './qd-confirm-dialog.js';\nimport './qd-student-table.js';\nimport { escapeHtml } from '../utils/dom-helpers.js';\nimport {\n confirmInstructorPassword,\n reauthErrorMessage,\n} from '../services/auth/instructor-session.js';\n\n@customElement('qd-pin-reset-dialog')\nexport class QdPinResetDialog extends LitElement {\n /**\n * Students available for PIN reset\n */\n @property({ type: Array })\n students: StudentRecord[] = [];\n\n /**\n * Whether dialog is visible\n */\n @property({ type: Boolean, reflect: true })\n open = false;\n\n /**\n * Student being confirmed for reset\n */\n @state()\n private confirmingStudent: StudentRecord | null = null;\n\n /**\n * Whether confirmation dialog is open\n */\n @state()\n private confirmDialogOpen = false;\n\n /**\n * Error message to display\n */\n @state()\n private errorMessage = '';\n\n static styles = css`\n :host {\n display: contents;\n }\n\n .pin-reset-content {\n min-width: 400px;\n max-width: 500px;\n }\n\n .error-message {\n color: #d32f2f;\n font-size: 11px;\n margin-top: 8px;\n padding: 8px;\n background: #ffebee;\n border-radius: 4px;\n }\n `;\n\n /**\n * Backward compatibility: Support both 'open' and 'showModal' props\n */\n @property({ type: Boolean })\n set showModal(value: boolean) {\n this.open = value;\n }\n get showModal(): boolean {\n return this.open;\n }\n\n /**\n * Close the modal\n */\n close(): void {\n this.open = false;\n this.confirmingStudent = null;\n this.confirmDialogOpen = false;\n this.errorMessage = '';\n }\n\n /**\n * Show the modal\n */\n show(): void {\n this.open = true;\n }\n\n /**\n * Handle modal close from qd-modal\n */\n private handleModalClose = (): void => {\n // Don't close main modal if confirm dialog is open\n if (this.confirmDialogOpen) {\n return;\n }\n this.close();\n this.dispatchEvent(new CustomEvent('close'));\n };\n\n /**\n * Show confirmation dialog for PIN reset\n */\n private handleResetClick = (student: StudentRecord): void => {\n this.confirmingStudent = student;\n this.confirmDialogOpen = true;\n };\n\n /**\n * Handle confirm button click in confirmation dialog\n */\n private handleConfirmReset = (e: CustomEvent<{ password?: string } | undefined>): void => {\n const student = this.confirmingStudent;\n if (!student) {\n return;\n }\n void this.verifyThenReset(student, e.detail?.password ?? '');\n };\n\n /**\n * Re-check the instructor password at the moment of the reset\n * (SRD 013 SEC-03), then reset the PIN.\n */\n private async verifyThenReset(student: StudentRecord, password: string): Promise {\n const reauth = await confirmInstructorPassword(password);\n if (!reauth.ok) {\n this.confirmDialogOpen = false;\n this.confirmingStudent = null;\n this.errorMessage = `${reauthErrorMessage(reauth)}. The PIN was not reset.`;\n return;\n }\n await this.executeReset(student);\n }\n\n /**\n * Handle cancel button click in confirmation dialog\n */\n private handleCancelReset = (): void => {\n this.confirmDialogOpen = false;\n this.confirmingStudent = null;\n };\n\n private async executeReset(student: StudentRecord) {\n const result = await resetStudentPin(student);\n\n this.confirmDialogOpen = false;\n this.confirmingStudent = null;\n\n if (!result.ok) {\n this.errorMessage = result.error ?? 'Failed to reset PIN. Please try again.';\n return;\n }\n\n // Update local data to trigger reactivity\n if (result.updated) {\n const index = this.students.findIndex((s) => s.serviceId === student.serviceId);\n if (index >= 0) {\n this.students[index] = result.updated;\n this.students = [...this.students];\n }\n }\n\n this.errorMessage = '';\n this.dispatchEvent(\n new CustomEvent('qd:pin-reset', {\n detail: {\n serviceId: student.serviceId,\n resetBy: 'instructor',\n timestamp: new Date().toISOString(),\n },\n bubbles: true,\n composed: true,\n }),\n );\n }\n\n override render() {\n const student = this.confirmingStudent;\n // Student name and service ID come from stored records: escape them before\n // they reach qd-confirm-dialog, which renders the message with unsafeHTML.\n const confirmMessage = student\n ? `Reset PIN for ${escapeHtml(student.name)} (${escapeHtml(student.serviceId)})?
                                  They will need to create a new PIN on next login.`\n : '';\n\n // Always render qd-modal so it can properly restore position when closing\n return html`\n \n Reset Student PIN\n\n ${this.open\n ? html`\n
                                  \n ) => this.handleResetClick(e.detail)}\n >\n\n ${this.errorMessage\n ? html`\n ${this.errorMessage}\n
                                  `\n : ''}\n \n `\n : nothing}\n
                                  \n\n \n `;\n }\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n 'qd-pin-reset-dialog': QdPinResetDialog;\n }\n}\n","/**\n * Instructor component orchestrator\n * Delegates to sub-components based on unlock state\n */\n\nimport { LitElement, html, css } from 'lit';\nimport { customElement, state } from 'lit/decorators.js';\nimport { sharedStyles } from './shared-styles.js';\nimport type { StudentRecord, SessionData } from '../../types/contracts.js';\nimport { STORAGE_KEYS } from '../../types/contracts.js';\nimport { getJSON, INSTRUCTOR_SHOW_ANSWERS_KEY } from '../../utils/storage-helpers.js';\nimport { isInstructor } from '../../utils/session-state.js';\nimport { SessionService } from '../../services/session.js';\nimport { getStorageService } from '../../services/storage-service.js';\nimport './qd-instructor-unlock.js';\nimport './qd-instructor-scores.js';\nimport './qd-instructor-export.js';\nimport './qd-instructor-manage.js';\nimport '../qd-build-info.js';\nimport '../qd-pin-reset-dialog.js';\nimport '../qd-help-trigger.js';\nimport '../qd-help-popup.js';\nimport { getHelpContent } from '../../config/help-content.js';\n\n/**\n * Main instructor panel orchestrating all sub-components\n *\n * State management:\n * - unlocked: false → shows unlock component\n * - unlocked: true → shows scores/export/manage controls\n *\n * @fires qd:instructor-unlock - Forwarded from unlock component\n * @fires qd:data-cleared - Forwarded from manage component\n */\n@customElement('qd-instructor')\nexport class QdInstructor extends LitElement {\n static override styles = [\n sharedStyles,\n css`\n :host {\n display: none; /* Hidden by default, shown when instructor logged in */\n }\n\n :host([data-show]) {\n display: block;\n }\n `,\n ];\n\n @state()\n private unlocked = false;\n\n @state()\n private showScores = false;\n\n @state()\n private students: StudentRecord[] = [];\n\n @state()\n private showStudentAnswers = false;\n\n @state()\n private showPinReset = false;\n\n @state()\n private helpOpen = false;\n\n connectedCallback() {\n super.connectedCallback();\n this.updateVisibility();\n\n // Auto-unlock if instructor is already logged in\n const instructorActive = isInstructor();\n if (instructorActive) {\n this.unlock();\n // Load students data for export button\n void this.refreshStudents();\n }\n\n // Restore toggle state from sessionStorage\n const savedState = sessionStorage.getItem(INSTRUCTOR_SHOW_ANSWERS_KEY);\n if (savedState !== null) {\n this.showStudentAnswers = savedState === 'true';\n\n // If toggle was enabled and instructor is logged in, dispatch event to show answers\n if (this.showStudentAnswers && instructorActive) {\n // Dispatch after tables are enhanced (use setTimeout to defer)\n setTimeout(() => {\n this.dispatchEvent(\n new CustomEvent('qd:instructor-show-answers', {\n bubbles: true,\n composed: true,\n }),\n );\n }, 100);\n }\n }\n\n document.addEventListener('qd:login', this.handleLoginEvent);\n document.addEventListener('qd:logout', this.handleLogoutEvent);\n }\n\n disconnectedCallback() {\n super.disconnectedCallback();\n document.removeEventListener('qd:login', this.handleLoginEvent);\n document.removeEventListener('qd:logout', this.handleLogoutEvent);\n }\n\n /**\n * Update visibility based on instructor session state\n */\n private updateVisibility(): void {\n this.toggleAttribute('data-show', isInstructor());\n }\n\n private handleLoginEvent = (event: Event): void => {\n const customEvent = event as CustomEvent<{ role?: string }>;\n const role = customEvent.detail?.role;\n\n this.updateVisibility();\n\n // Auto-unlock if instructor logged in\n if (role === 'instructor') {\n this.unlock();\n // Load students data for export button\n void this.refreshStudents();\n }\n };\n\n private handleLogoutEvent = (): void => {\n this.updateVisibility();\n this.lock();\n };\n\n /**\n * Set student data for display\n */\n setStudents(students: StudentRecord[]): void {\n this.students = students;\n }\n\n /**\n * Load all students for the current release into `this.students`.\n *\n * The single source for loading students; every panel/handler that needs a\n * fresh list calls this instead of duplicating the load/try-catch.\n */\n private async refreshStudents(): Promise {\n const session = getJSON(STORAGE_KEYS.SESSION);\n if (!session) return;\n\n try {\n const storageService = getStorageService();\n this.students = await storageService.getStudentsByRelease(session.release);\n } catch (err) {\n console.error('Failed to load students:', err);\n this.students = [];\n }\n }\n\n /**\n * Unlock instructor panel (call after successful auth)\n */\n unlock(): void {\n this.unlocked = true;\n }\n\n /**\n * Lock instructor panel (call on logout)\n */\n lock(): void {\n this.unlocked = false;\n this.showScores = false;\n this.showPinReset = false;\n }\n\n private handleResetPins = async (): Promise => {\n // Load all students for current release before showing reset dialog\n await this.refreshStudents();\n this.showPinReset = true;\n };\n\n private handleClosePinReset = (): void => {\n this.showPinReset = false;\n };\n\n private handlePinReset = (): void => {\n // Forward event to parent\n this.dispatchEvent(\n new CustomEvent('qd:pin-reset', {\n bubbles: true,\n composed: true,\n }),\n );\n };\n\n private handleUnlock = (): void => {\n this.unlocked = true;\n // Forward event to parent\n this.dispatchEvent(\n new CustomEvent('qd:instructor-unlock', {\n bubbles: true,\n composed: true,\n }),\n );\n };\n\n private handleViewScores = async (): Promise => {\n // Load all students for current release before showing scores\n await this.refreshStudents();\n this.showScores = true;\n };\n\n private handleCloseScores = (): void => {\n this.showScores = false;\n };\n\n private handleDataCleared = (): void => {\n // Forward event to parent\n this.dispatchEvent(\n new CustomEvent('qd:data-cleared', {\n bubbles: true,\n composed: true,\n }),\n );\n // Refresh students list\n this.students = [];\n };\n\n private handleLogout = (): void => {\n const session = getJSON(STORAGE_KEYS.SESSION);\n\n // Clear session from storage (this will also emit qd:logout event)\n const sessionService = new SessionService();\n sessionService.clearSession();\n\n // Dispatch event for any additional listeners\n this.dispatchEvent(\n new CustomEvent('qd:logout', {\n detail: {\n serviceId: session?.serviceId || 'unknown',\n },\n bubbles: true,\n composed: true,\n }),\n );\n };\n\n private handleToggleStudentAnswers = async (e: Event): Promise => {\n const checkbox = e.target as HTMLInputElement;\n this.showStudentAnswers = checkbox.checked;\n\n // FR-004: Load student data in fresh session when toggle is enabled\n if (this.showStudentAnswers && this.students.length === 0) {\n await this.refreshStudents();\n }\n\n // Emit event to notify table enhancers\n const eventName = this.showStudentAnswers\n ? 'qd:instructor-show-answers'\n : 'qd:instructor-hide-answers';\n\n this.dispatchEvent(\n new CustomEvent(eventName, {\n bubbles: true,\n composed: true,\n }),\n );\n\n // Persist toggle state in sessionStorage\n sessionStorage.setItem(INSTRUCTOR_SHOW_ANSWERS_KEY, String(this.showStudentAnswers));\n };\n\n private handleHelpOpen = (): void => {\n this.helpOpen = true;\n };\n\n private handleHelpClose = (): void => {\n this.helpOpen = false;\n };\n\n override render() {\n if (!this.unlocked) {\n return html`\n \n `;\n }\n\n return html`\n
                                  \n
                                  \n Instructor Mode\n \n \n
                                  \n\n \n\n \n\n \n\n \n \n \n \n\n \n\n \n\n \n\n \n
                                  \n `;\n }\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n 'qd-instructor': QdInstructor;\n }\n}\n","/**\n * Component Injector\n * Injects UI components into the DOM during initialization\n */\n\nimport '../components/qd-login.js';\nimport '../components/qd-status.js';\nimport '../components/qd-instructor/qd-instructor.js';\nimport { info } from '../utils/logger.js';\n\n/**\n * Default container selectors for component injection\n */\nexport const DEFAULT_CONTAINERS = {\n /** Where to inject status panel (Oxygen WebHelp default) */\n statusPanel: '.wh_top_menu_and_indexterms_link',\n} as const;\n\n/**\n * Configuration for component injection\n */\nexport interface ComponentInjectorConfig {\n /** Selector for status panel container */\n statusPanelContainer?: string;\n /** Database name for storage service */\n dbName?: string;\n}\n\n/**\n * Inject login component into status panel container\n */\nexport function injectLoginComponent(containerSelector: string): HTMLElement | null {\n const container = document.querySelector(containerSelector);\n if (!container) {\n info(`Login component not injected: container '${containerSelector}' not found`);\n return null;\n }\n\n const login = document.createElement('qd-login');\n container.appendChild(login);\n info('Login component injected');\n return login;\n}\n\n/**\n * Inject status component into status panel container\n */\nexport function injectStatusComponent(containerSelector: string): HTMLElement | null {\n const container = document.querySelector(containerSelector);\n if (!container) {\n info(`Status component not injected: container '${containerSelector}' not found`);\n return null;\n }\n\n const status = document.createElement('qd-status');\n container.appendChild(status);\n info('Status component injected');\n return status;\n}\n\n/**\n * Inject instructor component (shown when instructor unlocked)\n */\nexport function injectInstructorComponent(containerSelector: string): HTMLElement | null {\n const container = document.querySelector(containerSelector);\n if (!container) {\n info(`Instructor component not injected: container '${containerSelector}' not found`);\n return null;\n }\n\n const instructor = document.createElement('qd-instructor');\n container.appendChild(instructor);\n info('Instructor component injected');\n return instructor;\n}\n\n/**\n * Inject all UI components based on configuration\n */\nexport function injectComponents(config: ComponentInjectorConfig = {}): void {\n const statusPanelContainer = config.statusPanelContainer || DEFAULT_CONTAINERS.statusPanel;\n\n // Always inject login component (handles showing/hiding based on session state)\n injectLoginComponent(statusPanelContainer);\n\n // Always inject status component (handles showing/hiding based on session state)\n injectStatusComponent(statusPanelContainer);\n\n // Always inject instructor component (hidden until unlocked)\n injectInstructorComponent(statusPanelContainer);\n\n markPanelHost(statusPanelContainer);\n}\n\n/** Class on the element that hosts the panels. */\nexport const PANEL_HOST_CLASS = 'qd-panel-host';\n/** Class on the host's parent, so narrow screens can give the host its own row. */\nexport const PANEL_HOST_PARENT_CLASS = 'qd-panel-host-parent';\n\n/**\n * Tag the panel container and its parent so the global stylesheet can move\n * the panels onto their own full-width header row on narrow screens. Oxygen's\n * header row does not wrap, so at 768 px the panel ran off the right edge and\n * hid Logout (SRD 013 UX-06).\n *\n * @param containerSelector - Selector for the panel container\n */\nfunction markPanelHost(containerSelector: string): void {\n const container = document.querySelector(containerSelector);\n if (!container) {\n return;\n }\n container.classList.add(PANEL_HOST_CLASS);\n container.parentElement?.classList.add(PANEL_HOST_PARENT_CLASS);\n}\n","/**\n * Home Page Badge Enhancer\n *\n * Applies R/A/G (Red/Amber/Green) badges to navigation links based on\n * page completion states. Updates badges in real-time when states change.\n *\n * Features:\n * - Queries links with class .quizPageBtn\n * - Reads completion state from SessionCache\n * - Applies CSS classes: qd-badge-red, qd-badge-amber, qd-badge-green\n * - Listens for qd:state-changed events for real-time updates\n * - Handles missing data gracefully\n *\n * Badge Colors:\n * - Red: Unstarted (no answers provided)\n * - Amber: Incomplete (some answered OR any incorrect)\n * - Green: Complete (all answered AND all correct)\n */\n\nimport type { PageId, SessionCache, CompletionState } from '../types/contracts.js';\nimport { getJSON } from '../utils/storage-helpers.js';\nimport { STORAGE_KEYS } from '../types/contracts.js';\nimport { isInstructor } from '../utils/session-state.js';\nimport { info } from '../utils/logger.js';\n\n/**\n * CSS class constants for badges\n */\nconst BADGE_CLASSES = {\n red: 'qd-badge-red',\n amber: 'qd-badge-amber',\n green: 'qd-badge-green',\n} as const;\n\n/**\n * Map completion states to badge colors\n */\nconst STATE_TO_BADGE: Record = {\n unstarted: 'red',\n incomplete: 'amber',\n complete: 'green',\n};\n\n/**\n * Remove all R/A/G badge classes from a link, reverting it to native styling.\n *\n * @param link - Link element to strip badge classes from\n */\nfunction clearBadges(link: HTMLElement): void {\n Object.values(BADGE_CLASSES).forEach((className) => {\n link.classList.remove(className);\n });\n}\n\n/**\n * Apply badge class to a link element\n *\n * @param link - Link element to apply badge to\n * @param state - Completion state\n */\nfunction applyBadge(link: HTMLElement, state: CompletionState): void {\n // Remove all existing badge classes\n clearBadges(link);\n\n // Apply new badge class based on state\n const badgeColor = STATE_TO_BADGE[state];\n const badgeClass = BADGE_CLASSES[badgeColor];\n link.classList.add(badgeClass);\n}\n\n/**\n * Get completion state for a page from session cache\n *\n * @param pageId - Page ID to look up\n * @param cache - Session cache\n * @returns Completion state (defaults to 'unstarted' if not found)\n */\nfunction getPageState(pageId: PageId | null, cache: SessionCache | null): CompletionState {\n if (!pageId || !cache?.pages) {\n return 'unstarted';\n }\n\n const pageData = cache.pages[pageId];\n return pageData?.state ?? 'unstarted';\n}\n\n/**\n * Update badge for a single link\n *\n * @param link - Link element with data-page-id attribute\n */\nfunction updateLinkBadge(link: HTMLElement): void {\n const pageId = link.getAttribute('data-page-id');\n const cache = getJSON(STORAGE_KEYS.CACHE);\n const state = getPageState(pageId, cache);\n\n applyBadge(link, state);\n}\n\n/**\n * Update all badges from current session cache\n * If no session exists, remove all badges\n */\nfunction updateAllBadges(): void {\n const links = document.querySelectorAll('.quizPageBtn');\n const cache = getJSON(STORAGE_KEYS.CACHE);\n const instructorActive = isInstructor();\n\n // If instructor mode OR no cache, remove all badge styling\n if (!cache || instructorActive) {\n links.forEach((link) => {\n clearBadges(link);\n });\n if (instructorActive) {\n info(`Removed badge styling from ${links.length} page links (instructor mode)`);\n } else {\n info(`Removed badge styling from ${links.length} page links (no session)`);\n }\n return;\n }\n\n // Cache exists and not instructor, apply badges based on state\n links.forEach((link) => {\n updateLinkBadge(link);\n });\n\n info(`Updated ${links.length} page badges`);\n}\n\n/**\n * Handle qd:state-changed event\n *\n * @param event - Custom event with pageId and state\n */\nfunction handleStateChanged(event: Event): void {\n const customEvent = event as CustomEvent<{ pageId: PageId; state: CompletionState }>;\n const { pageId } = customEvent.detail;\n\n // Find link with matching pageId\n const link = document.querySelector(`[data-page-id=\"${pageId}\"]`);\n\n if (link && link.classList.contains('quizPageBtn')) {\n updateLinkBadge(link);\n info(`Updated badge for page ${pageId}`);\n }\n}\n\n/**\n * Handle qd:cache-rebuild event - refresh all badges after cache is ready\n */\nfunction handleCacheRebuild(): void {\n info('Cache rebuilt, refreshing all badges');\n updateAllBadges();\n}\n\n/**\n * Handle qd:logout event - remove all badge styling\n */\nfunction handleLogout(): void {\n info('Logout detected, removing all badge styling');\n const links = document.querySelectorAll('.quizPageBtn');\n\n links.forEach((link) => {\n // Remove all badge classes to revert to native button styling\n clearBadges(link);\n });\n\n info(`Removed badge styling from ${links.length} page links`);\n}\n\n/**\n * Extract pageId from link href attribute\n *\n * @param link - Link element with href\n * @returns PageId extracted from href, or null if invalid\n *\n * @example\n * href=\"Pages/quiz-mcq.html\" → \"quiz-mcq\"\n * href=\"gram-1.html\" → \"gram-1\"\n */\nfunction extractPageIdFromHref(link: HTMLAnchorElement): PageId | null {\n const href = link.getAttribute('href');\n if (!href) {\n return null;\n }\n\n // Extract filename from href (last segment after /)\n const filename = href.substring(href.lastIndexOf('/') + 1);\n\n // Remove .html or .htm extension\n const pageId = filename.replace(/\\.html?$/i, '');\n\n return pageId || null;\n}\n\n/**\n * Enhance home page with R/A/G badges on navigation links\n *\n * This function:\n * 1. Queries all links with class .quizPageBtn\n * 2. Extracts pageId from href attribute and sets data-page-id\n * 3. Reads SessionCache to determine page completion states\n * 4. Applies appropriate badge CSS classes\n * 5. Sets up event listener for real-time updates\n *\n * @example\n * ```html\n * MCQ Questions\n * ```\n *\n * After enhancement:\n * - data-page-id attribute set: data-page-id=\"quiz-mcq\"\n * - Unstarted pages: class=\"quizPageBtn qd-badge-red\"\n * - Incomplete pages: class=\"quizPageBtn qd-badge-amber\"\n * - Complete pages: class=\"quizPageBtn qd-badge-green\"\n */\nexport function enhanceHomeBadges(): void {\n // Find all navigation links\n const links = document.querySelectorAll('.quizPageBtn');\n\n // Extract pageId from href and set data-page-id attribute\n links.forEach((link) => {\n const pageId = extractPageIdFromHref(link);\n if (pageId) {\n link.setAttribute('data-page-id', pageId);\n info(`Set data-page-id=\"${pageId}\" for link: ${link.textContent?.trim()}`);\n } else {\n info(`Failed to extract pageId from href: ${link.getAttribute('href')}`);\n }\n });\n\n // Apply initial badges\n updateAllBadges();\n\n // Listen for state changes and update badges in real-time\n document.addEventListener('qd:state-changed', handleStateChanged);\n\n // Listen for cache rebuild (after login) to refresh badges\n document.addEventListener('qd:cache-rebuild', handleCacheRebuild);\n\n // Listen for logout events to reset badges\n document.addEventListener('qd:logout', handleLogout);\n\n info('Home page badges enhanced with event listeners');\n}\n","/**\n * Bootstrap Module\n * Main initialization logic for the Sonar Quiz System\n */\n\nimport { info, warn } from '../utils/logger.js';\nimport { EventCoordinator } from './event-coordinator.js';\nimport { SessionCoordinator } from './session-coordinator.js';\nimport { injectComponents, type ComponentInjectorConfig } from './component-injector.js';\nimport { enhanceQuizTable, getQuizTableMetadata } from '../enhancers/quiz-table.js';\nimport { revealInstructorAnswers } from '../enhancers/instructor-answer-reveal.js';\nimport { enhanceAnalysisTable } from '../enhancers/analysis-table.js';\nimport { enhanceHomeBadges } from '../enhancers/home-badges.js';\nimport { getStorageService } from '../services/storage-service.js';\nimport { getJSON, setJSON } from '../utils/storage-helpers.js';\nimport { getPageIdFromUrl } from '../utils/page-id.js';\nimport { injectGlobalStyles } from './global-styles.js';\nimport { STORAGE_KEYS, type SessionData, type SessionCache } from '../types/contracts.js';\nimport { isInstructor } from '../utils/session-state.js';\nimport { restoreInstructorSession } from '../services/auth/instructor-session.js';\n\n/**\n * Bootstrap configuration options\n */\nexport interface BootstrapConfig extends ComponentInjectorConfig {\n /** Auto-enhance quiz tables on init */\n autoEnhanceQuizTables?: boolean;\n /** Auto-enhance analysis tables on init */\n autoEnhanceAnalysisTables?: boolean;\n /** Auto-enhance home page badges on init */\n autoEnhanceHomeBadges?: boolean;\n}\n\n/**\n * Bootstrap state\n */\ninterface BootstrapState {\n initialized: boolean;\n eventCoordinator?: EventCoordinator;\n sessionCoordinator?: SessionCoordinator;\n}\n\nconst state: BootstrapState = {\n initialized: false,\n};\n\n/**\n * Initialize the Sonar Quiz System\n *\n * @param config - Bootstrap configuration\n */\nexport async function bootstrap(config: BootstrapConfig = {}): Promise {\n if (state.initialized) {\n warn('Bootstrap already initialized, skipping');\n return;\n }\n\n info('Bootstrapping Sonar Quiz System...');\n\n // 0. Inject required global styles\n injectGlobalStyles();\n\n // 1. Initialize storage service (IndexedDB)\n // dbName is REQUIRED - readDOMConfig() throws if missing\n if (!config.dbName) {\n const msg = 'FATAL: dbName not provided in bootstrap config. Processing stopped.';\n console.error(msg);\n throw new Error(msg);\n }\n const storageService = getStorageService(config.dbName);\n await storageService.init();\n\n // 1b. Re-verify any stored instructor secret before components or enhancers\n // ask whether this is an instructor (SRD 013 SEC-01, SEC-02)\n await restoreInstructorSession();\n\n // 2. Initialize event coordinator\n const eventCoordinator = new EventCoordinator();\n eventCoordinator.initialize();\n state.eventCoordinator = eventCoordinator;\n\n // 3. Initialize session coordinator\n const sessionCoordinator = new SessionCoordinator();\n sessionCoordinator.initialize();\n state.sessionCoordinator = sessionCoordinator;\n\n // 4. Inject UI components\n injectComponents({\n statusPanelContainer: config.statusPanelContainer,\n dbName: config.dbName,\n });\n\n // 5. Auto-enhance tables if enabled\n if (config.autoEnhanceQuizTables !== false) {\n enhanceAllTables('table.qd-quiz', 'quiz', (table) =>\n enhanceQuizTable(table, { interactive: false }),\n );\n }\n\n if (config.autoEnhanceAnalysisTables !== false) {\n enhanceAllTables('table.qd-analysis', 'analysis', (table) =>\n enhanceAnalysisTable(table, { interactive: false }),\n );\n }\n\n if (config.autoEnhanceHomeBadges !== false) {\n enhanceHomeBadgesIfPresent();\n }\n\n // 6. Check for existing session and upgrade tables if logged in\n await checkExistingSessionAndUpgradeTables();\n\n // 7. Listen for instructor login events to dynamically reveal answers\n // qd:login with role='instructor' is dispatched by qd-login component\n document.addEventListener('qd:login', (event) => {\n const detail = (event as CustomEvent<{ role?: string }>).detail;\n if (detail?.role === 'instructor') {\n info('Instructor login event received, revealing quiz answers');\n revealQuizAnswersForInstructor();\n }\n });\n\n state.initialized = true;\n info('Bootstrap complete');\n}\n\n/**\n * Enhance all tables matching a selector in non-interactive mode.\n *\n * Shared by quiz and analysis enhancement (they differ only in selector,\n * label, and enhancer). Tables start non-interactive (answers hidden /\n * read-only) and are upgraded to interactive after login.\n *\n * @param selector - CSS selector for the tables\n * @param label - Human-readable label for logging\n * @param enhance - Per-table non-interactive enhancer\n */\nfunction enhanceAllTables(\n selector: string,\n label: string,\n enhance: (table: HTMLTableElement) => void,\n): void {\n const tables = document.querySelectorAll(selector);\n\n if (tables.length === 0) {\n info(`No ${label} tables found to enhance`);\n return;\n }\n\n info(`Enhancing ${tables.length} ${label} table(s) in non-interactive mode...`);\n\n let enhanced = 0;\n for (const table of Array.from(tables)) {\n try {\n enhance(table);\n enhanced++;\n } catch (err) {\n warn(`Failed to enhance ${label} table: ${(err as Error).message}`);\n }\n }\n\n info(`Enhanced ${enhanced} of ${tables.length} ${label} table(s) (non-interactive)`);\n}\n\n/**\n * Enhance home page badges if .quizPageBtn links exist\n */\nfunction enhanceHomeBadgesIfPresent(): void {\n const links = document.querySelectorAll('.quizPageBtn');\n\n if (links.length === 0) {\n info('No .quizPageBtn links found, skipping badge enhancement');\n return;\n }\n\n info(`Enhancing home page badges for ${links.length} link(s)...`);\n\n try {\n enhanceHomeBadges();\n info('Home page badges enhanced');\n } catch (err) {\n warn(`Failed to enhance home badges: ${(err as Error).message}`);\n }\n}\n\n/**\n * Reveal quiz answers for instructor mode\n * Called when instructor logs in (either on page load or dynamically via event)\n * Shows answer and detail columns that were hidden for security\n */\nfunction revealQuizAnswersForInstructor(): void {\n const pageId = getPageIdFromUrl();\n\n // Reveal answer and detail columns for instructor (they're hidden by default in non-interactive mode)\n const quizTables = document.querySelectorAll('table.qd-quiz');\n\n if (quizTables.length === 0) {\n info('No quiz tables found to reveal answers for');\n return;\n }\n\n quizTables.forEach((table) => {\n // Get parsed metadata (contains correct answers)\n const metadata = getQuizTableMetadata(table);\n if (!metadata) return;\n\n // Update metadata with pageId\n metadata.pageId = pageId;\n\n // Reveal answers + wire instructor toggles via the shared enhancer.\n // The initial-load path adds the qd-quiz-instructor CSS visibility class.\n revealInstructorAnswers(table, metadata, { addInstructorClass: true });\n });\n\n info(`Revealed answers for instructor on ${quizTables.length} quiz table(s)`);\n}\n\n/**\n * Check for existing session and upgrade tables to interactive mode\n * Called during bootstrap to handle page navigation with active session\n */\nasync function checkExistingSessionAndUpgradeTables(): Promise {\n // Check if instructor mode FIRST - instructors don't need student session\n const instructorActive = isInstructor();\n if (instructorActive) {\n info('Instructor session detected, revealing answers in non-interactive tables');\n revealQuizAnswersForInstructor();\n return;\n }\n\n // Check if student session exists\n const session = getJSON(STORAGE_KEYS.SESSION);\n if (!session) {\n info('No existing session, tables remain in non-interactive mode');\n return;\n }\n\n info(`Existing session detected for ${session.serviceId}, upgrading tables to interactive mode`);\n\n // Load or rebuild cache from IndexedDB\n const storageService = getStorageService();\n let cache = getJSON(STORAGE_KEYS.CACHE);\n\n if (!cache) {\n info('Cache not found, rebuilding from IndexedDB...');\n try {\n const studentRecord = await storageService.loadStudentRecord(session);\n cache = storageService.buildCache(studentRecord);\n setJSON(STORAGE_KEYS.CACHE, cache);\n info(`Cache rebuilt from IndexedDB: ${cache.totals.total} total questions`);\n } catch {\n warn('Failed to rebuild cache from IndexedDB, using empty cache');\n cache = {\n totals: { total: 0, answered: 0, correct: 0 },\n pages: {},\n };\n setJSON(STORAGE_KEYS.CACHE, cache);\n }\n }\n\n const pageId = getPageIdFromUrl();\n\n if (!pageId) {\n info('No pageId found, skipping table upgrade');\n return;\n }\n\n // Upgrade quiz tables to interactive mode\n const quizTables = document.querySelectorAll('table.qd-quiz');\n if (quizTables.length > 0) {\n info(`Upgrading ${quizTables.length} quiz table(s) to interactive mode...`);\n quizTables.forEach((table) => {\n enhanceQuizTable(table, { interactive: true, pageId });\n });\n }\n\n // Upgrade analysis tables to interactive mode\n const analysisTables = document.querySelectorAll('table.qd-analysis');\n if (analysisTables.length > 0) {\n info(`Upgrading ${analysisTables.length} analysis table(s) to interactive mode...`);\n analysisTables.forEach((table) => {\n enhanceAnalysisTable(table, { interactive: true, pageId });\n });\n }\n}\n\n/**\n * Cleanup bootstrap resources\n */\nexport function cleanup(): void {\n if (!state.initialized) {\n warn('Bootstrap not initialized, nothing to cleanup');\n return;\n }\n\n info('Cleaning up bootstrap resources...');\n\n state.eventCoordinator?.cleanup();\n state.sessionCoordinator?.cleanup();\n\n state.initialized = false;\n state.eventCoordinator = undefined;\n state.sessionCoordinator = undefined;\n\n info('Bootstrap cleanup complete');\n}\n\n/**\n * Check if bootstrap is initialized\n */\nexport function isInitialized(): boolean {\n return state.initialized;\n}\n\n/**\n * Get the event coordinator instance\n */\nexport function getEventCoordinator(): EventCoordinator | undefined {\n return state.eventCoordinator;\n}\n\n/**\n * Get the session coordinator instance\n */\nexport function getSessionCoordinator(): SessionCoordinator | undefined {\n return state.sessionCoordinator;\n}\n","/**\n * Global style injection.\n *\n * Owns the single global CSS literal required by the quiz system (the only\n * non-Shadow-DOM CSS) and injects it once. Extracted from `bootstrap.ts` so the\n * bootstrap module stays a thin sequencer.\n */\n\nimport { info } from '../utils/logger.js';\n\n/** Global CSS injected once into . */\nconst GLOBAL_CSS = `\n /* Sonar Quiz System - Global Styles */\n .qd-hidden {\n display: none !important;\n }\n\n /* Quiz table interactive mode styles */\n .qd-quiz-interactive .qd-quiz-input {\n width: 100%;\n padding: 0.5rem;\n font-size: inherit;\n border: 1px solid #ccc;\n border-radius: 4px;\n }\n\n /* Ensure select elements inherit font properly */\n .qd-quiz-interactive select.qd-quiz-input {\n font-family: inherit;\n font-size: inherit;\n }\n\n /* Visually hidden text that screen readers still announce (SRD 013 A11Y-02) */\n .qd-sr-only {\n position: absolute !important;\n width: 1px;\n height: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n white-space: nowrap;\n border: 0;\n }\n\n /* Validation styling for answer cells */\n .qd-quiz-interactive .qd-answer-correct {\n background-color: #d4edda !important;\n border-color: #28a745 !important;\n }\n\n .qd-quiz-interactive .qd-answer-incorrect {\n background-color: #f8d7da !important;\n border-color: #dc3545 !important;\n }\n\n /* Non-colour correctness cue: a mark plus hidden text (SRD 013 A11Y-02) */\n .qd-answer-feedback {\n display: inline-block;\n margin-top: 4px;\n font-size: 0.9em;\n font-weight: 600;\n }\n\n .qd-answer-correct .qd-answer-feedback {\n color: #1e7b34;\n }\n\n .qd-answer-incorrect .qd-answer-feedback {\n color: #b02a37;\n }\n\n /* Invalid numeric input message (SRD 013 UX-03) */\n .qd-input-error {\n display: block;\n margin-top: 4px;\n font-size: 0.85em;\n color: #b02a37;\n }\n\n .qd-quiz-interactive .qd-quiz-input[aria-invalid='true'] {\n border-color: #b02a37;\n outline-color: #b02a37;\n }\n\n /* Prompt shown above quiz tables while nobody is logged in (SRD 013 UX-05) */\n .qd-login-notice {\n margin: 0 0 8px;\n padding: 6px 10px;\n border-left: 3px solid #1d6fb8;\n background: #e8f1fb;\n color: #0b4f8a;\n font-size: 0.9em;\n }\n\n /*\n * Narrow screens: give the header's panel container its own full-width row\n * so the login and status panels stay on screen (SRD 013 UX-06)\n */\n /* !important: Oxygen's c-no-wrap and Bootstrap's navbar-collapse rules\n outrank a single class; these only touch elements we marked */\n @media (max-width: 1000px) {\n .qd-panel-host-parent {\n flex-wrap: wrap !important;\n }\n\n .qd-panel-host-parent > .qd-panel-host {\n flex: 1 1 100% !important;\n max-width: 100% !important;\n justify-content: flex-end;\n }\n\n .qd-panel-host qd-login,\n .qd-panel-host qd-status,\n .qd-panel-host qd-instructor {\n max-width: 100%;\n }\n }\n\n /* Home page badge styles (R/A/G indicators) */\n .qd-badge-red {\n border-left: 4px solid #d32f2f !important;\n background-color: #ffebee !important;\n }\n\n .qd-badge-amber {\n border-left: 4px solid #ff9800 !important;\n background-color: #fff3e0 !important;\n }\n\n .qd-badge-green {\n border-left: 4px solid #4caf50 !important;\n background-color: #e8f5e9 !important;\n }\n\n /* Instructor-mode student answers/entries are now rendered by the\n Shadow-DOM / components, which\n own their styles. No global rules are needed here. */\n\n /* Modal error message styles (needed because qd-modal moves to body) */\n .error-message {\n color: #d32f2f;\n font-size: 12px;\n padding: 8px;\n background: #ffebee;\n border-radius: 4px;\n border-left: 3px solid #d32f2f;\n }\n `;\n\n/**\n * Inject the global CSS once. Must be called before any table enhancement.\n */\nexport function injectGlobalStyles(): void {\n // Check if styles already injected\n if (document.getElementById('qd-global-styles')) {\n return;\n }\n\n const style = document.createElement('style');\n style.id = 'qd-global-styles';\n style.textContent = GLOBAL_CSS;\n\n document.head.appendChild(style);\n info('Global styles injected');\n}\n","/**\n * Sonar Quiz System - Entry Point\n *\n * Offline-first interactive quiz and analysis platform for DITA-published content.\n *\n * @packageDocumentation\n */\n\nimport { bootstrap } from './init/bootstrap.js';\nimport { info } from './utils/logger.js';\nimport { readDOMConfig } from './config/dom-config-reader.js';\n\n// Export quiz table enhancer (Phase 2.1)\nexport {\n enhanceQuizTable,\n getQuizTableMetadata,\n isQuizTableEnhanced,\n} from './enhancers/quiz-table.js';\nexport type { EnhanceQuizTableOptions } from './enhancers/quiz-table.js';\n\n// Export analysis table enhancer (Phase 2.2)\nexport {\n enhanceAnalysisTable,\n getAnalysisTableMetadata,\n isAnalysisTableEnhanced,\n} from './enhancers/analysis-table.js';\nexport type { EnhanceAnalysisTableOptions } from './enhancers/analysis-table.js';\n\n// Export types\nexport type {\n ParsedQuizTable,\n QuizQuestion,\n AnswerRecord,\n CompletionState,\n PageId,\n SessionData,\n SessionCache,\n StudentRecord,\n PageData,\n ReleaseId,\n ServiceId,\n TableId,\n CellKey,\n QuestionKind,\n} from './types/contracts.js';\n\n// Export constants\nexport { STORAGE_KEYS, SCHEMA_VERSION, SESSION_TIMEOUT_MS } from './types/contracts.js';\n\n// Export services\nexport { parseQuizTable, validateAnswer } from './services/quiz-parser.js';\nexport {\n parseAnalysisTable,\n generateTableId,\n generateCellKey,\n isCellEditable,\n} from './services/analysis-parser.js';\nexport { calculateCompletionState } from './services/state-calculator.js';\n\n// Export utilities\nexport { Debouncer } from './utils/debouncer.js';\nexport { getJSON, setJSON, clearQuizData } from './utils/storage-helpers.js';\nexport { info, warn, error, setDebugMode } from './utils/logger.js';\n\n// Export obfuscation utilities for console access\nexport { migrateObfuscation } from './services/storage/obfuscation-migration.js';\nexport type {\n ObfuscationMigrationResult,\n ObfuscationMigrationDirection,\n ObfuscationMigrationOptions,\n} from './services/storage/obfuscation-migration.js';\nexport {\n deriveKey,\n encode,\n decode,\n isObfuscated,\n OBFUSCATION_PREFIX,\n} from './services/storage/obfuscation.js';\nexport type { ObfuscatedString } from './services/storage/obfuscation.js';\n\n// Export bootstrap (Phase 3)\nexport { bootstrap, cleanup, isInitialized } from './init/bootstrap.js';\nexport type { BootstrapConfig } from './init/bootstrap.js';\n\n// Export component injector\nexport { injectComponents, DEFAULT_CONTAINERS } from './init/component-injector.js';\nexport type { ComponentInjectorConfig } from './init/component-injector.js';\n\n/**\n * Version information\n */\nexport const VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'development';\nexport const BUILD_DATE = typeof __BUILD_DATE__ !== 'undefined' ? __BUILD_DATE__ : 'development';\n\n// Declared globals injected by Vite `define` at build time (see vite.config.ts).\n// VERSION comes from package.json so it cannot drift from the release tag.\ndeclare const __BUILD_DATE__: string;\ndeclare const __APP_VERSION__: string;\n\n/**\n * Auto-initialize on DOMContentLoaded\n *\n * System always initializes when script loads. Configuration is read from\n * hidden DOM elements injected by DITA publishing (see dom-config-reader.ts).\n */\nif (typeof window !== 'undefined') {\n const init = () => {\n info('Auto-initializing Sonar Quiz System');\n\n // Read configuration from hidden DOM elements\n const domConfig = readDOMConfig();\n\n // Bootstrap with DOM config\n bootstrap({\n dbName: domConfig.dbName,\n statusPanelContainer: domConfig.statusPanelContainer,\n autoEnhanceQuizTables: true,\n autoEnhanceAnalysisTables: true,\n autoEnhanceHomeBadges: true,\n }).catch((err) => {\n console.error('[FATAL] Bootstrap failed:', err);\n });\n };\n\n // Initialize when DOM is ready\n if (document.readyState === 'loading') {\n document.addEventListener('DOMContentLoaded', () => void init());\n } else {\n // DOM already loaded\n void init();\n }\n}\n"],"names":["debugEnabled","maskServiceId","serviceId","length","slice","repeat","sanitize","obj","sanitized","key","value","Object","entries","info","message","data","console","log","error","Error","errorObj","name","stack","warn","DEFAULT_CONFIG","CONFIG_IDS","readQuestionTotal","text","document","getElementById","textContent","trim","test","total","Number","readConfigElement","elementId","defaultValue","element","querySelector","readDOMConfig","dbName","msg","readRequiredConfigElement","config","statusPanelContainer","titleSelector","instructorHash","readDbName","readRelease","titleElement","ANSWER_ALPHABET","ENCODED_PREFIX","isEncodedAnswer","startsWith","isValidAnswerKey","Set","size","readAnswerKey","AnswerDecodeError","decodeAnswer","plain","ch","index","indexOf","parseQuizTable","table","answerKey","errors","questions","classList","contains","push","rows","Array","from","querySelectorAll","forEach","row","cells","questionCell","answerCell","detailCell","questionText","correctAnswer","err","olElement","options","ol","map","li","filter","kind","toleranceText","tolerance","parseFloat","isNaN","validateAnswer","question","answer","trimmedAnswer","userValue","correctValue","Math","abs","buildPageCache","_pageId","pageData","answers","answered","a","correct","success","state","last","lastAttempted","analysis","Debouncer","constructor","this","timers","Map","debounce","fn","delay","existing","get","clearTimeout","timer","setTimeout","delete","set","cancel","cancelAll","count","values","clear","isPending","has","getPendingCount","getTableRows","tbody","getRowCells","getTextContent","createElement","tag","className","addClass","classNames","add","removeClass","remove","escapeHtml","replace","INSTRUCTOR_SHOW_ANSWERS_KEY","getJSON","sessionStorage","getItem","JSON","parse","setJSON","json","stringify","setItem","clearQuizData","keysToRemove","i","removeItem","SESSION_TIMEOUT_MS","STORAGE_KEYS","SESSION","CACHE","INSTRUCTOR","PIN_ATTEMPTS","SESSION_EXPIRED","PIN_CONSTANTS","RateLimiter","freeFailures","failureCount","lockoutUntil","attempt","Date","now","recordFailure","delays","min","reset","getRemainingSeconds","remaining","max","ceil","isLockedOut","getExpectedInstructorHash","hashElement","async","verifyInstructorPassword","expectedHash","toLowerCase","fullHash","TextEncoder","encode","hashBuffer","crypto","subtle","digest","Uint8Array","b","toString","padStart","join","hashPasswordFull","encoder","aBuffer","bBuffer","importKey","hash","signature","sign","expectedKey","expectedSignature","byteLength","sigView","expView","result","constantTimeCompare","substring","verified","reauthLimiter","confirmInstructorPassword","password","ok","reason","retryInSeconds","reauthErrorMessage","startInstructorSession","hasActiveSession","isInstructor","detailCellContent","WeakMap","hideAnswerColumn","headerCells","hideDetailColumn","cell","innerHTML","authorAnswerHeader","loginNotices","setLoginNotice","visible","notice","setAttribute","anchor","parentElement","isConnected","parentNode","insertBefore","createQuestionInput","existingAnswer","spec","optionText","String","type","placeholder","getQuestionInputSpec","select","placeholderOption","disabled","appendChild","opt","option","input","getStorageKey","release","StorageError","operation","cause","super","logError","StorageNotInitializedError","StorageQuotaError","StorageFormatError","expected","found","storageKey","STORE_STUDENTS","STORE_BACKUPS","STORE_AUDIT_LOG","openDatabase","Promise","resolve","reject","timeoutId","resolved","cleanup","window","logWarn","deleteReq","indexedDB","deleteDatabase","onsuccess","then","catch","onerror","onblocked","request","open","db","objectStoreNames","isCorrupted","close","deleteRequest","onupgradeneeded","event","target","transaction","onabort","studentsStore","createObjectStore","keyPath","createIndex","unique","backupsStore","auditStore","createSchema","promisifyRequest","op","toStorageError","runTransaction","store","mode","objectStore","OBFUSCATION_PREFIX","deriveKey","releaseId","split","c","charCodeAt","stringToUtf8Bytes","str","xorBytes","dataByte","keyByte","base64","bytes","binary","byte","fromCharCode","btoa","bytesToBase64","decode","encoded","xoredBytes","atob","base64ToBytes","jsonBytes","TextDecoder","isObfuscated","IndexedDBStorageAdapter","initPromise","init","finally","ensureInitialized","getStudent","rawValue","storedIsObfuscated","obfKey","decodeStoredValue","saveStudent","record","valueToStore","encodeForStore","put","getStudentsByRelease","getStudentsByReleaseEncrypted","openCursor","results","cursor","tryDecodeCursorValue","continue","clearAll","all","backup","timestamp","toISOString","backupKey","originalKey","backupRecord","createBackup","saveAuditEvent","persistAuditEvent","storageInstance","currentDbName","getStorageAdapter","calculateCompletionState","totalQuestions","isPageUnstarted","every","isPageComplete","countFilledCells","calculatePageState","page","filled","quizComplete","analysisComplete","totalCells","createEmptyStudentRecord","session","schema","docId","attempted","updated","pages","StorageService","adapter","loadStudentRecord","newRecord","saveStudentRecord","totals","pageId","isArray","recalculateTotalsFromPages","updateRecordWithAnswer","questionIndex","previous","next","commit","attempts","priorAttempts","firstSuccess","withAttempts","firstAttempted","updateRecordWithAnalysis","tableId","cellKey","content","details","analysisData","label","labels","firstEdited","lastEdited","buildCache","cache","pageCache","buildCacheFromRecord","refreshCacheOnLogin","storageServiceInstance","currentServiceDbName","getStorageService","emitCustomEvent","detail","CustomEvent","bubbles","composed","cancelable","dispatchEvent","dispatchEventOn","persistAndNotify","storageService","onSavedDom","emit","events","SR_ONLY_CLASS","LIVE_REGION_ID","idCounter","ensureId","el","prefix","id","handleAnswerInput","metadata","debouncer","parsed","inputs","answerRecord","studentRecord","updatedRecord","applyValidationStyling","region","body","announce","saveAnswer","feedback","replaceChildren","mark","append","control","showInputError","insertAdjacentElement","clearInputError","removeAttribute","validateStudentFields","pin","isNumericAnswer","NUMERIC_INPUT_ERROR","formatStoredTimestamp","isoString","date","format","dateObj","getTime","formatCSVTimestamp","toLocaleDateString","month","getDate","getHours","getMinutes","formatDisplayTimestamp","formatTimestamp","t","globalThis","e","ShadowRoot","ShadyCSS","nativeShadow","Document","prototype","CSSStyleSheet","s","Symbol","o","n$3","_$cssResult$","cssText","styleSheet","replaceSync","reduce","n","cssRules","r","is","defineProperty","getOwnPropertyDescriptor","h","getOwnPropertyNames","getOwnPropertySymbols","getPrototypeOf","trustedTypes","l","emptyScript","p","reactiveElementPolyfillSupport","d","u","toAttribute","Boolean","fromAttribute","f","attribute","converter","reflect","useDefault","hasChanged","litPropertyMetadata","HTMLElement","addInitializer","_$Ei","observedAttributes","finalize","_$Eh","keys","createProperty","hasOwnProperty","create","wrapped","elementProperties","noAccessor","getPropertyDescriptor","call","requestUpdate","configurable","enumerable","getPropertyOptions","finalized","properties","_$Eu","elementStyles","finalizeStyles","styles","flat","reverse","unshift","_$Ep","isUpdatePending","hasUpdated","_$Em","_$Ev","_$ES","enableUpdating","_$AL","_$E_","addController","_$EO","renderRoot","hostConnected","removeController","createRenderRoot","shadowRoot","attachShadow","shadowRootOptions","adoptedStyleSheets","litNonce","connectedCallback","disconnectedCallback","hostDisconnected","attributeChangedCallback","_$AK","_$ET","_$Ej","hasAttribute","C","_$EP","_$Eq","scheduleUpdate","performUpdate","shouldUpdate","willUpdate","hostUpdate","update","_$EM","_$AE","hostUpdated","firstUpdated","updateComplete","getUpdateComplete","y","ReactiveElement","reactiveElementVersions","createPolicy","createHTML","random","toFixed","createComment","v","_","m","RegExp","g","$","x","_$litType$","strings","T","for","E","A","createTreeWalker","P","N","parts","lastIndex","exec","V","currentNode","firstChild","replaceWith","childNodes","nextNode","nodeType","hasAttributes","getAttributeNames","endsWith","getAttribute","ctor","H","I","L","k","tagName","S","_$Co","_$Cl","_$litDirective$","_$AO","_$AT","_$AS","M","_$AV","_$AN","_$AD","_$AM","_$AU","creationScope","importNode","R","nextSibling","z","_$AI","_$Cv","_$AH","_$AA","_$AB","startNode","endNode","_$AR","iterator","O","createTextNode","_$AC","_$AP","setConnected","fill","j","arguments","toggleAttribute","capture","once","passive","removeEventListener","addEventListener","handleEvent","host","litHtmlPolyfillSupport","litHtmlVersions","B","renderBefore","_$litPart$","renderOptions","_$Do","render","_$litElement$","litElementHydrateSupport","LitElement","litElementPolyfillSupport","litElementVersions","customElements","define","Reflect","decorate","QdStudentAnswers","sa","html","cssClass","maskedServiceId","formattedTimestamp","showStudentAnswersForTable","students","alert","_question","existingDisplay","studentAnswers","student","formatStudentAnswersForDisplay","display","hideStudentAnswersForTable","css","__decorateClass","property","customElement","tableMetadata","enhanceQuizTable","interactive","some","tableNeedsMissingKey","showAnswerColumn","header","setStudentAnswerHeader","existingPage","delta","updatedPage","registerPageQuestions","existingAnswers","HTMLSelectElement","inputMode","hasNonNumericCharacters","showAnswersHandler","hideAnswersHandler","instructorActive","showAnswers","resetUIState","clearValidationFeedback","selectedIndex","HTMLInputElement","logoutHandler","loginHandler","cleanupInstructorListeners","enhanceInteractive","colgroup","removeColgroup","enhanceNonInteractive","getQuizTableMetadata","hashString","hexHash","generateTableId","firstRow","cols","generateCellKey","col","isCellEditable","parseAnalysisTable","editableCells","rowIndex","colIndex","handleCellEdit","labelId","cellLabel","saveCellData","QdStudentEntries","sort","dateA","entry","last4","showStudentEntriesForTable","bodyPageId","dataset","path","location","pathname","pop","getCurrentPageId","grouped","groupEntriesByCell","rowElement","displayElement","createStudentEntriesDisplay","hideStudentEntriesForTable","enhanceAnalysisTable","cellKeyMap","existingAnalysis","existingCells","contentEditable","candidate","findCellLabel","showHandler","hideHandler","revealInstructorAnswers","addInstructorClass","HTMLTableCellElement","original","restoreDetailColumn","restoreAnswerHeader","getPageIdFromUrl","url","lastIndexOf","upgradeTablesAfterLogin","quizTables","revealTablesForInstructor","analysisTables","EventCoordinator","listeners","initialize","registerLoginHandlers","registerLogoutHandlers","registerAnswerHandlers","registerStateHandlers","registerInstructorHandlers","registerDataHandlers","resetQuizTableToNonInteractive","resetAnalysisTableToNonInteractive","unlockTime","eventName","handler","handlers","SessionService","createSession","loginTime","lastActivity","expiresAt","instructorUnlocked","saveSession","emitEvent","getSession","sessionData","updateActivity","isExpired","expiryDate","isSessionExpired","clearSession","unlockInstructor","lockInstructor","isInstructorUnlocked","getCache","cacheData","saveCache","clearCache","SessionCoordinator","activityTrackingActive","handleLogin","sessionService","scheduleExpiryCheck","setupActivityTracking","expireSession","expiryTimeoutId","timeUntilExpiry","activityHandler","updatedSession","activityDebounceTimeout","debouncedHandler","getSessionService","loginStyles","needsMigration","hasPinSet","pinHash","toHex","fromHex","hex","out","ArrayBuffer","parseInt","derivePinHash","saltHex","iterations","bits","deriveBits","salt","createPinCredential","getRandomValues","pinSalt","pinIterations","pinKdf","verifyStoredPin","stored","valid","storedHash","inputHash","hashPin","verifyPin","upgrade","getAttemptKey","getAttemptState","checkLockout","isLocked","remainingMs","lockoutTime","clearAttemptState","getRemainingAttempts","AuthService","loginStudent","runLogin","checkLock","surfaceMigration","errorMessage","errorLabel","retryAfterMigration","isRegistered","storage","opts","lockout","lockoutMs","existingStudent","updatedStudent","credential","pinCreatedAt","completePinSetup","check","lastAttempt","recordFailedAttempt","upgradePinCredential","newStudent","QdBuildInfo","MODAL_STATE_KEY","getCurrentModal","setCurrentModal","modal","QdModal","closable","previouslyFocused","originalParent","originalNextSibling","isInBody","handleKeyDown","emitCloseEvent","handleBackdropClick","handleCloseClick","stopPropagation","changedProperties","handleOpen","handleClose","moveToBody","restorePosition","show","currentModal","activeElement","requestAnimationFrame","focusFirstElement","focus","slot","assignedElements","flatten","focusable","matches","closeBtn","QdPasswordModal","title","handleModalClose","handleInput","handleSubmit","preventDefault","handleCancel","changedProps","passwordInput","nothing","query","QdInstructorLogin","showModal","rateLimiter","login","role","QdLockoutBanner","untilMs","seconds","interval","clearTimer","changed","startTimer","setInterval","clearInterval","QdHelpTrigger","panelType","handleClick","QdHelpPopup","portalElement","_isOpen","ensureStyles","removePortal","styleElement","head","createPortal","contentEl","headerEl","titleEl","bodyEl","migrateObfuscation","direction","startTime","performance","migrated","skipped","durationMs","dryRun","logInfo","allRecords","records","keyStr","getAllRawRecords","currentlyObfuscated","obfuscatedValue","putRawRecord","plainRecord","migrationDialogStyles","spinnerStyles","QdSpinner","QdMigrationDialog","dialogState","migrationResult","validatePassword","runMigration","handleContinue","renderContent","renderPasswordForm","renderMigrating","renderError","renderSuccess","HELP_CONTENT","status","instructor","getHelpContent","QdLogin","isSubmitting","touched","pendingPin","helpOpen","sessionNotice","showMigrationDialog","migrationError","pendingLoginData","authService","handleLoginEvent","updateVisibility","handleLogoutEvent","takeSessionExpiredNotice","handleLockoutExpired","handleHelpOpen","handleHelpClose","handleMigrationComplete","retryLoginAfterMigration","handleMigrationCancel","registrationTimer","minutes","round","handleStudentLogin","fieldErrors","handleNameInput","touch","handleServiceIdInput","handlePinInput","isValid","submitLabel","validationHint","scheduleRegistrationCheck","checkRegistration","known","field","shown","validateStudentForm","applyLoginResult","dispatchPinEvent","completeLogin","loginData","QdStatus","questionTotal","statusColor","handleStateChanged","loadCache","handleCacheRebuild","handleLogout","sharedStyles","QdInstructorUnlock","remainingSeconds","handlePasswordInput","startCountdown","countdownInterval","QdScoresModal","renderScoresTable","sortedStudents","localeCompare","renderStudentRow","summary","calculateSummary","answeredText","getScoreClass","percentage","firstTimeText","analysisSummary","idx","tracked","correctFirstTime","untracked","summariseFirstAttempts","QdInstructorScores","QdInstructorExport","handleExport","csv","generateCSV","blob","Blob","URL","createObjectURL","link","href","download","click","removeChild","revokeObjectURL","escapeCSVField","includes","who","csvRow","fields","hasData","tooltip","QdInstructorManage","showConfirmDialog","confirmText","checking","modalContainer","handleKeydown","handleCancelClear","handleClearRequest","handleConfirmInput","handleConfirmClear","reauth","removeModalFromBody","renderModalToBody","renderConfirmDialog","currentTarget","resetStudentPin","_salt","_iterations","_kdf","rest","pinResetAt","resetPin","auditEvent","eventId","randomUUID","resetBy","resetAt","_$Ct","_$Ci","it","directiveName","_t","raw","resultType","CONTENT_STYLES","QdConfirmDialog","cancelText","destructive","requirePassword","handleConfirm","confirmBlocked","unsafeHTML","QdStudentTable","actionLabel","searchText","handleSearchInput","filteredStudents","search","emitSelect","filtered","QdPinResetDialog","confirmingStudent","confirmDialogOpen","handleResetClick","handleConfirmReset","verifyThenReset","handleCancelReset","executeReset","findIndex","confirmMessage","QdInstructor","unlocked","showScores","showStudentAnswers","showPinReset","customEvent","unlock","refreshStudents","lock","handleResetPins","handleClosePinReset","handlePinReset","handleUnlock","handleViewScores","handleCloseScores","handleDataCleared","handleToggleStudentAnswers","checkbox","checked","savedState","setStudents","DEFAULT_CONTAINERS","statusPanel","injectComponents","containerSelector","container","injectLoginComponent","injectStatusComponent","injectInstructorComponent","PANEL_HOST_CLASS","PANEL_HOST_PARENT_CLASS","markPanelHost","BADGE_CLASSES","red","amber","green","STATE_TO_BADGE","unstarted","incomplete","complete","clearBadges","updateLinkBadge","getPageState","badgeClass","applyBadge","updateAllBadges","links","initialized","bootstrap","style","injectGlobalStyles","restoreInstructorSession","eventCoordinator","sessionCoordinator","autoEnhanceQuizTables","enhanceAllTables","autoEnhanceAnalysisTables","autoEnhanceHomeBadges","extractPageIdFromHref","enhanceHomeBadgesIfPresent","revealQuizAnswersForInstructor","checkExistingSessionAndUpgradeTables","selector","enhance","tables","enhanced","domConfig","readyState","enabled"],"mappings":"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","x_google_ignoreList":[34,35,36,37,38,39,40,41,42,87,88]} \ No newline at end of file diff --git a/dita-demo/page-index.html b/dita-demo/page-index.html index 21833c2a..c9246028 100644 --- a/dita-demo/page-index.html +++ b/dita-demo/page-index.html @@ -7,7 +7,6 @@ - @@ -43,7 +42,7 @@ - + - + diff --git a/dita-demo/search.html b/dita-demo/search.html index 03c71daf..c592cf88 100644 --- a/dita-demo/search.html +++ b/dita-demo/search.html @@ -17,14 +17,6 @@ - - - - - - - - @@ -61,7 +53,7 @@ - + - + @@ -84,39 +76,7 @@
                                  - -
                                  -
                                  -
                                    -
                                  • -
                                    -
                                    -
                                    -
                                    -
                                    -
                                    -
                                  • -
                                  • -
                                    -
                                    -
                                    -
                                    -
                                    -
                                    -
                                  • -
                                  • -
                                    -
                                    -
                                    -
                                    -
                                    -
                                    -
                                  • -
                                  -
                                  -
                                  -
                                  - +

                                  Search is turned off for this publication.

                                  diff --git a/dita-demo/welcome.html b/dita-demo/welcome.html index 5fe7ff0a..44cd4cf8 100644 --- a/dita-demo/welcome.html +++ b/dita-demo/welcome.html @@ -7,7 +7,6 @@ - @@ -43,7 +42,7 @@ - + - + diff --git a/dita/README.md b/dita/README.md index 2735ef5b..845bb21d 100644 --- a/dita/README.md +++ b/dita/README.md @@ -118,3 +118,9 @@ npm run update-dita-demo # copies the output into dita-dem ``` Then commit `dita-demo/`. CI's E2E tests and the PR previews use it. + +Without Oxygen, `npm run protect-dita-demo` applies the template's protection to the existing +`dita-demo/` output instead: it encodes answers and tolerances with the default key, adds the +`#qd-answer-key` and `#qd-question-total` spans, removes search and its index, and turns off +search autocomplete. It follows the template's rules (`scripts/protect-demo-output.js`), but it +is a stand-in: a real Oxygen publish is still the check that the template works. diff --git a/dita/template/f13ldMan-CSA.opt b/dita/template/f13ldMan-CSA.opt index 7c39bc6e..44808d9c 100644 --- a/dita/template/f13ldMan-CSA.opt +++ b/dita/template/f13ldMan-CSA.opt @@ -27,6 +27,8 @@ + +

                                template does the rest. No command line.

                                Screenshots come from the demo publication on the current - code. The demo output predates the template change that removes the search box, so the capture - hides it to match. Open the live demo.

                                `, + code. Open the live demo.

                                `, }, { section: 'Student', shot: '01-logged-out', title: 'Arriving at a quiz page', @@ -209,7 +208,7 @@

                                Sonar Quiz System

                                { section: 'Student', shot: '05-mcq', title: 'Answering multiple-choice questions', notes: [ - ['progress', '"Answered" and "correct" come from the stored record, so they do not change with the page. With a publication total it reads "2 of 40 answered".', 'SCO-02, SCO-03'], + ['progress', '"Answered" and "correct" come from the stored record, so they do not change with the page. The template counts the questions in the publication, so it reads "2 of 37 answered".', 'SCO-02, SCO-03'], ['header', 'The column is headed "Your answer", whatever the author called it.', 'UX-04'], ['correct', 'Right and wrong are shown with a mark and words, not colour alone, and announced to screen readers.', 'A11Y-02'], ['incorrect', 'Retries are allowed for learning. The first result and the number of tries are kept for the instructor.', 'SCO-01'], diff --git a/docs/walkthrough/sample-export.csv b/docs/walkthrough/sample-export.csv index ab590d66..b856b153 100644 --- a/docs/walkthrough/sample-export.csv +++ b/docs/walkthrough/sample-export.csv @@ -1,11 +1,11 @@ Service ID,Name,Release,Page ID,Question Index,Answer,Success,Timestamp,Kind,Attempts,First Attempt Correct -30012345,Jo Bloggs,TRV Connectors Autumn 2025,quiz-mcq,0,1,true,2026-10-31T17:40:36.019Z,quiz,2,false -30012345,Jo Bloggs,TRV Connectors Autumn 2025,quiz-mcq,1,2,false,2026-10-31T17:40:36.427Z,quiz,1,false -30012345,Jo Bloggs,TRV Connectors Autumn 2025,quiz-numeric,4,1500,true,2026-10-31T17:40:37.861Z,quiz,1,true -30012345,Jo Bloggs,TRV Connectors Autumn 2025,analysis-tactical,Threat Assessment,High: submarine contact bearing 045,,2026-10-31T17:40:39.742Z,analysis,, -30012345,Jo Bloggs,TRV Connectors Autumn 2025,analysis-tactical,Recommended Action,Turn away and increase speed,,2026-10-31T17:40:39.742Z,analysis,, -30067890,Sam Smith,TRV Connectors Autumn 2025,quiz-mcq,0,1,true,2026-10-31T17:40:44.530Z,quiz,1,true -30067890,Sam Smith,TRV Connectors Autumn 2025,quiz-mcq,1,3,true,2026-10-31T17:40:44.790Z,quiz,1,true -30067890,Sam Smith,TRV Connectors Autumn 2025,quiz-mcq,2,1,true,2026-10-31T17:40:45.048Z,quiz,1,true -30067890,Sam Smith,TRV Connectors Autumn 2025,quiz-mcq,3,1,true,2026-10-31T17:40:45.306Z,quiz,1,true -30067890,Sam Smith,TRV Connectors Autumn 2025,quiz-mcq,4,2,true,2026-10-31T17:40:45.566Z,quiz,1,true \ No newline at end of file +30012345,Jo Bloggs,TRV Connectors Autumn 2025,quiz-mcq,0,1,true,2026-11-01T10:13:30.980Z,quiz,2,false +30012345,Jo Bloggs,TRV Connectors Autumn 2025,quiz-mcq,1,2,false,2026-11-01T10:13:31.398Z,quiz,1,false +30012345,Jo Bloggs,TRV Connectors Autumn 2025,quiz-numeric,4,1500,true,2026-11-01T10:13:32.851Z,quiz,1,true +30012345,Jo Bloggs,TRV Connectors Autumn 2025,analysis-tactical,Threat Assessment,High: submarine contact bearing 045,,2026-11-01T10:13:34.788Z,analysis,, +30012345,Jo Bloggs,TRV Connectors Autumn 2025,analysis-tactical,Recommended Action,Turn away and increase speed,,2026-11-01T10:13:34.788Z,analysis,, +30067890,Sam Smith,TRV Connectors Autumn 2025,quiz-mcq,0,1,true,2026-11-01T10:13:39.674Z,quiz,1,true +30067890,Sam Smith,TRV Connectors Autumn 2025,quiz-mcq,1,3,true,2026-11-01T10:13:39.934Z,quiz,1,true +30067890,Sam Smith,TRV Connectors Autumn 2025,quiz-mcq,2,1,true,2026-11-01T10:13:40.197Z,quiz,1,true +30067890,Sam Smith,TRV Connectors Autumn 2025,quiz-mcq,3,1,true,2026-11-01T10:13:40.461Z,quiz,1,true +30067890,Sam Smith,TRV Connectors Autumn 2025,quiz-mcq,4,2,true,2026-11-01T10:13:40.721Z,quiz,1,true \ No newline at end of file diff --git a/package.json b/package.json index 593f75ea..c971d70e 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "build:dita:encrypted": "npm run build:dita", "build:dita:plain": "npm run build && npm run copy-to-dita", "copy-to-dita": "cp dist/sonar-quiz.iife.* dita/template/resources/ && cp dist/sonar-quiz.iife.* dita-demo/oxygen-webhelp/template/resources/", + "protect-dita-demo": "node scripts/protect-demo-output.js dita-demo", "update-dita-demo": "rm -rf dita-demo && mkdir -p dita-demo && cp -r dita/out/oxygen/* dita-demo/ && rm -rf dita-demo/oxygen-webhelp/app/search/index", "typecheck": "tsc --noEmit", "preview": "vite preview", diff --git a/scripts/capture-walkthrough.mjs b/scripts/capture-walkthrough.mjs index 186d86ca..72f029ce 100644 --- a/scripts/capture-walkthrough.mjs +++ b/scripts/capture-walkthrough.mjs @@ -117,15 +117,6 @@ async function main() { viewport: { width: WIDTH, height: HEIGHT }, acceptDownloads: true, }); - // The published template no longer has a search box (SRD 013 ANS-01), but - // dita-demo/ predates that template; hide it so screenshots match. - await context.addInitScript(() => { - document.addEventListener('DOMContentLoaded', () => { - const style = document.createElement('style'); - style.textContent = '.wh_search_input { display: none !important; }'; - document.head.appendChild(style); - }); - }); const page = await context.newPage(); console.log('Student journey'); @@ -319,13 +310,6 @@ async function main() { console.log('Shared-machine check'); const forged = await browser.newContext({ viewport: { width: WIDTH, height: HEIGHT } }); - await forged.addInitScript(() => { - document.addEventListener('DOMContentLoaded', () => { - const style = document.createElement('style'); - style.textContent = '.wh_search_input { display: none !important; }'; - document.head.appendChild(style); - }); - }); const intruder = await forged.newPage(); await open(intruder, 'Pages/quiz-mcq.html'); await intruder.evaluate(() => sessionStorage.setItem('qd/instructor', 'true')); diff --git a/scripts/protect-demo-output.d.ts b/scripts/protect-demo-output.d.ts new file mode 100644 index 00000000..3d68d75c --- /dev/null +++ b/scripts/protect-demo-output.d.ts @@ -0,0 +1,16 @@ +/** Type declarations for scripts/protect-demo-output.js (used by its unit test). */ + +export const DEFAULT_KEY: string; + +export function encodeAnswer(value: string, key: string): string; + +export function countQuizRows(html: string): number; + +export function protectHtml(html: string, options: { key: string; total: string }): string; + +export function protectSearchPage(html: string): string; + +export function protectOutput( + outputDir: string, + options?: { key?: string }, +): { pages: number; questionTotal: number }; diff --git a/scripts/protect-demo-output.js b/scripts/protect-demo-output.js new file mode 100644 index 00000000..dce0de4d --- /dev/null +++ b/scripts/protect-demo-output.js @@ -0,0 +1,243 @@ +#!/usr/bin/env node + +/** + * Apply the Oxygen template's answer protection (SRD 013 WP3) to WebHelp output + * that was published before the template had it. + * + * This is a developer stand-in for republishing in Oxygen, used to bring + * dita-demo/ (the E2E fixture and PR preview) up to date without an Oxygen + * licence. It does what the template does, with the same rules: + * + * - each qd-quiz Answer cell, and each Detail cell holding only a number, is + * rewritten as "qd1:" + the keyed substitution of quizAnswerEncoding.xsl; + * - #qd-question-total and #qd-answer-key spans follow #qd-db-name, as + * customHeader.xsl writes them; + * - the search box, the search index and its scripts are removed, the + * search page shows the notice from wt_search.html, and search + * autocomplete is turned off, as f13ldMan-CSA.opt does. + * + * A real Oxygen publish replaces this. Content authors never run it. + * + * Usage: + * npm run protect-dita-demo + * node scripts/protect-demo-output.js [KEY] + */ + +import { readdirSync, readFileSync, writeFileSync, statSync, rmSync, existsSync } from 'fs'; +import { join, basename } from 'path'; +import { fileURLToPath } from 'url'; +import { SEARCH_INDEX_DIR } from './check-answer-leak.js'; + +const ALPHABET = '0123456789-+.'; +const PREFIX = 'qd1:'; +/** quizAnswerEncoding.xsl's built-in key, also the value in DITA_project_CSA.xpr */ +export const DEFAULT_KEY = 'KXMQBRTLHZWDP'; +const NUMERIC_ONLY = /^[0-9+\-.]+$/; +const SEARCH_NOTICE = '

                                Search is turned off for this publication.

                                '; + +/** + * Encode one answer as quizAnswerEncoding.xsl does. + * + * @param {string} value - Answer or tolerance text + * @param {string} key - 13 different capital letters + * @returns {string} "qd1:" + substituted value + */ +export function encodeAnswer(value, key) { + if (!/^[A-Z]{13}$/.test(key) || new Set(key).size !== 13) { + throw new Error(`Key must be 13 different capital letters A-Z, got "${key}"`); + } + const text = value.replace(/\s+/g, ' ').trim(); + if (!NUMERIC_ONLY.test(text)) { + throw new Error(`Quiz answer "${text}" may contain only digits, a sign and a decimal point`); + } + return PREFIX + [...text].map((ch) => key[ALPHABET.indexOf(ch)]).join(''); +} + +/** Each qd-quiz table in a page, as {start, end} source offsets. */ +function quizTables(html) { + const tables = []; + const open = /]*class="[^"]*\bqd-quiz\b[^"]*"[^>]*>/g; + let match; + while ((match = open.exec(html))) { + const end = html.indexOf('', match.index); + if (end === -1) break; + tables.push({ start: match.index, end: end + ''.length }); + open.lastIndex = end; + } + return tables; +} + +/** Body rows of one table's source. */ +function bodyRows(tableHtml) { + const tbody = tableHtml.match(/]*>([\s\S]*?)<\/tbody>/); + return tbody ? [...tbody[1].matchAll(/]*>[\s\S]*?<\/tr>/g)].map((m) => m[0]) : []; +} + +/** + * Number of quiz questions in one page. + * + * @param {string} html - Page source + * @returns {number} qd-quiz body rows + */ +export function countQuizRows(html) { + return quizTables(html).reduce((sum, t) => sum + bodyRows(html.slice(t.start, t.end)).length, 0); +} + +/** Encode the non-blank text between tags of a cell's inner HTML. */ +function encodeCellText(inner, key) { + return inner.replace(/(^|>)([^<]+)(?=<|$)/g, (whole, lead, text) => + text.trim() === '' || text.trim().startsWith(PREFIX) ? whole : lead + encodeAnswer(text, key), + ); +} + +/** Encode the Answer cell, and a numeric Detail cell, of one row. */ +function protectRow(row, key) { + let column = 0; + return row.replace(/(]*>)([\s\S]*?)(<\/td>)/g, (whole, open, inner, close) => { + column += 1; + const text = inner.trim(); + const numericDetail = column === 3 && !/]*>`, 'g'); + pattern.lastIndex = start; + let depth = 0; + let match; + while ((match = pattern.exec(html))) { + depth += match[1] ? -1 : 1; + if (depth === 0) return pattern.lastIndex; + } + return -1; +} + +/** Remove every
                                whose opening tag matches `opening`. */ +function removeDivs(html, opening) { + let out = html; + let match; + while ((match = opening.exec(out))) { + const end = elementEnd(out, match.index, 'div'); + if (end === -1) break; + out = out.slice(0, match.index) + out.slice(end); + opening.lastIndex = 0; + } + return out; +} + +/** Set or add a hidden config span directly after `after`. */ +function setSpan(html, id, value, after) { + const span = ``; + const existing = new RegExp(`]*>[^<]*`); + if (existing.test(html)) return html.replace(existing, span); + const anchor = html.match(after); + if (!anchor) return html; + const at = anchor.index + anchor[0].length; + return html.slice(0, at) + span + html.slice(at); +} + +/** + * Apply the template's protection to one page. + * + * @param {string} html - Page source + * @param {{key: string, total: string}} options - Answer key and question total + * @returns {string} Protected page source + */ +export function protectHtml(html, { key, total }) { + let out = html; + for (const table of quizTables(out).reverse()) { + const source = out.slice(table.start, table.end); + const protectedTable = source.replace(/]*>[\s\S]*?<\/tbody>/, (tbody) => + tbody.replace(/]*>[\s\S]*?<\/tr>/g, (row) => protectRow(row, key)), + ); + out = out.slice(0, table.start) + protectedTable + out.slice(table.end); + } + out = setSpan(out, 'qd-question-total', total, /]*>[^<]*<\/span>/); + out = setSpan(out, 'qd-answer-key', key, /]*>[^<]*<\/span>/); + out = removeDivs(out, /]*class="[^"]*\bwh_search_input\b[^"]*"[^>]*>/); + out = out.replace( + /[ \t]*]*src="[^"]*app\/search\/index\/[^"]*"[^>]*><\/script>\r?\n?/g, + '', + ); + return out; +} + +/** + * Replace the search page's results area with the template's notice. + * + * @param {string} html - search.html source + * @returns {string} Updated source + */ +export function protectSearchPage(html) { + const opening = /]*id="results"[^>]*>\s*
                                /.exec(html); + if (!opening) return html; + const rowStart = html.indexOf('
                                ', opening.index); + const rowEnd = elementEnd(html, rowStart, 'div'); + if (rowEnd === -1) return html; + return `${html.slice(0, rowStart)}
                                \n ${SEARCH_NOTICE}\n
                                ${html.slice(rowEnd)}`; +} + +/** + * Turn off search autocomplete, as the template's f13ldMan-CSA.opt does: its + * script throws on every page once the search box is gone. + */ +function disableAutocomplete(propertiesFile) { + if (!existsSync(propertiesFile)) return; + const source = readFileSync(propertiesFile, 'utf8'); + const out = source.replace(/("webhelp\.enable\.search\.autocomplete"\s*:\s*)"[^"]*"/, '$1"no"'); + if (out !== source) writeFileSync(propertiesFile, out); +} + +/** Every .html file under a directory. */ +function htmlFiles(dir) { + const out = []; + for (const name of readdirSync(dir)) { + const path = join(dir, name); + if (statSync(path).isDirectory()) out.push(...htmlFiles(path)); + else if (name.endsWith('.html')) out.push(path); + } + return out; +} + +/** + * Protect a whole WebHelp output folder in place. + * + * @param {string} outputDir - Published output folder + * @param {{key?: string}} [options] - Answer key (default: the template's) + * @returns {{pages: number, questionTotal: number}} Pages written and question total + */ +export function protectOutput(outputDir, { key = DEFAULT_KEY } = {}) { + const files = htmlFiles(outputDir); + const sources = new Map(files.map((file) => [file, readFileSync(file, 'utf8')])); + const questionTotal = [...sources.values()].reduce((sum, html) => sum + countQuizRows(html), 0); + const total = questionTotal > 0 ? String(questionTotal) : ''; + for (const [file, html] of sources) { + let out = protectHtml(html, { key, total }); + if (basename(file) === 'search.html') out = protectSearchPage(out); + if (out !== html) writeFileSync(file, out); + } + rmSync(join(outputDir, SEARCH_INDEX_DIR), { recursive: true, force: true }); + disableAutocomplete(join(outputDir, 'oxygen-webhelp', 'app', 'options', 'properties.js')); + return { pages: files.length, questionTotal }; +} + +function main() { + const [outputDir, key = DEFAULT_KEY] = process.argv.slice(2); + if (!outputDir) { + console.error('Usage: node scripts/protect-demo-output.js [KEY]'); + process.exit(2); + } + const { pages, questionTotal } = protectOutput(outputDir, { key }); + console.log(`Protected ${pages} page(s); question total ${questionTotal}`); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + main(); +} diff --git a/tests/e2e/workflows/dita-instructor-flow.spec.ts b/tests/e2e/workflows/dita-instructor-flow.spec.ts index bcdc5b12..56ba01b4 100644 --- a/tests/e2e/workflows/dita-instructor-flow.spec.ts +++ b/tests/e2e/workflows/dita-instructor-flow.spec.ts @@ -94,7 +94,7 @@ test.describe('DITA Instructor Flow', () => { await expect(statusPanel).toBeVisible({ timeout: 3000 }); // Verify student status shows progress - await expect(statusPanel.locator('.progress-text')).toContainText('0 answered'); + await expect(statusPanel.locator('.progress-text')).toContainText('0 of 37 answered'); // dita-demo publishes its question total (SCO-05) // Navigate to a quiz page await page.click('a.quizPageBtn[href*="quiz-mcq"]'); diff --git a/tests/e2e/workflows/dita-student-flow.spec.ts b/tests/e2e/workflows/dita-student-flow.spec.ts index 5d21bea0..dbd0f184 100644 --- a/tests/e2e/workflows/dita-student-flow.spec.ts +++ b/tests/e2e/workflows/dita-student-flow.spec.ts @@ -90,7 +90,7 @@ test.describe('DITA Student Flow', () => { await expect(loginForm).not.toBeVisible(); // Verify status shows initial progress (0 correct) - await expect(statusPanel.locator('.progress-text')).toContainText('0 answered'); + await expect(statusPanel.locator('.progress-text')).toContainText('0 of 37 answered'); // dita-demo publishes its question total (SCO-05) await expect(statusPanel.locator('.progress-text')).toContainText('correct'); // Verify status indicator is red (no progress) @@ -355,7 +355,7 @@ test.describe('DITA Student Flow', () => { await statusPanel.waitFor(); // Verify new session created (progress starts at 0) - await expect(statusPanel.locator('.progress-text')).toContainText('0 answered'); + await expect(statusPanel.locator('.progress-text')).toContainText('0 of 37 answered'); // dita-demo publishes its question total (SCO-05) // Navigate to quiz page await page.click('a.quizPageBtn[href*="quiz-mcq"]'); diff --git a/tests/unit/scripts/oxygen-template.test.ts b/tests/unit/scripts/oxygen-template.test.ts index e82284d7..45ed24e5 100644 --- a/tests/unit/scripts/oxygen-template.test.ts +++ b/tests/unit/scripts/oxygen-template.test.ts @@ -40,4 +40,9 @@ describe('Oxygen template wiring', () => { expect(total).toContain(' { + const opt = readFileSync(resolve(__dirname, '../../../dita/template/f13ldMan-CSA.opt'), 'utf8'); + expect(opt).toContain(''); + }); }); diff --git a/tests/unit/scripts/protect-demo-output.test.ts b/tests/unit/scripts/protect-demo-output.test.ts new file mode 100644 index 00000000..ed2481d0 --- /dev/null +++ b/tests/unit/scripts/protect-demo-output.test.ts @@ -0,0 +1,139 @@ +/** + * Unit tests for the developer stand-in that applies the Oxygen template's + * answer protection (SRD 013 WP3) to already-published demo output. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { + encodeAnswer, + countQuizRows, + protectHtml, + protectSearchPage, + protectOutput, + DEFAULT_KEY, +} from '../../../scripts/protect-demo-output.js'; +import { findLeaksInHtml, SEARCH_INDEX_DIR } from '../../../scripts/check-answer-leak.js'; +import { decodeAnswer } from '../../../src/services/answer-encoding'; + +const KEY = 'KXMQBRTLHZWDP'; + +const header = ``; + +const quizPage = ` + + +${header} + + + + + + + + +
                                QuestionAnswerDetail
                                MCQ?3
                                1. One 12
                                2. Two
                                3. Three
                                Numeric?-1.50.5
                                +
                                Not a quiz427
                                +`; + +describe('protect-demo-output', () => { + let dir = ''; + afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + + it('encodes like quizAnswerEncoding.xsl and decodes with the runtime', () => { + expect(encodeAnswer('1500', KEY)).toBe('qd1:XRKK'); + expect(encodeAnswer(' -1.5 ', KEY)).toBe('qd1:WXPR'); + expect(decodeAnswer(encodeAnswer('-1.5', KEY), KEY)).toBe('-1.5'); + expect(DEFAULT_KEY).toBe(KEY); + }); + + it('refuses an answer or key the encoding cannot handle', () => { + expect(() => encodeAnswer('15 kHz', KEY)).toThrow(/15 kHz/); + expect(() => encodeAnswer('1', 'ABC')).toThrow(/13 different capital letters/); + expect(() => encodeAnswer('1', 'AAAAAAAAAAAAA')).toThrow(/13 different capital letters/); + }); + + it('counts quiz rows only', () => { + expect(countQuizRows(quizPage)).toBe(2); + expect( + countQuizRows('
                                x
                                '), + ).toBe(0); + }); + + it('encodes answers and tolerances, leaving MCQ options and other tables alone', () => { + const out = protectHtml(quizPage, { key: KEY, total: '2' }); + expect(findLeaksInHtml(out)).toEqual([]); + expect(out).toContain('qd1:Q'); + expect(out).toContain('qd1:WXPR'); + expect(out).toContain('qd1:KPR'); + expect(out).toContain('
                              3. One 12
                              4. '); + expect(out).toContain('427'); + }); + + it('writes the key and total spans after the db-name span, once', () => { + const once = protectHtml(quizPage, { key: KEY, total: '2' }); + const twice = protectHtml(once, { key: KEY, total: '2' }); + for (const out of [once, twice]) { + expect(out.match(/id="qd-answer-key"/g)).toHaveLength(1); + expect(out).toContain( + '' + + '' + + ``, + ); + } + expect(twice).toContain('qd1:Q'); + expect(twice).not.toContain('qd1:qd1:'); + }); + + it('removes the search box and search index scripts', () => { + const out = protectHtml(quizPage, { key: KEY, total: '2' }); + expect(out).not.toContain('wh_search_input'); + expect(out).not.toContain('textToSearch'); + expect(out).not.toContain('search/index/'); + expect(out).toContain('app/options/properties.js'); + expect(out).toContain(''); + }); + + it('replaces search results with the notice the template renders', () => { + const search = `
                                +
                                +
                                x
                                +
                                +
                                f
                                `; + const out = protectSearchPage(search); + expect(out).not.toContain('searchResults'); + expect(out).toContain('

                                Search is turned off for this publication.

                                '); + expect(out).toContain('
                                f
                                '); + }); + + it('protects a whole output folder and deletes the search index', () => { + dir = mkdtempSync(join(tmpdir(), 'qd-protect-')); + mkdirSync(join(dir, 'Pages')); + mkdirSync(join(dir, SEARCH_INDEX_DIR), { recursive: true }); + writeFileSync(join(dir, SEARCH_INDEX_DIR, 'index-1.js'), 'var x = "1500";'); + writeFileSync(join(dir, 'Pages', 'quiz.html'), quizPage); + writeFileSync(join(dir, 'Pages', 'quiz2.html'), quizPage); + writeFileSync(join(dir, 'index.html'), `${header}`); + mkdirSync(join(dir, 'oxygen-webhelp', 'app', 'options'), { recursive: true }); + const properties = join(dir, 'oxygen-webhelp', 'app', 'options', 'properties.js'); + writeFileSync(properties, '{ "webhelp.enable.search.autocomplete" : "true",\n "x" : "true" }'); + + const result = protectOutput(dir, { key: KEY }); + + expect(result).toEqual({ pages: 3, questionTotal: 4 }); + expect(existsSync(join(dir, SEARCH_INDEX_DIR))).toBe(false); + const home = readFileSync(join(dir, 'index.html'), 'utf8'); + expect(home).toContain(''); + expect(findLeaksInHtml(readFileSync(join(dir, 'Pages', 'quiz.html'), 'utf8'))).toEqual([]); + expect(readFileSync(properties, 'utf8')).toBe( + '{ "webhelp.enable.search.autocomplete" : "no",\n "x" : "true" }', + ); + }); +});