Browse Source

fix(learning): extend bulk assessment to learned passes

彭峰 6 hours ago
parent
commit
8cd0f73e2a

+ 24 - 0
docs/qa/word-selection-learned-passes-2026-11-02.md

@@ -0,0 +1,24 @@
+# 2026-11-02 已学栏批量选词与右侧按钮调整(未上线)
+
+## 范围及结果
+
+- 未学习、已学一遍、已学第二遍均支持全选本页、取消个别词及批量判断。保留分批保存、失败重试冻结、每轮待学容量和分页校正规则。
+- 已学一遍的词标记认识后进入第二遍,第二遍的进入第三遍;临时预览与保存后的各栏计数均按原遍数推进,不重置为第一遍。
+- 请求键身份包含原遍数,同一组词连续两遍是不同保存操作;同一遍失败重试仍沿用原请求键。已判断但尚未保存的认识词不再重复计入下一遍的批量勾选。
+- 按用户明确补充,已学第三遍继续只读,不添加全选、判断按钮或重学选择框。
+- 批量栏左侧为取消及勾选数量,认识/不认识按钮靠右;绿色主按钮和暖色次按钮带图标与焦点提示。320px 窄屏将按钮独立成行并右对齐,防止内容挤压。
+
+## 验证
+
+- 新增的 3 项回归先在旧实现上失败;修复后包含原有识词撤销流程的 4 项重点用例通过。
+- 最终全量前端回归:移动端 330 项、管理端 348 项,共 678 项通过,两个测试进程均正常退出。首次全量执行在测试成功后的退出阶段卡住,终止后分别重跑确认通过。
+- 移动端生产构建通过,既有样式体积和 CommonJS 警告仍存在。
+- 浏览器隔离接口检查:已学一遍和第二遍全选后排除一个词,仅训练排除的词;同一组词连续两遍使用不同请求键并正确累计至第三遍;第三遍无全选、勾选框或判断入口。
+- 1024px、375px、320px 均无横向溢出或单词遮挡;批量按钮与工具栏右侧间距均为 14px,绿色/暖色区分正常,取消后的勾选状态可见,页面无运行时异常。
+- 独立代码复查未发现本次引入的阻塞问题。
+
+截图、浏览器脚本、`verification.json` 及测试/构建日志保存在本机 `~/.local/state/xiaoshu/qa/2026-11-02-learned-passes/`。浏览器使用测试账号和隔离接口,不代表微信实机验收。
+
+## 发布边界
+
+本次仅提交、推送客户端、测试和记录,不部署前后端。其他任务正在进行的同步、后台及 SQL 工作区改动不包含在本次提交中。

+ 10 - 0
docs/superpowers/plans/2026-11-02-word-selection-learned-passes.md

@@ -0,0 +1,10 @@
+# 已学栏批量选词与按钮样式调整
+
+目标:未学习、已学一遍、已学第二遍使用相同的本页全选/取消个别词/批量认识或不认识流程;按用户补充,已学第三遍保留只读,不加全选或重学。批量判断按钮靠右,绿色主按钮与暖色次按钮更加醒目。
+
+- [x] 先用回归用例复现已学一遍/第二遍缺少全选、已认识词保存未递增到正确遍数的问题,并固定第三遍不可操作规则。
+- [x] 扩展选词判断和临时预览计数到前两遍已学词。每批保存成功后从原遍数加一,使用原遍数参与请求键身份,保证同一组词的第二遍和第三遍是独立命令,失败重试沿用原命令。
+- [x] 所有可操作栏保存后都校正分页;全认识后显示对应下一遍,已学第三遍保持只读。
+- [x] 批量栏左侧显示取消与勾选数,右侧显示绿色“认识”和暖色“不认识”;窄屏必要时独立成行并右对齐,保持点击区、对比度及键盘焦点清晰。
+- [x] 完成相关集成测试、全量前端回归、移动端构建及 320/375/桌面浏览器交互检查。
+- [x] 只提交本次客户端和验证文件,推送当前分支;保留其他正在进行的后台/同步改动,不部署。

+ 48 - 3
projects/xiaoshu-mobile/src/app/core/home-learning-modules.integration.spec.ts

@@ -1785,7 +1785,7 @@ describe('home learning modules', () => {
     (harness.routeNativeElement?.querySelectorAll('.word-selection-tabs button')[1] as HTMLButtonElement).click();
     harness.fixture.detectChanges();
     expect(harness.routeNativeElement?.querySelectorAll('.completed-column .word-refine-item').length).toBe(2);
-    (harness.routeNativeElement?.querySelector('.completed-column .unknown-button') as HTMLButtonElement).click();
+    (harness.routeNativeElement?.querySelector('.completed-column .word-assessment:has(.known-button.active) .unknown-button') as HTMLButtonElement).click();
     harness.fixture.detectChanges();
     expect(harness.routeNativeElement?.querySelectorAll('.completed-column .word-refine-item').length).toBe(1);
     (harness.routeNativeElement?.querySelectorAll('.word-selection-tabs button')[0] as HTMLButtonElement).click();
@@ -2232,14 +2232,15 @@ describe('home learning modules', () => {
     expect(harness.routeNativeElement?.querySelector('.review-complete')?.textContent).toContain('复习已完成');expect(harness.routeNativeElement?.querySelector('.review-complete')?.textContent).not.toMatch(/收入|费用|计薪|¥/);expect(component.canDeactivate()).toBeTrue();
   });
 
-  async function teacherSelection(count: number) {
+  async function teacherSelection(count: number, pass = 0) {
     user.set({userId:300,groupId:3}); session.groupId.set(3); session.student.set({userId:100,groupId:1});
     const api=TestBed.inject(ApiService) as unknown as LearningModulesApiService;
-    api.courseWords=Array.from({length:count},(_,i)=>({GeneralID:1001+i,NodeID:811,Title:'word-'+(i+1),sy:'释义 '+(i+1),w_learned:0}));
+    api.courseWords=Array.from({length:count},(_,i)=>({GeneralID:1001+i,NodeID:811,Title:'word-'+(i+1),sy:'释义 '+(i+1),w_learned:pass}));
     const harness=await RouterTestingHarness.create();
     await harness.navigateByUrl('/pages/stu/stu_abc?nids=811&pnid=801');
     await harness.fixture.whenStable(); harness.fixture.detectChanges();
     const component=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
+    if(pass>0){component.selectCoursePass(pass);await harness.fixture.whenStable();harness.fixture.detectChanges();}
     return {api,harness,component};
   }
 
@@ -2422,4 +2423,48 @@ describe('home learning modules', () => {
     expect(learning.words().length).toBe(3);expect(api.courseIncrements.size).toBe(0);
   });
 
+
+  for(const pass of [1,2]) {
+    it('bulk assesses learned pass '+pass+' and advances only recognized words by one pass', async () => {
+      const {api,harness,component}=await teacherSelection(4,pass);
+      const view=()=>harness.routeNativeElement!;
+      expect(view().querySelector('.bulk-select-toggle')).not.toBeNull();
+      expect(view().querySelectorAll('.word-refine-main .known-button').length).toBe(4);
+      (view().querySelector('.bulk-select-toggle') as HTMLButtonElement)?.click();harness.fixture.detectChanges();
+      expect(view().querySelectorAll('.word-select-check[aria-pressed="true"]').length).toBe(4);
+      (view().querySelectorAll<HTMLButtonElement>('.word-select-check')[3]).click();harness.fixture.detectChanges();
+      (view().querySelector('.bulk-assessment .known-button') as HTMLButtonElement)?.click();await harness.fixture.whenStable();harness.fixture.detectChanges();
+      expect(TestBed.inject(Router).url).toContain('/pages/stu/stu_con');
+      const learning=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
+      expect(learning.words().map(w=>w.wordId)).toEqual(['1004']);
+      expect([...api.courseIncrements.keys()].sort()).toEqual(['1001','1002','1003']);
+      expect(component.coursePassCount(pass)).toBe(1);expect(component.coursePassCount(pass+1)).toBe(3);
+      expect(component.firstPassProgress()).toBe(100);
+    });
+  }
+
+  it('uses different save identities for consecutive learned passes and leaves the third pass read-only', async () => {
+    const {api,harness,component}=await teacherSelection(2,1);
+    spyOn(TestBed.inject(PlatformService),'toast'); // Two consecutive saves test state, not toast dismissal timers.
+    const original=api.post.bind(api),saved=new Map<string,Observable<ApiEnvelope<any>>>(),keys:string[]=[];
+    spyOn(api,'post').and.callFake(((action:string,params:Record<string,unknown>={})=>{
+      if(action!=='e_add_words')return original(action,params);
+      const key=String(params['requestKey']);keys.push(key);if(saved.has(key))return saved.get(key)!;
+      const result=original(action,params);saved.set(key,result);return result;
+    }) as typeof api.post);
+    for(const expectedPass of [2,3]){
+      (harness.routeNativeElement!.querySelector('.bulk-select-toggle') as HTMLButtonElement)?.click();harness.fixture.detectChanges();
+      (harness.routeNativeElement!.querySelector('.bulk-assessment .known-button') as HTMLButtonElement)?.click();await harness.fixture.whenStable();harness.fixture.detectChanges();
+      expect(component.coursePass()).toBe(expectedPass);expect(component.visibleCourseWords().length).toBe(2);
+    }
+    expect(keys.length).toBe(2);expect(new Set(keys).size).toBe(2);
+    expect([...api.courseIncrements.values()]).toEqual([2,2]);
+    expect(component.coursePassCount(3)).toBe(2);expect(component.coursePassCount(0)).toBe(0);
+    expect(harness.routeNativeElement!.querySelector('.bulk-select-toggle')).toBeNull();
+    expect(harness.routeNativeElement!.querySelector('.word-select-check')).toBeNull();
+    expect(harness.routeNativeElement!.querySelector('.word-assessment')).toBeNull();
+    component.toggleBulkSelection();component.applyBulkAssessment('known');
+    expect(keys.length).toBe(2);
+  });
+
 });

+ 7 - 7
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.html

@@ -159,14 +159,14 @@
         </nav>
         <section id="course-pass-panel" class="word-selection-list" [class.pending-column]="coursePass() === 0" [class.completed-column]="coursePass() > 0" role="tabpanel" [attr.aria-label]="coursePassTabs[coursePass()]" [attr.aria-busy]="selectionLoading()">
           <p class="word-column-help">{{ coursePassHint }}</p>
-          @if (!isReadOnlySelectionPage && coursePass() === 0) {
+          @if (!isReadOnlySelectionPage && coursePass() < 3) {
             <div class="course-bulk-toolbar">
               <button type="button" class="bulk-select-toggle" [disabled]="selectionLocked() || selectionLoading() || !bulkCandidates().length" [attr.aria-pressed]="bulkSelection() !== null" (click)="toggleBulkSelection()">{{ bulkSelection() !== null ? '取消全选' : '全选本页' }}</button>
               @if (bulkSelection(); as checked) {
+                <span class="bulk-count" role="status">已勾选 <b>{{ checked.size }}</b> 个</span>
                 <div class="bulk-assessment" role="group" aria-label="批量判断单词">
-                  <span role="status">已勾选 {{ checked.size }} 个</span>
-                  <button type="button" class="known-button" [disabled]="selectionLocked() || !checked.size" (click)="applyBulkAssessment('known')">认识</button>
-                  <button type="button" class="unknown-button" [disabled]="selectionLocked() || !checked.size" (click)="applyBulkAssessment('unknown')">不认识</button>
+                  <button type="button" class="known-button" [disabled]="selectionLocked() || !checked.size" (click)="applyBulkAssessment('known')"><lucide-icon [img]="icons.Check" [size]="14" />认识</button>
+                  <button type="button" class="unknown-button" [disabled]="selectionLocked() || !checked.size" (click)="applyBulkAssessment('unknown')"><lucide-icon [img]="icons.CircleHelp" [size]="14" />不认识</button>
                 </div>
                 <small>勾选认识的词,取消不认识的词,再点“认识”;未勾选的保留待学。</small>
               }
@@ -177,7 +177,7 @@
               <article class="word-refine-item" [class.selected]="courseWordChecked(word)" [class.completed-word]="courseWordPass(word) > 0">
                 <div class="word-refine-main">
                   @if (canRelearnCourseWord(word)) {
-                    <button type="button" class="word-select-check" [disabled]="selectionLocked()" [attr.aria-label]="(bulkSelection() !== null ? (courseWordChecked(word) ? '取消勾选:' : '勾选:') : (selected().has(word.id) ? '移出学习:' : '加入学习:')) + word.word" [attr.aria-pressed]="courseWordChecked(word)" (click)="toggleWord(word)">
+                    <button type="button" class="word-select-check" [disabled]="selectionLocked() || selectionAssessment()[word.id] === 'known'" [attr.aria-label]="(bulkSelection() !== null ? (courseWordChecked(word) ? '取消勾选:' : '勾选:') : (selected().has(word.id) ? '移出学习:' : '加入学习:')) + word.word" [attr.aria-pressed]="courseWordChecked(word)" (click)="toggleWord(word)">
                       @if (courseWordChecked(word)) { <lucide-icon [img]="icons.Check" [size]="16" /> }
                     </button>
                   } @else {
@@ -187,9 +187,9 @@
                     <strong>{{ word.word }}</strong>
                     <small [class.revealed]="selectionWordRevealed(word)">{{ selectionWordRevealed(word) ? (word.meaning || '暂无释义') : courseWordPass(word) > 0 ? selectionCompletionLabel(word) + ' · 点击查看释义' : isReadOnlySelectionPage ? '点击查看释义' : '先说出中文意思,再点击核对' }}</small>
                   </button>
-                  @if (!isReadOnlySelectionPage && !wordHistoricallyLearned(word)) {
+                  @if (!isReadOnlySelectionPage && coursePass() < 3) {
                     <div class="word-assessment" role="group" [attr.aria-label]="word.word + '的识词判断'">
-                      <button type="button" class="known-button" [disabled]="selectionLocked()" [class.active]="selectionAssessment()[word.id] === 'known'" [attr.aria-pressed]="selectionAssessment()[word.id] === 'known'" (click)="markWordKnown(word)">认识</button>
+                      <button type="button" class="known-button" [disabled]="selectionLocked() || selectionAssessment()[word.id] === 'known'" [class.active]="selectionAssessment()[word.id] === 'known'" [attr.aria-pressed]="selectionAssessment()[word.id] === 'known'" (click)="markWordKnown(word)">认识</button>
                       <button type="button" class="unknown-button" [disabled]="selectionLocked()" [class.active]="selectionAssessment()[word.id] === 'unknown'" [attr.aria-pressed]="selectionAssessment()[word.id] === 'unknown'" (click)="markWordUnknown(word)">不认识</button>
                     </div>
                   }

+ 27 - 17
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.ts

@@ -132,7 +132,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   readonly knownSaveRetry = signal(false);
   private knownSaveContinuation: { continueLearning: boolean; openKnownPass: boolean } | null = null;
   private courseSelectionNeedsRebase = false;
-  readonly bulkCandidates = computed(() => this.visibleCourseWords().filter(word => !this.wordHistoricallyLearned(word)));
+  readonly bulkCandidates = computed(() => this.visibleCourseWords().filter(word => this.canRelearnCourseWord(word) && this.selectionAssessment()[word.id] !== 'known'));
   readonly activeSelectionBucket = signal<'pending' | 'completed'>('pending');
   readonly coursePassTabs = ['未学习', '已学一遍', '已学第二遍', '已学第三遍'];
   readonly coursePass = signal(0);
@@ -140,7 +140,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   readonly bookPassCounts = signal<number[] | null>(null);
   private readonly courseSelectionCache = signal<Word[]>([]);
   readonly visibleCourseWords = computed(() => {
-    const candidates = this.coursePass() === 1 ? [...this.words(), ...this.newlyKnownWords()] : this.words();
+    const candidates = [...this.words(), ...this.newlyKnownWords()];
     return [...new Map(candidates.map(word => [word.id, word])).values()]
       .filter(word => this.courseWordPass(word) === this.coursePass());
   });
@@ -261,7 +261,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   readonly visibleNewWords = computed(() => this.activeSelectionBucket() === 'completed' ? this.learnedNewWords() : this.unlearnedNewWords());
   readonly newWordPageCount = computed(() => Math.max(1, Math.ceil(this.selectionTotal() / this.newWordPageSize)));
   readonly allVisibleNewWordsSelected = computed(() => this.visibleNewWords().length > 0 && this.visibleNewWords().every((word) => this.selected().has(word.id)));
-  readonly newlyKnownWords = computed(() => (this.isRefineSelectionPage ? this.courseSelectionCache() : this.words()).filter((word) => !this.wordHistoricallyLearned(word) && this.selectionAssessment()[word.id] === 'known'));
+  readonly newlyKnownWords = computed(() => (this.isRefineSelectionPage ? this.courseSelectionCache() : this.words()).filter((word) => this.selectionAssessment()[word.id] === 'known'));
   readonly selectionActionLabel = computed(() => {
     if (this.knownSaveRetry()) return '重试保存';
     const hasLearningWords = this.selected().size > 0;
@@ -667,14 +667,21 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
 
   courseWordPass(word: Word): number {
-    return Math.min(3, Math.max(this.selectionAssessment()[word.id] === 'known' ? 1 : 0, Math.floor(Number(word['w_learned']) || 0)));
+    return Math.min(3, this.savedCoursePass(word) + (this.selectionAssessment()[word.id] === 'known' ? 1 : 0));
+  }
+
+  private savedCoursePass(word: Word): number {
+    return Math.min(3, Math.max(0, Math.floor(Number(word['w_learned']) || 0)));
   }
 
   coursePassCount(pass: number): number {
     const counts = this.coursePassCounts();
     if (!counts) return 0;
-    const known = this.newlyKnownWords().length;
-    return Math.max(0, counts[pass] + (pass === 0 ? -known : pass === 1 ? known : 0));
+    const adjustment = this.newlyKnownWords().reduce((total, word) => {
+      const before = this.savedCoursePass(word), after = Math.min(3, before + 1);
+      return total + Number(after === pass) - Number(before === pass);
+    }, 0);
+    return Math.max(0, counts[pass] + adjustment);
   }
 
   canRelearnCourseWord(word: Word): boolean {
@@ -685,7 +692,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     const pass = this.coursePass();
     if (pass === 0) return this.isReadOnlySelectionPage ? '这些单词尚未完成第一遍学习,点击可查看释义。' : '可直接选择“认识”或“不认识”,也可全选本页后批量判断。';
     if (pass === 3) return '这些单词已完成三遍学习,可查看和听读音。';
-    return this.isReadOnlySelectionPage ? `这些单词已学${pass}遍,可由老师选词继续学习。` : `选择单词开始第${pass === 1 ? '二' : '三'}遍学习。`;
+    return this.isReadOnlySelectionPage ? `这些单词已学${pass}遍,可由老师选词继续学习。` : `选择单词开始第${pass === 1 ? '二' : '三'}遍学习,也可全选本页批量判断。`;
   }
 
   selectCoursePass(pass: number): void {
@@ -715,14 +722,14 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
 
   markWordKnown(word: Word): void {
-    if (this.selectionLocked() || this.isReadOnlySelectionPage || this.wordHistoricallyLearned(word)) return;
+    if (this.selectionLocked() || !this.canRelearnCourseWord(word) || this.selectionAssessment()[word.id] === 'known') return;
     this.setWordSelected(word, false);
     this.setSelectionAssessment(word, 'known');
     this.bulkSelection.update(current => current === null ? null : new Set([...current].filter(id => id !== word.id)));
   }
 
   markWordUnknown(word: Word): void {
-    if (this.selectionLocked() || this.isReadOnlySelectionPage || this.wordHistoricallyLearned(word)) return;
+    if (this.selectionLocked() || this.isReadOnlySelectionPage || this.savedCoursePass(word) >= 3) return;
     if (this.setWordSelected(word, true)) {
       this.setSelectionAssessment(word, 'unknown');
       this.bulkSelection.update(current => current === null ? null : new Set([...current].filter(id => id !== word.id)));
@@ -734,12 +741,12 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
 
   toggleBulkSelection(): void {
-    if (this.selectionLocked() || this.selectionLoading() || this.isReadOnlySelectionPage || this.coursePass() !== 0) return;
+    if (this.selectionLocked() || this.selectionLoading() || this.isReadOnlySelectionPage || this.coursePass() >= 3) return;
     this.bulkSelection.set(this.bulkSelection() === null ? new Set(this.bulkCandidates().map(word => word.id)) : null);
   }
 
   applyBulkAssessment(assessment: 'known' | 'unknown'): void {
-    if (this.selectionLocked() || this.isReadOnlySelectionPage || this.coursePass() !== 0) return;
+    if (this.selectionLocked() || this.isReadOnlySelectionPage || this.coursePass() >= 3) return;
     const checked = this.bulkSelection();
     if (!checked?.size) return;
     const candidates = this.bulkCandidates();
@@ -817,7 +824,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
             this.beginSelectedFlow(chosen, '');
             return;
           }
-          if (openKnownPass) this.selectCoursePass(1);
+          if (openKnownPass) this.selectCoursePass(Math.min(3, this.coursePass() + 1));
           this.platform.toast('识词结果已保存', 'success');
         },
         error: (error: ApiError) => {
@@ -2433,17 +2440,20 @@ export class LearningPageComponent implements OnInit, OnDestroy {
       wordsId: batch.map((word) => word.wordId).join(','),
       kcid: (this.meta.path === 'pages/stu/pl_end' ? this.orderInfo()['learningCourseId'] : '') || this.query('pnid') || this.query('kcid') || this.orderInfo()['kcid'],
       save: 1,
-      requestKey:this.draft().command('learn:'+batch.map(w=>w.wordId).sort().join(',')),
+      requestKey:this.draft().command('learn:'+batch.map(w=>w.wordId+'@'+this.savedCoursePass(w)).sort().join(',')),
     }).pipe(tap(() => onBatchSaved?.(batch)))), toArray());
   }
 
   private commitKnownWords(words: Word[]): void {
-    if (this.isRefineSelectionPage && this.coursePass() === 0) this.courseSelectionNeedsRebase = true;
-    const ids = new Set(words.map(word => word.id));
-    const update = (current: Word[]) => current.map(word => ids.has(word.id) ? {...word, w_learned: 1} : word);
+    if (this.isRefineSelectionPage) this.courseSelectionNeedsRebase = true;
+    const nextPass = new Map(words.map(word => [word.id, Math.min(3, this.savedCoursePass(word) + 1)]));
+    const ids = new Set(nextPass.keys());
+    const update = (current: Word[]) => current.map(word => ids.has(word.id) ? {...word, w_learned: nextPass.get(word.id)!} : word);
     this.words.update(update);
     this.courseSelectionCache.update(update);
-    const advance = (counts: number[] | null) => counts ? [Math.max(0,counts[0]-ids.size),counts[1]+ids.size,counts[2],counts[3]] : null;
+    const changes = [0,0,0,0];
+    for (const word of words) { changes[this.savedCoursePass(word)]--; changes[nextPass.get(word.id)!]++; }
+    const advance = (counts: number[] | null) => counts ? counts.map((count, pass) => Math.max(0, count + changes[pass])) : null;
     this.coursePassCounts.update(advance);
     this.bookPassCounts.update(advance);
     this.selectionAssessment.update(current => Object.fromEntries(Object.entries(current).filter(([id]) => !ids.has(id))) as Record<string, 'known' | 'unknown'>);

+ 11 - 4
projects/xiaoshu-mobile/src/styles.scss

@@ -872,10 +872,17 @@ app-learning-page .word-assessment button, app-learning-page .bulk-assessment bu
 app-learning-page .word-assessment .unknown-button, app-learning-page .bulk-assessment .unknown-button { color: #9a6407; }
 app-learning-page .word-assessment .known-button.active { color: white; background: var(--brand-strong); border-color: var(--brand-strong); }
 app-learning-page .word-assessment .unknown-button.active { color: #4d3100; background: var(--accent-pale); border-color: var(--accent); }
-app-learning-page .course-bulk-toolbar { display: flex; position: sticky; top: 0; z-index: 2; padding: 10px 14px; align-items: center; flex-wrap: wrap; gap: 8px 12px; background: var(--brand-wash); border-bottom: 1px solid var(--line); }
-app-learning-page .bulk-assessment { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
-app-learning-page .bulk-assessment span { color: var(--ink-soft); font-size: 12px; }
-app-learning-page .course-bulk-toolbar small { flex-basis: 100%; color: var(--ink-soft); font-size: 11px; line-height: 1.6; }
+app-learning-page .course-bulk-toolbar { display: grid; position: sticky; top: 0; z-index: 2; padding: 12px 14px; align-items: center; grid-template-columns: auto minmax(0, 1fr) auto; gap: 8px 12px; background: var(--brand-wash); border-bottom: 1px solid var(--line); }
+app-learning-page .bulk-assessment { display: flex; align-items: center; justify-self: end; gap: 8px; }
+app-learning-page .bulk-count { min-width: 0; color: var(--ink-soft); font-size: 12px; line-height: 1.6; }
+app-learning-page .bulk-count b { color: var(--brand-strong); font-weight: 750; }
+app-learning-page .course-bulk-toolbar small { grid-column: 1 / -1; color: var(--ink-soft); font-size: 11px; line-height: 1.6; }
+app-learning-page .bulk-assessment button { min-height: 38px; padding: 0 12px; gap: 6px; font-size: 13px; font-weight: 750; transition: filter .15s, box-shadow .15s; }
+app-learning-page .bulk-assessment .known-button { color: white; background: var(--brand-strong); border-color: var(--brand-strong); box-shadow: 0 3px 8px rgba(23,108,85,.16); }
+app-learning-page .bulk-assessment .unknown-button { color: #795007; background: var(--accent-pale); border-color: #d6b56e; }
+app-learning-page .bulk-assessment button:focus-visible { outline: 2px solid var(--brand-strong); outline-offset: 3px; }
+@media (hover: hover) { app-learning-page .bulk-assessment button:not(:disabled):hover { filter: brightness(1.06); } }
+@media (max-width: 350px) { app-learning-page .course-bulk-toolbar { grid-template-columns: auto minmax(0,1fr); } app-learning-page .bulk-count { justify-self: end; } app-learning-page .bulk-assessment { grid-column: 1 / -1; } }
 app-learning-page .selection-retry-note { margin: 10px 14px; padding: 12px; color: #704500; background: var(--accent-pale); border-radius: var(--radius-sm); font-size: 13px; line-height: 1.6; }
 app-learning-page .word-refine-main:has(.word-assessment) .audio { width: 26px; }
 app-learning-page .word-refine-main:not(:has(.word-assessment)) { grid-template-columns: 32px minmax(0, 1fr) 26px; }