feat(ui): point help new-content indicator at the updated tabs and elements

- Replace timestamp comparison (help_last_viewed vs a hardcoded date) with
  a content-version marker (data-help-content-version) read from the
  rendered modal markup, so badge state always reflects the content
  actually served
- Only mark content as viewed when the modal is opened while it contains
  new content; opening a stale pre-upgrade page no longer suppresses the
  badge after a refresh
- Flag the Replay Tutorial button itself with a 'New' chip (hidden by
  default, one-time glow animation) and scroll it into view when
  revealed; tab-level dots now mark getting-started and shortcuts
  instead of documentation
- Translate help.newContentBadge into all 9 locales, reusing the
  established help.documentation.newBadge renderings
- Add HelpManager content-version unit tests (12 cases)
This commit is contained in:
Will Miao
2026-09-03 21:40:18 +08:00
parent a61840b366
commit 03569c62df
14 changed files with 273 additions and 65 deletions
+2 -1
View File
@@ -2028,7 +2028,8 @@
"settings": "Einstellungen & Konfiguration", "settings": "Einstellungen & Konfiguration",
"extensions": "Erweiterungen", "extensions": "Erweiterungen",
"newBadge": "NEU" "newBadge": "NEU"
} },
"newContentBadge": "NEU"
}, },
"update": { "update": {
"title": "Nach Updates suchen", "title": "Nach Updates suchen",
+2 -1
View File
@@ -2028,7 +2028,8 @@
"settings": "Settings & Configuration", "settings": "Settings & Configuration",
"extensions": "Extensions", "extensions": "Extensions",
"newBadge": "NEW" "newBadge": "NEW"
} },
"newContentBadge": "New"
}, },
"update": { "update": {
"title": "Check for Updates", "title": "Check for Updates",
+2 -1
View File
@@ -2028,7 +2028,8 @@
"settings": "Configuración", "settings": "Configuración",
"extensions": "Extensiones", "extensions": "Extensiones",
"newBadge": "NUEVO" "newBadge": "NUEVO"
} },
"newContentBadge": "NUEVO"
}, },
"update": { "update": {
"title": "Comprobar actualizaciones", "title": "Comprobar actualizaciones",
+2 -1
View File
@@ -2028,7 +2028,8 @@
"settings": "Paramètres & Configuration", "settings": "Paramètres & Configuration",
"extensions": "Extensions", "extensions": "Extensions",
"newBadge": "NOUVEAU" "newBadge": "NOUVEAU"
} },
"newContentBadge": "NOUVEAU"
}, },
"update": { "update": {
"title": "Vérifier les mises à jour", "title": "Vérifier les mises à jour",
+2 -1
View File
@@ -2028,7 +2028,8 @@
"settings": "הגדרות ותצורה", "settings": "הגדרות ותצורה",
"extensions": "הרחבות", "extensions": "הרחבות",
"newBadge": "חדש" "newBadge": "חדש"
} },
"newContentBadge": "חדש"
}, },
"update": { "update": {
"title": "בדוק עדכונים", "title": "בדוק עדכונים",
+2 -1
View File
@@ -2028,7 +2028,8 @@
"settings": "設定&構成", "settings": "設定&構成",
"extensions": "拡張機能", "extensions": "拡張機能",
"newBadge": "新着" "newBadge": "新着"
} },
"newContentBadge": "新着"
}, },
"update": { "update": {
"title": "更新確認", "title": "更新確認",
+2 -1
View File
@@ -2028,7 +2028,8 @@
"settings": "설정 & 구성", "settings": "설정 & 구성",
"extensions": "확장", "extensions": "확장",
"newBadge": "신규" "newBadge": "신규"
} },
"newContentBadge": "신규"
}, },
"update": { "update": {
"title": "업데이트 확인", "title": "업데이트 확인",
+2 -1
View File
@@ -2028,7 +2028,8 @@
"settings": "Настройки и конфигурация", "settings": "Настройки и конфигурация",
"extensions": "Расширения", "extensions": "Расширения",
"newBadge": "НОВОЕ" "newBadge": "НОВОЕ"
} },
"newContentBadge": "НОВОЕ"
}, },
"update": { "update": {
"title": "Проверить обновления", "title": "Проверить обновления",
+2 -1
View File
@@ -2028,7 +2028,8 @@
"settings": "设置与配置", "settings": "设置与配置",
"extensions": "扩展", "extensions": "扩展",
"newBadge": "新" "newBadge": "新"
} },
"newContentBadge": "新"
}, },
"update": { "update": {
"title": "检查更新", "title": "检查更新",
+2 -1
View File
@@ -2028,7 +2028,8 @@
"settings": "設定與配置", "settings": "設定與配置",
"extensions": "擴充功能", "extensions": "擴充功能",
"newBadge": "新" "newBadge": "新"
} },
"newContentBadge": "新"
}, },
"update": { "update": {
"title": "檢查更新", "title": "檢查更新",
@@ -167,6 +167,29 @@
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
} }
/* Replay Tutorial button: badge hidden until the button is flagged as new content */
.replay-tutorial-btn .new-content-badge {
display: none;
background-color: rgba(255, 255, 255, 0.22);
color: #fff;
box-shadow: none;
margin-left: 2px;
}
.replay-tutorial-btn.has-new-content .new-content-badge {
display: inline-flex;
}
/* One-time attention pulse when the button is flagged as new content */
@keyframes new-content-glow {
0% { box-shadow: 0 0 0 0 oklch(from var(--lora-accent) l c h / 55%); }
100% { box-shadow: 0 0 0 16px transparent; }
}
.replay-tutorial-btn.has-new-content {
animation: new-content-glow 1.2s ease-out 3;
}
/* Update video list styles */ /* Update video list styles */
.video-list { .video-list {
display: flex; display: flex;
+60 -48
View File
@@ -6,19 +6,11 @@ import { onboardingManager } from './OnboardingManager.js';
*/ */
export class HelpManager { export class HelpManager {
constructor() { constructor() {
this.lastViewedTimestamp = getStorageItem('help_last_viewed', 0); // Version of the help content the user has seen. Compared against the
this.latestContentTimestamp = new Date('2025-10-11').getTime(); // Will be updated from server or config // data-help-content-version marker rendered into the help modal markup,
// so badge state is always derived from the content actually served.
this.viewedContentVersion = getStorageItem('help_viewed_content_version', null);
this.isInitialized = false; this.isInitialized = false;
// Default latest content data - could be fetched from server
this.latestVideoData = {
timestamp: new Date('2024-06-09').getTime(), // Default timestamp
walkthrough: {
id: 'hvKw31YpE-U',
title: 'Getting Started with LoRA Manager'
},
playlistUpdated: true
};
} }
/** /**
@@ -35,9 +27,6 @@ export class HelpManager {
// Check if we need to show the badge // Check if we need to show the badge
this.updateHelpBadge(); this.updateHelpBadge();
// Fetch latest video data (could be implemented to fetch from remote source)
this.fetchLatestVideoData();
this.isInitialized = true; this.isInitialized = true;
return this; return this;
} }
@@ -122,58 +111,81 @@ export class HelpManager {
*/ */
openHelpModal(tabId) { openHelpModal(tabId) {
// Use modalManager to open the help modal // Use modalManager to open the help modal
if (window.modalManager) { if (!window.modalManager) return;
window.modalManager.toggleModal('helpModal');
if (tabId) { const hadNewContent = this.hasNewContent();
this.activateHelpTab(tabId);
}
// Add visual indicator to Documentation tab if there's new content window.modalManager.toggleModal('helpModal');
this.updateDocumentationTabIndicator();
// Update the last viewed timestamp if (tabId) {
this.activateHelpTab(tabId);
}
// Only acknowledge the content as viewed when the user opened the
// modal while it actually contained new content. Opening a stale
// (pre-upgrade) page must not suppress the badge after a refresh.
if (hadNewContent) {
this.updateNewContentTabIndicators();
this.markContentAsViewed(); this.markContentAsViewed();
// Hide the badge
this.hideHelpBadge();
} }
// Hide the badge
this.hideHelpBadge();
} }
/** /**
* Add visual indicator to Documentation tab for new content * Add visual indicator to tabs that received new content
*/ */
updateDocumentationTabIndicator() { updateNewContentTabIndicators() {
const docTab = document.querySelector('.tab-btn[data-tab="documentation"]'); if (!this.hasNewContent()) return;
if (docTab && this.hasNewContent()) {
docTab.classList.add('has-new-content'); // Tabs updated in the 2026-09-03 discoverability release:
// getting-started (Replay Tutorial button) and shortcuts (new cheat-sheet tab)
const NEW_CONTENT_TABS = ['getting-started', 'shortcuts'];
NEW_CONTENT_TABS.forEach(tabId => {
const tab = document.querySelector(`.help-tabs .tab-btn[data-tab="${tabId}"]`);
if (tab) {
tab.classList.add('has-new-content');
}
});
// Point the indicator at the specific new element inside the
// Getting Started tab, and scroll it into view so it is not lost
// below the fold of the modal body.
const replayBtn = document.getElementById('replayTutorialBtn');
if (replayBtn) {
replayBtn.classList.add('has-new-content');
const gettingStartedActive = document.querySelector('#getting-started.tab-pane.active');
if (gettingStartedActive && typeof replayBtn.scrollIntoView === 'function') {
replayBtn.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
} }
} }
/** /**
* Mark content as viewed by saving current timestamp * Mark content as viewed by persisting the version rendered in the DOM.
* No-op when the served markup carries no version marker (stale assets),
* so viewing old content never suppresses the badge for new content.
*/ */
markContentAsViewed() { markContentAsViewed() {
this.lastViewedTimestamp = Date.now(); const currentVersion = this.getCurrentContentVersion();
setStorageItem('help_last_viewed', this.lastViewedTimestamp); if (!currentVersion) return;
this.viewedContentVersion = currentVersion;
setStorageItem('help_viewed_content_version', this.viewedContentVersion);
} }
/** /**
* Fetch latest video data (could be implemented to actually fetch from a remote source) * Read the help content version from the rendered modal markup
* @returns {string|null} Version marker, or null if the served markup has none
*/ */
fetchLatestVideoData() { getCurrentContentVersion() {
// In a real implementation, you'd fetch this from your server const marker = document.querySelector('[data-help-content-version]');
// For now, we'll just use the hardcoded data from constructor return marker ? marker.getAttribute('data-help-content-version') : null;
// Update the timestamp with the latest data
this.latestContentTimestamp = Math.max(this.latestContentTimestamp, this.latestVideoData.timestamp);
// Check again if we need to show the badge with this new data
this.updateHelpBadge();
} }
/** /**
* Update help badge visibility based on timestamps * Update help badge visibility based on viewed vs. served content version
*/ */
updateHelpBadge() { updateHelpBadge() {
if (this.hasNewContent()) { if (this.hasNewContent()) {
@@ -184,11 +196,11 @@ export class HelpManager {
} }
/** /**
* Check if there's new content the user hasn't seen * Check if the served help content is newer than what the user has viewed
*/ */
hasNewContent() { hasNewContent() {
// If user has never viewed the help, or the content is newer than last viewed const currentVersion = this.getCurrentContentVersion();
return this.lastViewedTimestamp === 0 || this.latestContentTimestamp > this.lastViewedTimestamp; return Boolean(currentVersion) && currentVersion !== this.viewedContentVersion;
} }
/** /**
+2 -1
View File
@@ -1,5 +1,5 @@
<!-- Help Modal --> <!-- Help Modal -->
<div id="helpModal" class="modal"> <div id="helpModal" class="modal" data-help-content-version="2026-09-03">
<div class="modal-content help-modal"> <div class="modal-content help-modal">
<button class="close" onclick="modalManager.closeModal('helpModal')">&times;</button> <button class="close" onclick="modalManager.closeModal('helpModal')">&times;</button>
<div class="help-header"> <div class="help-header">
@@ -44,6 +44,7 @@
<button id="replayTutorialBtn" class="replay-tutorial-btn"> <button id="replayTutorialBtn" class="replay-tutorial-btn">
<i class="fas fa-graduation-cap"></i> <i class="fas fa-graduation-cap"></i>
<span>{{ t('help.gettingStarted.replayTutorial') }}</span> <span>{{ t('help.gettingStarted.replayTutorial') }}</span>
<span class="new-content-badge">{{ t('help.newContentBadge') }}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -0,0 +1,162 @@
import { describe, it, beforeEach, expect, vi } from 'vitest';
const HELP_MANAGER_MODULE = new URL('../../../static/js/managers/HelpManager.js', import.meta.url).pathname;
const VIEWED_KEY = 'lora_manager_help_viewed_content_version';
function setupDom({ versionMarker = null } = {}) {
const markerAttr = versionMarker ? ` data-help-content-version="${versionMarker}"` : '';
document.body.innerHTML = `
<div class="help-toggle" id="helpToggleBtn">
<span class="update-badge"></span>
</div>
<div id="helpModal" class="modal"${markerAttr}>
<div class="help-tabs">
<button class="tab-btn" data-tab="getting-started"></button>
<button class="tab-btn" data-tab="shortcuts"></button>
</div>
<div class="tab-pane active" id="getting-started">
<button id="replayTutorialBtn" class="replay-tutorial-btn"></button>
</div>
</div>
`;
}
describe('HelpManager content-version badge logic', () => {
let HelpManager;
beforeEach(async () => {
({ HelpManager } = await import(HELP_MANAGER_MODULE));
});
function badgeIsVisible() {
return document.querySelector('#helpToggleBtn .update-badge').classList.contains('visible');
}
it('has no new content when the served markup carries no version marker', () => {
setupDom({ versionMarker: null });
const manager = new HelpManager();
expect(manager.hasNewContent()).toBe(false);
manager.updateHelpBadge();
expect(badgeIsVisible()).toBe(false);
});
it('has new content when a version marker exists and nothing has been viewed yet', () => {
setupDom({ versionMarker: '2026-09-03' });
const manager = new HelpManager();
expect(manager.hasNewContent()).toBe(true);
manager.updateHelpBadge();
expect(badgeIsVisible()).toBe(true);
});
it('has no new content once the stored viewed version matches the marker', () => {
setupDom({ versionMarker: '2026-09-03' });
localStorage.setItem(VIEWED_KEY, '2026-09-03');
const manager = new HelpManager();
expect(manager.hasNewContent()).toBe(false);
manager.updateHelpBadge();
expect(badgeIsVisible()).toBe(false);
});
it('has new content again when the marker moves to a newer version', () => {
setupDom({ versionMarker: '2026-09-03' });
localStorage.setItem(VIEWED_KEY, '2025-10-11');
const manager = new HelpManager();
expect(manager.hasNewContent()).toBe(true);
});
it('markContentAsViewed persists the DOM marker version', () => {
setupDom({ versionMarker: '2026-09-03' });
const manager = new HelpManager();
manager.markContentAsViewed();
expect(localStorage.getItem(VIEWED_KEY)).toBe('2026-09-03');
expect(manager.hasNewContent()).toBe(false);
});
it('markContentAsViewed is a no-op without a version marker (stale assets)', () => {
setupDom({ versionMarker: null });
const manager = new HelpManager();
manager.markContentAsViewed();
expect(localStorage.getItem(VIEWED_KEY)).toBeNull();
});
it('opening the help modal without new content does not mark it as viewed', () => {
// Regression test: on a stale (pre-upgrade) page the user may open the
// help modal before refreshing; that must not suppress the badge for
// the new content they have not seen yet.
setupDom({ versionMarker: null });
window.modalManager = { toggleModal: vi.fn() };
const manager = new HelpManager();
manager.openHelpModal();
expect(localStorage.getItem(VIEWED_KEY)).toBeNull();
expect(manager.hasNewContent()).toBe(false);
delete window.modalManager;
});
it('opening the help modal with new content marks it as viewed and hides the badge', () => {
setupDom({ versionMarker: '2026-09-03' });
window.modalManager = { toggleModal: vi.fn() };
const manager = new HelpManager();
manager.updateHelpBadge();
expect(badgeIsVisible()).toBe(true);
manager.openHelpModal();
expect(localStorage.getItem(VIEWED_KEY)).toBe('2026-09-03');
expect(badgeIsVisible()).toBe(false);
delete window.modalManager;
});
it('adds new-content indicators to the getting-started and shortcuts tabs', () => {
setupDom({ versionMarker: '2026-09-03' });
const manager = new HelpManager();
manager.updateNewContentTabIndicators();
expect(document.querySelector('.help-tabs .tab-btn[data-tab="getting-started"]').classList.contains('has-new-content')).toBe(true);
expect(document.querySelector('.help-tabs .tab-btn[data-tab="shortcuts"]').classList.contains('has-new-content')).toBe(true);
});
it('flags the Replay Tutorial button and scrolls it into view', () => {
setupDom({ versionMarker: '2026-09-03' });
const replayBtn = document.getElementById('replayTutorialBtn');
replayBtn.scrollIntoView = vi.fn();
const manager = new HelpManager();
manager.updateNewContentTabIndicators();
expect(replayBtn.classList.contains('has-new-content')).toBe(true);
expect(replayBtn.scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'nearest' });
});
it('does not flag the Replay Tutorial button when the content is not new', () => {
setupDom({ versionMarker: '2026-09-03' });
localStorage.setItem(VIEWED_KEY, '2026-09-03');
const manager = new HelpManager();
manager.updateNewContentTabIndicators();
expect(document.getElementById('replayTutorialBtn').classList.contains('has-new-content')).toBe(false);
});
it('does not scroll the Replay Tutorial button when the getting-started tab is inactive', () => {
setupDom({ versionMarker: '2026-09-03' });
document.getElementById('getting-started').classList.remove('active');
const replayBtn = document.getElementById('replayTutorialBtn');
replayBtn.scrollIntoView = vi.fn();
const manager = new HelpManager();
manager.updateNewContentTabIndicators();
expect(replayBtn.scrollIntoView).not.toHaveBeenCalled();
});
});