diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b9e6577 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,61 @@ +# TypesetOK Changelog + +מקור האמת המרכזי לכל עדכוני התוכנה ויומן השינויים של TypesetOK. +המידע בקובץ זה נשאב באופן אוטומטי לחלוניות "מה חדש" ולמודאל הכרזות הגרסה בממשק התוכנה. + +--- + +## [0.30.4] - 2026-10-30 +- **Importance**: minor +- **Badge**: עברית: גרסה נוכחית | en: Current Version +- **Title**: עברית: עדכון שיפורים, טיפוגרפיה ותאימות מקלדת | en: Enhancements, Hebrew Typography & Keyboard Usability +- **Summary**: עברית: עדכון זה מביא תמיכה מלאה במשקלי גופן (Regular/Bold) בסרגל המבנה ובקנבס, שיפור במקשי קיצור במקלדת עברית ותיקוני יציבות. | en: This update brings font weight support (Regular/Bold) across inspector and canvas, Hebrew keyboard shortcut compatibility, and stability fixes. + +### Changes +- [typography] עברית: תמיכה מובנית במשקלי גופן (Bold 700 / Regular 400) בגופנים עבריים ושימורם בייצוא | en: Native font weight support (Bold 700 / Regular 400) for Hebrew fonts with faithful export retention +- [improvements] עברית: תאימות פלטת הפקודות (Ctrl+K) למקלדת עברית עם המקש "ל" ללא צורך בהחלפת שפה | en: Hebrew keyboard layout compatibility for Command Palette (Ctrl+K / Ctrl+ל) +- [fixes] עברית: מניעת השתלטות של מקש הסלאש ("/") בזמן הקלדה חופשית בעורך הטקסט | en: Prevented slash key ("/") from hijacking story editor typing +- [performance] עברית: ייעול מנגנון הרינדור של מסגרות הטקסט בקנבס בצפיפויות מסך גבוהות | en: Optimized canvas text frame rendering on high DPI displays + +--- + +## [0.30.0] - 2026-10-15 +- **Importance**: major +- **Badge**: עברית: עדכון גדול | en: Major Release +- **Title**: עברית: שחרור גרסה ראשית: מנוע עימוד רב-תזרימי ותקני דפוס מקצועיים | en: Major Release: Multi-Flow Layout Engine & Professional Pre-Press +- **Summary**: עברית: קפיצת מדרגה משמעותית לתוכנת TypesetOK עם מנוע Rust Native מהיר, יישור עברי תלת-שכבתי, ייצוא ISO PDF/X-1a ומגן שמות קדושים. | en: A massive milestone for TypesetOK featuring a Rust Native engine, 3-tier Hebrew justification, ISO PDF/X-1a pre-press export, and holy name protection. + +### Highlights +- [rust-engine] icon: sparkle | badge: עברית: מהיר פי 10, en: 10x Faster + - Title: עברית: מנוע עימוד Rust מקורי | en: Native Rust Layout Engine + - Description: עברית: ליבת עימוד דטרמיניסטית וסופר-מהירה המבצעת שבירת שורות אופטימלית בשיטת Knuth-Plass על פני פסקאות עבריות שלמות. | en: Blazing fast deterministic layout engine running Knuth-Plass paragraph breaking across full Hebrew texts. +- [hebrew-justifier] icon: typography | badge: עברית: דיוק טיפוגרפי, en: Typographic Precision + - Title: עברית: יישור עברי מסורתי תלת-שכבתי | en: 3-Tier Traditional Hebrew Justifier + - Description: עברית: שילוב עדין של רווחי מילים גמישים, מתיחת אותיות רפויות (אהלתר"ם) וריווח בין-אותי מיקרומטרי ללא צורך במקפים מכוערים. | en: Harmonious blending of word spacing, wide Hebrew letter dilation, and micro letter-spacing. +- [pdf-x-export] icon: export | badge: עברית: תקן דפוס, en: Print Standard + - Title: עברית: ייצוא תקני ISO PDF/X-1a ו-PDF/X-4 | en: ISO PDF/X-1a & PDF/X-4 Pre-Press + - Description: עברית: הפקת קבצים מוכנים ישירות להדפסת אופסט ודפוס דיגיטלי מסחרי, עם הפרדת צבעים מלאה של 100% K לטקסט חד ונקי. | en: Production-ready PDF export for offset & digital printing, strict 100% pure black channel separation. +- [sacred-text-shield] icon: check | badge: עברית: מסורת ישראל, en: Traditional Rules + - Title: עברית: קדושת הטקסט ונרמול קנוני | en: Sacred Text Shield & Canonical Normalization + - Description: עברית: מניעת שבירת שמות קדושים בכללי No-Break קפדניים, סנכרון גימטריה עברית לעמודים ונרמול סדר ניקוד וטעמים. | en: Strict no-break protection for divine names, Hebrew gematria page numbering, and canonical niqqud normalization. + +### Changes +- [features] עברית: מבנה עמוד רב-תזרימי מלא עבור דפי גמרא (מרכז, רש"י, תוספות, הערות) ומקראות גדולות | en: Full multi-flow page architecture for Talmud pages and rabbinic commentaries +- [features] עברית: ייצוא PDF/X-1a תקני עם פרופילי צבע Fogra 39 ו-ISO Coated v2 | en: Compliant ISO PDF/X-1a export with Fogra 39 and ISO Coated v2 profiles +- [typography] עברית: אלגוריתם מתיחת אותיות עבריות רפויות וריווח מילים גמיש | en: Hebrew dilated letter expansion and elastic word space distribution +- [improvements] עברית: ניהול פרויקטים ומסך פתיחה חדש עם תבניות מהירות לספרים, עלונים וקונטרסים | en: Project manager and fresh welcome dashboard with quick templates for books and booklets +- [fixes] עברית: תיקון חישוב גבולות עמודים בצמדי עמודים (Spreads) בספרים מימין לשמאל | en: Fixed page spread layout calculation for right-to-left Hebrew books + +--- + +## [0.29.0] - 2026-09-20 +- **Importance**: minor +- **Badge**: עברית: תוספים ועריכה | en: Plugins & Editing +- **Title**: עברית: מערכת תוספים מודולרית ועורך טקסט משולב | en: Modular Plugin System & Integrated Story Editor +- **Summary**: עברית: הוספת סביבת הרחבות מבודדת ומאובטחת לתוספים ב-TypeScript/JavaScript, לצד עורך טקסט רציף (Story Editor) עם חיפוש והחלפה. | en: Introduced an isolated plugin architecture for TypeScript/JavaScript extensions alongside a split Story Editor. + +### Changes +- [features] עברית: מנוע פלאגינים מאובטח (PluginEngine) עם הרשאות מוגדרות והידור בזמן ריצה | en: Hardened plugin sandbox engine with strict permission boundaries and runtime compilation +- [features] עברית: עורך טקסט משולב בתצוגת Split View עם ספירת מילים חיה לכל תזרים | en: Integrated Story Editor with split view and live word counts per flow +- [improvements] עברית: ייבוא ישיר של קובצי Word (.docx), Markdown (.md) וטקסט רציף | en: Direct import pipeline for Word (.docx), Markdown (.md), and plain text +- [fixes] עברית: שיפור מנגנון שמירה אוטומטית ומניעת איבוד שינויים בעת יציאה | en: Enhanced auto-save safety checks and unsaved change prevention on quit diff --git a/PROJECT.md b/PROJECT.md index 43c983d..60a1f8f 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -44,6 +44,7 @@ TypesetOK הינה תוכנת DTP (עימוד וסדר דפוס) שולחנית | 19| License & Dependency Notice Audit | Complete license audit, dependency notices, and copyright headers | M5 | Survey / R5 | | 20| E2E Testing Suite (Tiers 1-4) | Comprehensive opaque-box test suite across 4 tiers derived from requirements | E2E-Track | Dual Track | | 21| 100% E2E Pass & Adversarial Hardening | Pass 100% E2E test suite + Tier 5 adversarial testing & verification | M6 (Final) | Dual Track | +| 22| Bilingual What's New System | Side drawer for minor updates, popup dialog for major updates, unread bell indicator, and full Hebrew/English changelog | M6 | Feature Track | --- diff --git a/docs/WHATS_NEW_SYSTEM.md b/docs/WHATS_NEW_SYSTEM.md new file mode 100644 index 0000000..9e601c0 --- /dev/null +++ b/docs/WHATS_NEW_SYSTEM.md @@ -0,0 +1,106 @@ +# מערכת "מה חדש" ויומן עדכונים (What's New & Changelog System) + +מערכת מודרנית, דו-לשונית (עברית ואנגלית) לניהול וחשיפת חידושים ועדכונים למשתמשי TypesetOK. + +--- + +## 🎯 עקרונות חוויית המשתמש (UX Principles) + +1. **עדכונים גדולים (Major Releases):** + - קופץ חלון פופ-אפ ייעודי (`MajorUpdateModal`) באופן אוטומטי בעליית התוכנה בפעם הראשונה לאחר העדכון. + - החלון מעוצב כ-Hero Showcase חגיגי, עם כרטיסיות גריד המדגישות את גולת הכותרת של התכונות החדשות. + - כולל תיבת סימון "אל תציג חלון זה שוב עבור גרסה זו", כפתור "לכל העדכונים והשינויים" (שפותח ישירות את החלונית המלאה), וכפתור סגירה ("הבנתי, תודה!"). + +2. **עדכונים קטנים (Minor & Patch Releases):** + - **אינם מטרידים את המשתמש בפופ-אפ חודרני!** + - מוצגים בחלונית צד ייעודית (`UpdatesDrawer`) הנפתחת בצורה אלגנטית ומאפשרת שיטוט נוח, סינון וחיפוש. + - בסרגל המערכת העליון (`TopSystemBar`) מופיע כפתור פעמון/sparkle עם נקודת התראה כתומה פועמת (`tok-updates-unread-dot`) כשיש עדכון חדש שטרם נקרא. פתיחת החלונית מסמנת את העדכון כנצפה ומעלימה את האינדיקטור. + +3. **דו-לשוניות מלאה (Full Bilingual RTL/LTR Support):** + - כל הממשק, הכרטיסיות, התגיות, הסינונים והטקסטים מוגדרים במלואם הן בעברית והן באנגלית. + - מעבר שפה באפליקציה (עברית ↔ אנגלית) מעדכן באופן מיידי את החלונית והפופ-אפ ואת כיווניות הגלישה והאנימציות (RTL מימין, LTR משמאל). + +--- + +## 🏛️ מבנה הקוד והארכיטקטורה + +המערכת בנויה ממודולים מופרדים ונקיים תחת `packages/tok-ui/src/`: + +``` +packages/tok-ui/src/ +├── updates/ +│ ├── types.ts # טיפוסי נתונים, סכמות ChangelogEntry, UpdateImportance, ChangeCategory +│ ├── changelogData.ts # מאגר נתוני הגרסאות ההיסטוריות והנוכחיות בעברית ובאנגלית +│ ├── updatesManager.ts # שירות סטייט מרכזי: semver comparison, localStorage, unread tracking +│ ├── updatePills.ts # רכיבי UI קטנים: תגיות קטגוריה ובאדג'י גרסה +│ ├── ChangelogEntryCard.ts # כרטיס גרסה מפורט עבור רשימת העדכונים +│ └── UpdatesBellButton.ts # כפתור פעמון סרגל עליון עם אינדיקטור unread חי +├── components/ +│ ├── UpdatesDrawer.ts # חלונית צד (Side Drawer) לעיון מלא, סינון צ'יפים וחיפוש +│ └── MajorUpdateModal.ts # פופ-אפ מודאלי להכרזה על שחרור גרסה ראשית +``` + +--- + +## 🚀 מחזור חיים בעליית התוכנה (Startup Lifecycle) + +בעת פתיחת התוכנה (`TypesetOkApp.init`): +1. נקראת גרסת התוכנה הנוכחית (`getAppVersion`). +2. `UpdatesStateManager` משווה את הגרסה הנוכחית מול `lastSeenMajorVersion` ו-`lastSeenVersion` השמורים ב-`localStorage`: + - **אם הגרסה היא `major`** וטרם הוצגה ולא נדחתה: נקראת `openMajorUpdate(version)` ומוצג הפופ-אפ. + - **אם הגרסה היא `minor` / `patch`**: הפופ-אפ **לא** קופץ; כפתור הפעמון בסרגל העליון מדליק אינדיקטור עדכון ומזמין את המשתמש לעיין בחלונית בשעות הפנאי. + +--- + +## 📝 כיצד להוסיף גרסה חדשה (Adding a New Release) + +כדי לתעד גרסה חדשה, עורכים את `packages/tok-ui/src/updates/changelogData.ts` ומוסיפים רשומה בראש המערך `CHANGELOG_ENTRIES`: + +```typescript +{ + version: '0.31.0', + releaseDate: '2026-11-15', + importance: 'major', // או 'minor' / 'patch' + title: { + he: 'כותרת העדכון בעברית', + en: 'Update Title in English' + }, + summary: { + he: 'תקציר השינויים בעברית...', + en: 'English summary of the changes...' + }, + badge: { + he: 'עדכון גדול', + en: 'Major Release' + }, + // שדה highlights חובה בגרסאות major: + highlights: [ + { + id: 'feature-id', + title: { he: 'שם היכולת', en: 'Feature Name' }, + description: { he: 'תיאור מפורט', en: 'Detailed description' }, + icon: 'sparkle', // שם אייקון מתוך IconName + badge: { he: 'חדש', en: 'New' } + } + ], + changes: [ + { + category: 'features', // 'features' | 'improvements' | 'fixes' | 'typography' | 'performance' + text: { he: 'פירוט השינוי בעברית', en: 'English change description' }, + tag: { he: 'תכונה חדשה', en: 'New Feature' } + } + ] +} +``` + +--- + +## 🔍 פקודות בדיקה (Automated Tests) + +המערכת מגובה ב-13 בדיקות יחידה ואינטגרציה ב-`test/whats_new_system.test.mjs`, הפועלות כחלק מסוויטת הבדיקות הרשמית של הפרויקט (`npm test`): +- שלמות מאגר הגרסאות ושדות חובה דו-לשוניים. +- דטרמיניסטיות השוואת Semver. +- לוגיקת החלטה בין פופ-אפ (Major) לחלונית (Minor). +- תקינות סינונים וחיפוש. +- כיסוי מפתחות i18n בעברית ובאנגלית. +- אינטגרציית רכיבי ה-UI (סרגל עליון, תפריטים, Command Palette). diff --git a/package.json b/package.json index a0cd31b..c86e9b2 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,13 @@ ], "scripts": { "build:ts": "tsc --build", + "sync:changelog": "node scripts/sync-changelog.mjs", "bundle:ui": "node scripts/bundle-ui.mjs", "lint": "tsc --build", "build": "npm run build:ts && npm run bundle:ui", "package:desktop": "node scripts/build-desktop-bundle.mjs", "package:installer": "node scripts/build-installer.mjs", - "test": "node --test test/frontend.test.mjs test/milestone_features.test.mjs test/story_editor.test.mjs test/outcome_validation.test.mjs test/importer.test.mjs test/lifecycle_flow.test.mjs test/geometric_fidelity.test.mjs test/phase3_integration.test.mjs test/issue20_regression.test.mjs", + "test": "node --test test/frontend.test.mjs test/milestone_features.test.mjs test/story_editor.test.mjs test/outcome_validation.test.mjs test/importer.test.mjs test/lifecycle_flow.test.mjs test/geometric_fidelity.test.mjs test/phase3_integration.test.mjs test/issue20_regression.test.mjs test/whats_new_system.test.mjs", "start": "electron packages/tok-electron/dist/main.js", "perf:startup": "node scripts/measure-startup.mjs" }, diff --git a/packages/tok-ui/src/app.ts b/packages/tok-ui/src/app.ts index 8584c3c..f290069 100644 --- a/packages/tok-ui/src/app.ts +++ b/packages/tok-ui/src/app.ts @@ -1,3 +1,4 @@ +import { getAppVersion } from './appInfo'; import { TopSystemBar } from './components/TopSystemBar'; import { StructureBar } from './components/StructureBar'; import { ContextualInspector } from './components/ContextualInspector'; @@ -9,6 +10,9 @@ import { ExportDialog, ExportOptions } from './components/ExportDialog'; import { WelcomeModal, addRecentProject, updateProjectClosedTime } from './components/WelcomeModal'; import { SettingsModal } from './components/SettingsModal'; import { AboutModal } from './components/AboutModal'; +import { UpdatesDrawer } from './components/UpdatesDrawer'; +import { MajorUpdateModal } from './components/MajorUpdateModal'; +import { UpdatesStateManager } from './updates/updatesManager'; import { UserGuideModal } from './components/UserGuideModal'; import { TextStatsModal } from './components/TextStatsModal'; import { ImportTemplateModal } from './components/ImportTemplateModal'; @@ -136,6 +140,8 @@ export class TypesetOkApp { private welcomeModalInstance?: WelcomeModal; private settingsModalInstance?: SettingsModal; private aboutModalInstance?: AboutModal; + private updatesDrawerInstance?: UpdatesDrawer; + private majorUpdateModalInstance?: MajorUpdateModal; private exportDialogInstance?: ExportDialog; private userGuideModalInstance?: UserGuideModal; private textStatsModalInstance?: TextStatsModal; @@ -266,6 +272,7 @@ export class TypesetOkApp { onOpenProjects: () => this.welcomeModal.show(), onOpenSettings: () => this.settingsModal.show(), onOpenAbout: () => this.aboutModal.show(), + onOpenWhatsNew: () => this.openWhatsNew(), onUndo: () => this.runEditCommand('undo'), onRedo: () => this.runEditCommand('redo'), onRenameDocument: (newTitle) => this.renameDocument(newTitle) @@ -533,6 +540,11 @@ export class TypesetOkApp { this.pluginEngine.loadPlugins().catch(console.error); }); + // 8b. Check for major updates popup on startup + runWhenIdle(() => { + this.checkStartupUpdatesPrompt(); + }); + // 8. Initialize default multi-flow document state this.activeFlowId = 'gemara'; this.storyEditor.loadStory(this.documentState.flows.gemara); @@ -874,6 +886,7 @@ export class TypesetOkApp { if (guide === 'baseline' && current.baseline !== visible) this.canvas.toggleBaselineGuide(); }, getZoom: () => this.canvas.getZoom(), + onOpenWhatsNew: () => this.openWhatsNew(), setZoom: (z) => { this.canvas.setZoom(z); this.statusBar.updateStats({ zoom: z }); @@ -890,7 +903,8 @@ export class TypesetOkApp { onClose: () => { if (!this.hasOpenDocument) this.welcomeModal.show(); }, - onCheckUpdates: () => this.settingsModal.show('updates') + onCheckUpdates: () => this.settingsModal.show('updates'), + onOpenWhatsNew: () => this.openWhatsNew() }); this.root.appendChild(this.aboutModalInstance.element); } @@ -1644,10 +1658,61 @@ export class TypesetOkApp { public openAbout(): void { this.aboutModal.show(); } + public get updatesDrawer(): UpdatesDrawer { + if (!this.updatesDrawerInstance) { + this.updatesDrawerInstance = new UpdatesDrawer({ + onClose: () => {}, + onCheckUpdates: () => this.settingsModal.show('updates'), + onOpenSettings: () => this.settingsModal.show('updates') + }); + this.root.appendChild(this.updatesDrawerInstance.element); + } + return this.updatesDrawerInstance; + } + + public get majorUpdateModal(): MajorUpdateModal { + if (!this.majorUpdateModalInstance) { + this.majorUpdateModalInstance = new MajorUpdateModal({ + onClose: () => {}, + onExploreAll: () => this.updatesDrawer.show() + }); + this.root.appendChild(this.majorUpdateModalInstance.element); + } + return this.majorUpdateModalInstance; + } + + public openWhatsNew(version?: string): void { + this.updatesDrawer.show(version); + } + + public openMajorUpdate(version?: string): void { + this.majorUpdateModal.show(version); + } + + private checkStartupUpdatesPrompt(): void { + const updatesManager = UpdatesStateManager.getInstance(); + getAppVersion().then((ver: string) => { + const version = ver || updatesManager.getLatestEntry()?.version; + if (version && updatesManager.shouldPromptMajorUpdate(version)) { + this.openMajorUpdate(version); + } + }).catch((e: any) => { + console.warn('[UPDATES] Startup check error:', e); + }); + } + /** Built-in palette commands in the current UI language (page & layout modifying only). */ private buildCommands(): PaletteItem[] { return [ + { + id: 'cmd-whats-new', + icon: 'sparkle', + category: t('sidebarAbout'), + title: t('commandPaletteWhatsNew'), + subtitle: t('whatsNewSubtitle'), + action: () => this.handleSystemAction('open-whats-new') + }, { id: 'cmd-full-justify', icon: 'alignJustify', diff --git a/packages/tok-ui/src/components/AboutModal.ts b/packages/tok-ui/src/components/AboutModal.ts index ac72ae2..f30a82a 100644 --- a/packages/tok-ui/src/components/AboutModal.ts +++ b/packages/tok-ui/src/components/AboutModal.ts @@ -6,6 +6,7 @@ import { fillAppVersion } from '../appInfo'; export interface AboutModalCallbacks { onClose: () => void; onCheckUpdates?: () => void; + onOpenWhatsNew?: () => void; } const REPO_URL = 'https://github.com/TypesetOK/typesetok'; @@ -224,6 +225,20 @@ export class AboutModal { } })); + foot.appendChild(button(t('whatsNewTitle'), { + className: 'tok-btn', + icon: 'sparkle', + attrs: { 'data-focus-key': 'whats-new' }, + onClick: () => { + this.hide(); + if (this.callbacks.onOpenWhatsNew) { + this.callbacks.onOpenWhatsNew(); + } else { + (window as any).tokApp?.openWhatsNew?.(); + } + } + })); + foot.appendChild(el('span', 'tok-grow')); foot.appendChild(button(t('aboutClose'), { diff --git a/packages/tok-ui/src/components/MajorUpdateModal.ts b/packages/tok-ui/src/components/MajorUpdateModal.ts new file mode 100644 index 0000000..6c385ba --- /dev/null +++ b/packages/tok-ui/src/components/MajorUpdateModal.ts @@ -0,0 +1,207 @@ +import { t, i18n, Language } from '../i18n'; +import { el, icon, iconButton, button } from '../ui'; +import { ModalController } from './ModalController'; +import { UpdatesStateManager } from '../updates/updatesManager'; +import { ChangelogEntry, HighlightCard } from '../updates/types'; +import { createVersionBadge } from '../updates/updatePills'; +import { IconName } from '../icons'; + +export interface MajorUpdateModalCallbacks { + onClose: () => void; + onExploreAll: () => void; +} + +export class MajorUpdateModal { + public element: HTMLElement; + private callbacks: MajorUpdateModalCallbacks; + private isVisible = false; + private modal: ModalController; + private stateManager: UpdatesStateManager; + private currentEntry?: ChangelogEntry; + private dontShowAgainCheckbox?: HTMLInputElement; + + constructor(callbacks: MajorUpdateModalCallbacks, customManager?: UpdatesStateManager) { + this.callbacks = callbacks; + this.stateManager = customManager || UpdatesStateManager.getInstance(); + this.element = el('div', 'tok-overlay tok-major-update-overlay'); + + this.modal = new ModalController(this.element, () => this.hide(), { closeOnBackdrop: true }); + + i18n.onChange(() => { + if (this.isVisible) this.render(); + }); + } + + public show(targetVersion?: string): void { + if (targetVersion) { + this.currentEntry = this.stateManager.getEntry(targetVersion); + } else { + this.currentEntry = this.stateManager.getLatestMajorEntry() || this.stateManager.getLatestEntry(); + } + + if (!this.currentEntry) return; + + this.isVisible = true; + this.element.classList.add('tok-open'); + this.render(); + this.modal.opened(); + } + + public hide(): void { + if (!this.isVisible) return; + + if (this.currentEntry) { + if (this.dontShowAgainCheckbox?.checked) { + this.stateManager.dismissMajorPrompt(this.currentEntry.version); + } else { + this.stateManager.markVersionAsSeen(this.currentEntry.version); + } + } + + this.isVisible = false; + this.element.classList.remove('tok-open'); + this.modal.closed(); + this.callbacks.onClose(); + } + + public isOpen(): boolean { + return this.isVisible; + } + + public render(): void { + if (!this.currentEntry) return; + + const focusKey = this.modal.captureFocus(); + this.element.innerHTML = ''; + this.element.dir = i18n.getDirection(); + const lang: Language = i18n.getLanguage(); + + const dialog = el('div', 'tok-dialog tok-major-dialog'); + dialog.setAttribute('role', 'dialog'); + dialog.setAttribute('aria-modal', 'true'); + dialog.setAttribute('aria-label', t('whatsNewMajorTitle')); + + // 1. Dialog Header + const head = el('header', 'tok-dialog-head tok-major-head'); + + const brandMark = el('span', 'tok-major-hero-icon', { 'aria-hidden': 'true' }); + brandMark.appendChild(icon('sparkle', 24)); + head.appendChild(brandMark); + + const headText = el('div', 'tok-dialog-head-text'); + const titleRow = el('div', 'tok-major-head-row'); + titleRow.appendChild(el('h2', undefined, { 'data-modal-title': '' }, t('whatsNewMajorTitle'))); + titleRow.appendChild(createVersionBadge(this.currentEntry.importance, this.currentEntry.badge)); + headText.appendChild(titleRow); + + headText.appendChild(el('p', undefined, undefined, t('whatsNewMajorSubtitle'))); + head.appendChild(headText); + + head.appendChild(iconButton('close', t('aboutClose'), () => this.hide(), { + attrs: { 'data-focus-key': 'close-x' } + })); + dialog.appendChild(head); + + // 2. Dialog Body (content & highlights container) + const body = el('div', 'tok-dialog-body tok-major-body'); + + // Hero title banner + const heroBanner = el('div', 'tok-major-hero-banner'); + const versionLabel = el('span', 'tok-major-version-pill', { dir: 'ltr' }, `v${this.currentEntry.version}`); + heroBanner.appendChild(versionLabel); + + const mainTitle = el('h3', 'tok-major-main-title', undefined, this.currentEntry.title[lang] || this.currentEntry.title.en); + heroBanner.appendChild(mainTitle); + + const summaryPara = el('p', 'tok-major-summary', undefined, this.currentEntry.summary[lang] || this.currentEntry.summary.en); + heroBanner.appendChild(summaryPara); + + body.appendChild(heroBanner); + + // Showcase Cards Grid + const grid = el('div', 'tok-major-showcase-grid'); + if (this.currentEntry.highlights && this.currentEntry.highlights.length > 0) { + for (const cardData of this.currentEntry.highlights) { + grid.appendChild(this.createShowcaseCard(cardData, lang)); + } + } else { + // Fallback highlights from entry changes + for (const change of this.currentEntry.changes.slice(0, 4)) { + const fallbackCard: HighlightCard = { + id: change.category, + title: change.tag || { he: 'עדכון מרכזי', en: 'Highlight' }, + description: change.text, + icon: 'sparkle' + }; + grid.appendChild(this.createShowcaseCard(fallbackCard, lang)); + } + } + + body.appendChild(grid); + dialog.appendChild(body); + + // 3. Dialog Footer + const foot = el('footer', 'tok-dialog-foot tok-major-foot'); + + // Don't show again checkbox + const checkboxLabel = el('label', 'tok-checkbox-label'); + this.dontShowAgainCheckbox = el('input', 'tok-checkbox', { + type: 'checkbox' + }) as HTMLInputElement; + checkboxLabel.appendChild(this.dontShowAgainCheckbox); + checkboxLabel.appendChild(el('span', undefined, undefined, t('whatsNewDontShowAgain'))); + foot.appendChild(checkboxLabel); + + foot.appendChild(el('span', 'tok-grow')); + + // Explore All Button + const exploreBtn = button(t('whatsNewExploreAll'), { + className: 'tok-btn tok-btn-secondary', + icon: 'pages', + attrs: { 'data-focus-key': 'explore-all' }, + onClick: () => { + this.hide(); + this.callbacks.onExploreAll(); + } + }); + foot.appendChild(exploreBtn); + + // Primary Got It Button + const gotItBtn = button(t('whatsNewGotIt'), { + className: 'tok-btn tok-btn-primary', + attrs: { 'data-focus-key': 'got-it' }, + onClick: () => this.hide() + }); + foot.appendChild(gotItBtn); + + dialog.appendChild(foot); + this.element.appendChild(dialog); + + this.modal.afterRender(focusKey); + } + + private createShowcaseCard(card: HighlightCard, lang: Language): HTMLElement { + const cardEl = el('div', 'tok-major-card'); + + const cardHead = el('div', 'tok-major-card-head'); + const iconWrap = el('div', 'tok-major-card-icon'); + iconWrap.appendChild(icon((card.icon as IconName) || 'sparkle', 20)); + cardHead.appendChild(iconWrap); + + const titleWrap = el('div', 'tok-major-card-title-wrap'); + const cardTitle = el('h4', 'tok-major-card-title', undefined, card.title[lang] || card.title.en); + titleWrap.appendChild(cardTitle); + + if (card.badge) { + const cardBadge = el('span', 'tok-badge tok-badge-card', undefined, card.badge[lang] || card.badge.en); + titleWrap.appendChild(cardBadge); + } + cardHead.appendChild(titleWrap); + cardEl.appendChild(cardHead); + + const descPara = el('p', 'tok-major-card-desc', undefined, card.description[lang] || card.description.en); + cardEl.appendChild(descPara); + + return cardEl; + } +} diff --git a/packages/tok-ui/src/components/SettingsModal.ts b/packages/tok-ui/src/components/SettingsModal.ts index e4e9f7a..ff295e0 100644 --- a/packages/tok-ui/src/components/SettingsModal.ts +++ b/packages/tok-ui/src/components/SettingsModal.ts @@ -19,6 +19,7 @@ export interface SettingsModalCallbacks { setGuide?: (guide: 'margins' | 'baseline', visible: boolean) => void; getZoom?: () => number; setZoom?: (zoom: number) => void; + onOpenWhatsNew?: () => void; } type SettingsTab = 'appearance' | 'accessibility' | 'logs' | 'updates' | 'plugins'; @@ -467,6 +468,27 @@ export class SettingsModal { const resultBox = el('div', 'tok-card', { 'aria-live': 'polite', style: 'font-size:13px;color:var(--tok-text-secondary)' }, t('updatesHint')); container.appendChild(resultBox); + const historyCard = el('div', 'tok-card tok-card-row', { style: 'margin-top: 12px;' }); + const histInfo = el('div', undefined, { style: 'display:flex;flex-direction:column;gap:2px' }); + histInfo.appendChild(el('span', undefined, { style: 'font-size:13px;font-weight:600' }, t('whatsNewTitle'))); + histInfo.appendChild(el('span', undefined, { style: 'font-size:12px;color:var(--tok-text-secondary)' }, t('whatsNewSubtitle'))); + historyCard.appendChild(histInfo); + + const histBtn = button(t('whatsNewViewHistory'), { + className: 'tok-btn tok-btn-secondary', + icon: 'sparkle', + attrs: { 'data-focus-key': 'open-whats-new-tab' }, + onClick: () => { + this.hide(); + if (this.callbacks.onOpenWhatsNew) { + this.callbacks.onOpenWhatsNew(); + } else { + (window as any).tokApp?.openWhatsNew?.(); + } + } + }); + historyCard.appendChild(histBtn); + container.appendChild(historyCard); const showLatest = (version?: string) => { resultBox.innerHTML = ''; diff --git a/packages/tok-ui/src/components/TopSystemBar.ts b/packages/tok-ui/src/components/TopSystemBar.ts index 0f93825..d802f2d 100644 --- a/packages/tok-ui/src/components/TopSystemBar.ts +++ b/packages/tok-ui/src/components/TopSystemBar.ts @@ -1,3 +1,4 @@ +import { UpdatesBellButton } from '../updates/UpdatesBellButton'; import { ViewMode } from '../types'; import { t, i18n } from '../i18n'; import { el, icon, iconButton, kbd, button } from '../ui'; @@ -11,6 +12,7 @@ export interface TopSystemBarCallbacks { onToggleLanguage?: () => void; onOpenSettings?: () => void; onOpenAbout?: () => void; + onOpenWhatsNew?: () => void; onUndo?: () => void; onRedo?: () => void; onRenameDocument?: (newTitle: string) => void; @@ -36,6 +38,7 @@ const MENU_ITEMS: MenuItem[] = [ { labelKey: 'menuExportPdf', shortcut: 'Ctrl+E', action: 'export-pdf' }, { type: 'separator' }, { labelKey: 'sidebarSettings', shortcut: 'Ctrl+,', action: 'open-settings' }, + { labelKey: 'menuWhatsNew', action: 'open-whats-new' }, { labelKey: 'sidebarAbout', action: 'open-about' }, { type: 'separator' }, { labelKey: 'menuExit', shortcut: 'Ctrl+Q', action: 'quit-app' } @@ -302,6 +305,12 @@ export class TopSystemBar { } end.appendChild(undo); end.appendChild(redo); + if (this.callbacks.onOpenWhatsNew) { + const bell = new UpdatesBellButton({ + onClick: () => this.callbacks.onOpenWhatsNew?.() + }); + end.appendChild(bell.element); + } this.element.appendChild(end); } diff --git a/packages/tok-ui/src/components/UpdatesDrawer.ts b/packages/tok-ui/src/components/UpdatesDrawer.ts new file mode 100644 index 0000000..ae81c5d --- /dev/null +++ b/packages/tok-ui/src/components/UpdatesDrawer.ts @@ -0,0 +1,207 @@ +import { t, i18n } from '../i18n'; +import { el, icon, iconButton, button } from '../ui'; +import { ModalController } from './ModalController'; +import { UpdatesStateManager } from '../updates/updatesManager'; +import { ChangelogEntryCard } from '../updates/ChangelogEntryCard'; +import { UpdatesFilterOptions, ChangeCategory, UpdateImportance } from '../updates/types'; + +export interface UpdatesDrawerCallbacks { + onClose: () => void; + onCheckUpdates?: () => void; + onOpenSettings?: () => void; +} + +export class UpdatesDrawer { + public element: HTMLElement; + private callbacks: UpdatesDrawerCallbacks; + private isVisible = false; + private modal: ModalController; + private stateManager: UpdatesStateManager; + private filterOptions: UpdatesFilterOptions = { category: 'all', importance: 'all', searchQuery: '' }; + private cardsContainer!: HTMLElement; + private searchInput!: HTMLInputElement; + + constructor(callbacks: UpdatesDrawerCallbacks, customManager?: UpdatesStateManager) { + this.callbacks = callbacks; + this.stateManager = customManager || UpdatesStateManager.getInstance(); + this.element = el('div', 'tok-overlay tok-updates-overlay'); + + this.modal = new ModalController(this.element, () => this.hide(), { closeOnBackdrop: true }); + + i18n.onChange(() => { + if (this.isVisible) this.render(); + }); + } + + public show(targetVersion?: string): void { + this.isVisible = true; + this.element.classList.add('tok-open'); + + // Mark current updates as seen when user opens the drawer + if (targetVersion) { + this.stateManager.markVersionAsSeen(targetVersion); + } else { + this.stateManager.markAllAsSeen(); + } + + this.render(); + this.modal.opened(); + } + + public hide(): void { + if (!this.isVisible) return; + this.isVisible = false; + this.element.classList.remove('tok-open'); + this.modal.closed(); + this.callbacks.onClose(); + } + + public isOpen(): boolean { + return this.isVisible; + } + + public render(): void { + const focusKey = this.modal.captureFocus(); + this.element.innerHTML = ''; + this.element.dir = i18n.getDirection(); + + // Drawer container + const drawer = el('div', 'tok-drawer tok-updates-drawer'); + drawer.setAttribute('role', 'dialog'); + drawer.setAttribute('aria-modal', 'true'); + drawer.setAttribute('aria-label', t('whatsNewTitle')); + + // 1. Drawer Header + const head = el('header', 'tok-drawer-head'); + const headIconWrap = el('div', 'tok-drawer-head-icon'); + headIconWrap.appendChild(icon('sparkle', 22)); + head.appendChild(headIconWrap); + + const headText = el('div', 'tok-dialog-head-text'); + headText.appendChild(el('h2', undefined, { 'data-modal-title': '' }, t('whatsNewTitle'))); + headText.appendChild(el('p', undefined, undefined, t('whatsNewSubtitle'))); + head.appendChild(headText); + + head.appendChild(iconButton('close', t('aboutClose'), () => this.hide(), { + attrs: { 'data-focus-key': 'close-x' } + })); + drawer.appendChild(head); + + // 2. Filter & Search Controls Bar + const filterSection = el('div', 'tok-updates-filter-bar'); + + // Search input + const searchWrap = el('div', 'tok-updates-search-wrap'); + searchWrap.appendChild(icon('search', 16)); + this.searchInput = el('input', 'tok-input tok-updates-search-input', { + type: 'search', + placeholder: t('whatsNewSearchPlaceholder'), + 'aria-label': t('whatsNewSearchPlaceholder'), + value: this.filterOptions.searchQuery || '' + }) as HTMLInputElement; + + this.searchInput.addEventListener('input', () => { + this.filterOptions.searchQuery = this.searchInput.value; + this.populateCards(); + }); + searchWrap.appendChild(this.searchInput); + filterSection.appendChild(searchWrap); + + // Filter Chips + const chipsRow = el('div', 'tok-updates-filter-chips', { role: 'tablist' }); + + const filterPresets: Array<{ id: string; label: string; importance?: UpdateImportance | 'all'; category?: ChangeCategory | 'all' }> = [ + { id: 'all', label: t('whatsNewFilterAll'), importance: 'all', category: 'all' }, + { id: 'major', label: t('whatsNewFilterMajor'), importance: 'major', category: 'all' }, + { id: 'improvements', label: t('whatsNewFilterMinor'), importance: 'all', category: 'improvements' }, + { id: 'fixes', label: t('whatsNewFilterFixes'), importance: 'all', category: 'fixes' }, + { id: 'typography', label: t('whatsNewFilterTypography'), importance: 'all', category: 'typography' } + ]; + + for (const preset of filterPresets) { + const isSelected = (preset.importance ? this.filterOptions.importance === preset.importance : true) && + (preset.category ? this.filterOptions.category === preset.category : true); + + const chip = el('button', `tok-filter-chip ${isSelected ? 'tok-filter-chip-active' : ''}`, { + type: 'button', + role: 'tab', + 'aria-selected': String(isSelected) + }, preset.label); + + chip.addEventListener('click', () => { + this.filterOptions.importance = preset.importance; + this.filterOptions.category = preset.category; + drawer.querySelectorAll('.tok-filter-chip').forEach((c) => { + c.classList.remove('tok-filter-chip-active'); + c.setAttribute('aria-selected', 'false'); + }); + chip.classList.add('tok-filter-chip-active'); + chip.setAttribute('aria-selected', 'true'); + this.populateCards(); + }); + + chipsRow.appendChild(chip); + } + + filterSection.appendChild(chipsRow); + drawer.appendChild(filterSection); + + // 3. Scrollable Changelog Cards List + const body = el('div', 'tok-drawer-body tok-updates-body'); + this.cardsContainer = el('div', 'tok-updates-cards-list'); + body.appendChild(this.cardsContainer); + drawer.appendChild(body); + + // 4. Drawer Footer + const foot = el('footer', 'tok-drawer-foot'); + + const checkBtn = button(t('whatsNewCheckUpdates'), { + className: 'tok-btn tok-btn-secondary', + icon: 'refresh', + attrs: { 'data-focus-key': 'check-net' }, + onClick: () => { + if (this.callbacks.onCheckUpdates) { + this.callbacks.onCheckUpdates(); + } else if (this.callbacks.onOpenSettings) { + this.hide(); + this.callbacks.onOpenSettings(); + } + } + }); + foot.appendChild(checkBtn); + + foot.appendChild(el('span', 'tok-grow')); + + const closeBtn = button(t('whatsNewGotIt'), { + className: 'tok-btn tok-btn-primary', + attrs: { 'data-focus-key': 'close-btn' }, + onClick: () => this.hide() + }); + foot.appendChild(closeBtn); + + drawer.appendChild(foot); + this.element.appendChild(drawer); + + this.populateCards(); + this.modal.afterRender(focusKey); + } + + private populateCards(): void { + this.cardsContainer.innerHTML = ''; + const lang = i18n.getLanguage(); + const entries = this.stateManager.filterEntries(this.filterOptions, lang); + + if (entries.length === 0) { + const empty = el('div', 'tok-updates-empty-state'); + empty.appendChild(icon('info', 28)); + empty.appendChild(el('p', undefined, undefined, t('whatsNewNoResults'))); + this.cardsContainer.appendChild(empty); + return; + } + + for (const entry of entries) { + const card = new ChangelogEntryCard(entry); + this.cardsContainer.appendChild(card.element); + } + } +} diff --git a/packages/tok-ui/src/i18n.ts b/packages/tok-ui/src/i18n.ts index f083c20..ef1ec3c 100644 --- a/packages/tok-ui/src/i18n.ts +++ b/packages/tok-ui/src/i18n.ts @@ -176,6 +176,35 @@ export const strings: Translations = { updatesCurrentChannel: { he: 'ערוץ שחרור רשמי יציב', en: 'Official stable channel' }, updatesHint: { he: 'לחצו על "בדיקת עדכונים" כדי לבדוק אם יש גרסה חדשה ב-GitHub.', en: 'Click "Check for updates" to look for a new release on GitHub.' }, + // What's New & Changelog System + whatsNewTitle: { he: 'מה התחדש ב-TypesetOK?', en: "What's New in TypesetOK?" }, + whatsNewSubtitle: { he: 'יומן שינויים, תכונות חדשות ושיפורים טיפוגרפיים', en: 'Changelog, new features, and typography improvements' }, + whatsNewMajorTitle: { he: 'שחרור גרסה ראשית', en: 'Major Release Announcement' }, + whatsNewMajorSubtitle: { he: 'תכונות חדשות ושיפורים מרכזיים שנחתו בגרסה זו', en: 'New capabilities and core highlights in this version' }, + whatsNewBadgeMajor: { he: 'עדכון גדול', en: 'Major' }, + whatsNewBadgeMinor: { he: 'עדכון קל', en: 'Minor' }, + whatsNewBadgePatch: { he: 'תיקונים', en: 'Patch' }, + whatsNewFilterAll: { he: 'הכל', en: 'All' }, + whatsNewFilterMajor: { he: 'עדכונים גדולים', en: 'Major Updates' }, + whatsNewFilterMinor: { he: 'שיפורים', en: 'Improvements' }, + whatsNewFilterFixes: { he: 'תיקונים', en: 'Bug Fixes' }, + whatsNewFilterTypography: { he: 'טיפוגרפיה ועברית', en: 'Typography' }, + whatsNewSearchPlaceholder: { he: 'חיפוש בעדכונים ובשינויים…', en: 'Search updates and changes…' }, + whatsNewExploreAll: { he: 'לכל העדכונים והשינויים', en: 'View Full Changelog' }, + whatsNewGotIt: { he: 'הבנתי, תודה!', en: 'Got it, thanks!' }, + whatsNewDontShowAgain: { he: 'אל תציג חלון זה שוב עבור גרסה זו', en: "Don't show this popup again for this version" }, + whatsNewViewHistory: { he: 'היסטוריית גרסאות', en: 'Version History' }, + whatsNewCheckUpdates: { he: 'בדוק עדכונים זמינים', en: 'Check for Updates' }, + whatsNewNoResults: { he: 'לא נמצאו עדכונים התואמים את החיפוש', en: 'No updates match your search' }, + whatsNewCatFeatures: { he: 'תכונות חדשות', en: 'New Features' }, + whatsNewCatImprovements: { he: 'שיפורים ודיוק', en: 'Improvements' }, + whatsNewCatFixes: { he: 'תיקוני תקלות', en: 'Bug Fixes' }, + whatsNewCatTypography: { he: 'עברית וסדר דפוס', en: 'Hebrew Typography' }, + whatsNewCatPerformance: { he: 'ביצועים ויציבות', en: 'Performance & Stability' }, + whatsNewUnreadAria: { he: 'ישנם עדכונים חדשים שלא נצפו', en: 'There are unread updates' }, + menuWhatsNew: { he: 'מה חדש בתוכנה…', en: "What's New…" }, + commandPaletteWhatsNew: { he: 'מה חדש ויומן עדכונים', en: "What's New & Changelog" }, + // Plugins Settings pluginsInstalled: { he: 'תוספים מותקנים', en: 'Installed plugins' }, pluginsOpenFolder: { he: 'פתיחת תיקיית התוספים', en: 'Open Plugins Directory' }, diff --git a/packages/tok-ui/src/index.html b/packages/tok-ui/src/index.html index 43bc578..24d4c78 100644 --- a/packages/tok-ui/src/index.html +++ b/packages/tok-ui/src/index.html @@ -795,6 +795,460 @@ @keyframes tokRise { from { opacity: 0; transform: translateY(10px) scale(0.985); } to { opacity: 1; transform: none; } } @keyframes tokRiseToast { from { opacity: 0; transform: translate(-50%, 8px); } to { opacity: 1; transform: translate(-50%, 0); } } @keyframes tokPop { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } } + + /* ========================================================= + What's New, Updates Drawer & Major Release Popup Styles + ========================================================= */ + + /* Bell / Updates Action Button & Unread Dot */ + .tok-updates-bell-btn { + position: relative; + } + .tok-updates-unread-dot { + display: none; + position: absolute; + top: 5px; + inset-inline-end: 5px; + width: 8px; + height: 8px; + border-radius: 50%; + background: #f59e0b; + box-shadow: 0 0 0 2px var(--tok-bg-app); + } + .tok-updates-unread-dot.tok-visible { + display: block; + animation: tokPulse 2s infinite ease-in-out; + } + @keyframes tokPulse { + 0%, 100% { transform: scale(1); opacity: 1; } + 50% { transform: scale(1.3); opacity: 0.8; } + } + + /* Side Drawer */ + .tok-updates-overlay { + position: fixed; + inset: 0; + z-index: 1000; + background: rgba(0, 0, 0, 0.45); + backdrop-filter: blur(2px); + display: flex; + justify-content: flex-end; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; + } + [dir="ltr"] .tok-updates-overlay { + justify-content: flex-start; + } + .tok-updates-overlay.tok-open { + opacity: 1; + pointer-events: auto; + } + .tok-updates-drawer { + width: 480px; + max-width: 90vw; + height: 100vh; + background: var(--tok-bg-surface-1); + box-shadow: -4px 0 24px rgba(0, 0, 0, 0.2); + display: flex; + flex-direction: column; + transform: translateX(100%); + transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1); + } + [dir="ltr"] .tok-updates-drawer { + box-shadow: 4px 0 24px rgba(0, 0, 0, 0.2); + transform: translateX(-100%); + } + .tok-updates-overlay.tok-open .tok-updates-drawer { + transform: translateX(0); + } + + /* Drawer Header & Filter */ + .tok-drawer-head { + height: 64px; + flex: none; + display: flex; + align-items: center; + gap: 12px; + padding: 0 20px; + border-bottom: 1px solid var(--tok-border-subtle); + background: var(--tok-bg-surface-2); + } + .tok-drawer-head-icon { + width: 38px; + height: 38px; + border-radius: 10px; + background: rgba(30, 74, 158, 0.12); + color: #1E4A9E; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + .tok-updates-filter-bar { + padding: 12px 20px; + background: var(--tok-bg-surface-1); + border-bottom: 1px solid var(--tok-border-subtle); + display: flex; + flex-direction: column; + gap: 10px; + } + .tok-updates-search-wrap { + position: relative; + display: flex; + align-items: center; + } + .tok-updates-search-wrap svg { + position: absolute; + inset-inline-start: 10px; + color: var(--tok-text-muted); + pointer-events: none; + } + .tok-updates-search-input { + width: 100%; + padding-inline-start: 32px; + padding-inline-end: 12px; + height: 34px; + font-size: 13px; + } + .tok-updates-filter-chips { + display: flex; + gap: 6px; + overflow-x: auto; + padding-bottom: 2px; + } + .tok-filter-chip { + border: 1px solid var(--tok-border-subtle); + background: var(--tok-bg-surface-2); + color: var(--tok-text-secondary); + border-radius: 14px; + padding: 4px 10px; + font-size: 11.5px; + font-weight: 500; + cursor: pointer; + white-space: nowrap; + transition: all 0.15s ease; + } + .tok-filter-chip:hover { + background: var(--tok-bg-surface-3); + color: var(--tok-text-primary); + } + .tok-filter-chip-active { + background: #1E4A9E; + color: #ffffff; + border-color: #1E4A9E; + } + .tok-filter-chip-active:hover { + background: #183b7e; + color: #ffffff; + } + + /* Drawer Body & Cards */ + .tok-updates-body { + flex: 1; + overflow-y: auto; + padding: 16px 20px; + } + .tok-updates-cards-list { + display: flex; + flex-direction: column; + gap: 16px; + } + .tok-changelog-card { + border: 1px solid var(--tok-border-subtle); + background: var(--tok-bg-surface-2); + border-radius: 12px; + padding: 16px; + display: flex; + flex-direction: column; + gap: 10px; + transition: box-shadow 0.15s ease, border-color 0.15s ease; + } + .tok-changelog-card:hover { + border-color: var(--tok-border-strong); + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05); + } + .tok-changelog-major { + border-inline-start: 4px solid #7c3aed; + } + .tok-changelog-minor { + border-inline-start: 4px solid #1E4A9E; + } + .tok-changelog-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + } + .tok-changelog-version-group { + display: flex; + align-items: center; + gap: 8px; + } + .tok-changelog-version-tag { + font-size: 14px; + font-weight: 700; + color: var(--tok-text-primary); + margin: 0; + font-variant-numeric: tabular-nums; + } + .tok-changelog-date { + font-size: 11.5px; + color: var(--tok-text-muted); + } + .tok-changelog-entry-title { + font-size: 13.5px; + font-weight: 600; + color: var(--tok-text-primary); + margin: 0; + } + .tok-changelog-entry-summary { + font-size: 12.5px; + color: var(--tok-text-secondary); + line-height: 1.5; + margin: 0; + } + .tok-changelog-changes-list { + list-style: none; + padding: 0; + margin: 4px 0 0 0; + display: flex; + flex-direction: column; + gap: 8px; + } + .tok-changelog-change-item { + display: flex; + align-items: flex-start; + gap: 8px; + font-size: 12px; + line-height: 1.45; + } + .tok-changelog-change-text { + flex: 1; + color: var(--tok-text-primary); + } + + /* Category & Version Badges */ + .tok-update-pill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 7px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; + flex-shrink: 0; + } + .tok-update-pill-features { background: rgba(124, 58, 237, 0.12); color: #7c3aed; } + .tok-update-pill-typography { background: rgba(180, 83, 9, 0.12); color: #b45309; } + .tok-update-pill-improvements { background: rgba(30, 74, 158, 0.12); color: #1e4a9e; } + .tok-update-pill-fixes { background: rgba(21, 128, 61, 0.12); color: #15803d; } + .tok-update-pill-performance { background: rgba(2, 132, 199, 0.12); color: #0284c7; } + + .tok-badge-major { + background: #7c3aed; + color: #ffffff; + padding: 2px 8px; + font-weight: 700; + border-radius: 10px; + font-size: 11px; + } + .tok-badge-info { + background: rgba(30, 74, 158, 0.12); + color: #1e4a9e; + padding: 2px 8px; + font-weight: 600; + border-radius: 10px; + font-size: 11px; + } + + /* Drawer Footer */ + .tok-drawer-foot { + height: 60px; + flex: none; + display: flex; + align-items: center; + gap: 12px; + padding: 0 20px; + border-top: 1px solid var(--tok-border-subtle); + background: var(--tok-bg-surface-2); + } + .tok-updates-empty-state { + padding: 40px 20px; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + color: var(--tok-text-muted); + } + + /* ========================================================= + Major Release Popup Modal Styles + ========================================================= */ + .tok-major-update-overlay { + position: fixed; + inset: 0; + z-index: 1050; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; + } + .tok-major-update-overlay.tok-open { + opacity: 1; + pointer-events: auto; + } + .tok-major-dialog { + width: 740px; + max-width: 94vw; + max-height: 90vh; + background: var(--tok-bg-surface-1); + border-radius: 16px; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3); + display: flex; + flex-direction: column; + overflow: hidden; + animation: tokRise 0.25s cubic-bezier(0.16, 1, 0.3, 1); + } + .tok-major-head { + background: linear-gradient(135deg, rgba(30, 74, 158, 0.08) 0%, rgba(124, 58, 237, 0.08) 100%); + } + .tok-major-hero-icon { + width: 44px; + height: 44px; + border-radius: 12px; + background: linear-gradient(135deg, #1E4A9E, #7c3aed); + color: #ffffff; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + box-shadow: 0 4px 12px rgba(124, 58, 237, 0.25); + } + .tok-major-head-row { + display: flex; + align-items: center; + gap: 10px; + } + .tok-major-body { + flex: 1; + overflow-y: auto; + padding: 24px; + display: flex; + flex-direction: column; + gap: 20px; + } + .tok-major-hero-banner { + background: var(--tok-bg-surface-2); + border: 1px solid var(--tok-border-subtle); + border-radius: 12px; + padding: 18px 20px; + display: flex; + flex-direction: column; + gap: 6px; + } + .tok-major-version-pill { + font-size: 12px; + font-weight: 700; + color: #7c3aed; + text-transform: uppercase; + letter-spacing: 0.05em; + } + .tok-major-main-title { + font-size: 18px; + font-weight: 700; + color: var(--tok-text-primary); + margin: 0; + } + .tok-major-summary { + font-size: 13.5px; + color: var(--tok-text-secondary); + line-height: 1.5; + margin: 0; + } + .tok-major-showcase-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 14px; + } + @media (max-width: 640px) { + .tok-major-showcase-grid { + grid-template-columns: 1fr; + } + } + .tok-major-card { + border: 1px solid var(--tok-border-subtle); + background: var(--tok-bg-surface-2); + border-radius: 12px; + padding: 16px; + display: flex; + flex-direction: column; + gap: 8px; + } + .tok-major-card-head { + display: flex; + align-items: center; + gap: 10px; + } + .tok-major-card-icon { + width: 34px; + height: 34px; + border-radius: 8px; + background: rgba(30, 74, 158, 0.1); + color: #1E4A9E; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + .tok-major-card-title-wrap { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + } + .tok-major-card-title { + font-size: 13.5px; + font-weight: 600; + color: var(--tok-text-primary); + margin: 0; + } + .tok-badge-card { + font-size: 10.5px; + padding: 1px 6px; + border-radius: 8px; + background: rgba(124, 58, 237, 0.12); + color: #7c3aed; + font-weight: 600; + } + .tok-major-card-desc { + font-size: 12px; + color: var(--tok-text-secondary); + line-height: 1.45; + margin: 0; + } + .tok-major-foot { + height: 64px; + padding: 0 24px; + } + .tok-checkbox-label { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--tok-text-muted); + cursor: pointer; + user-select: none; + } + .tok-checkbox { + cursor: pointer; + } + diff --git a/packages/tok-ui/src/updates/ChangelogEntryCard.ts b/packages/tok-ui/src/updates/ChangelogEntryCard.ts new file mode 100644 index 0000000..6fe992a --- /dev/null +++ b/packages/tok-ui/src/updates/ChangelogEntryCard.ts @@ -0,0 +1,73 @@ +import { ChangelogEntry } from './types'; +import { i18n, Language } from '../i18n'; +import { el } from '../ui'; +import { createVersionBadge, createCategoryPill } from './updatePills'; + +export class ChangelogEntryCard { + public element: HTMLElement; + private entry: ChangelogEntry; + + constructor(entry: ChangelogEntry) { + this.entry = entry; + this.element = el('article', `tok-changelog-card tok-changelog-${this.entry.importance}`); + this.render(); + } + + public render(): void { + const lang: Language = i18n.getLanguage(); + this.element.innerHTML = ''; + this.element.setAttribute('data-version', this.entry.version); + + // Header row: version, badge, date + const header = el('header', 'tok-changelog-card-header'); + const titleGroup = el('div', 'tok-changelog-version-group'); + + const versionTitle = el('h3', 'tok-changelog-version-tag', undefined, `v${this.entry.version}`); + titleGroup.appendChild(versionTitle); + + const badge = createVersionBadge(this.entry.importance, this.entry.badge); + titleGroup.appendChild(badge); + + header.appendChild(titleGroup); + + const dateSpan = el('time', 'tok-changelog-date', { + datetime: this.entry.releaseDate, + dir: 'ltr' + }, this.entry.releaseDate); + header.appendChild(dateSpan); + + this.element.appendChild(header); + + // Entry Title & Summary + const titleText = this.entry.title[lang] || this.entry.title.en; + const summaryText = this.entry.summary[lang] || this.entry.summary.en; + + const entryTitle = el('h4', 'tok-changelog-entry-title', undefined, titleText); + this.element.appendChild(entryTitle); + + const summaryPara = el('p', 'tok-changelog-entry-summary', undefined, summaryText); + this.element.appendChild(summaryPara); + + // Changes List + if (this.entry.changes && this.entry.changes.length > 0) { + const changesList = el('ul', 'tok-changelog-changes-list'); + + for (const item of this.entry.changes) { + const li = el('li', 'tok-changelog-change-item'); + const pill = createCategoryPill(item.category, item.tag); + li.appendChild(pill); + + const textSpan = el('span', 'tok-changelog-change-text', undefined, item.text[lang] || item.text.en); + li.appendChild(textSpan); + + changesList.appendChild(li); + } + + this.element.appendChild(changesList); + } + } + + public updateLanguage(): void { + this.render(); + } +} diff --git a/packages/tok-ui/src/updates/UpdatesBellButton.ts b/packages/tok-ui/src/updates/UpdatesBellButton.ts new file mode 100644 index 0000000..61e035c --- /dev/null +++ b/packages/tok-ui/src/updates/UpdatesBellButton.ts @@ -0,0 +1,75 @@ +import { t, i18n } from '../i18n'; +import { el, icon } from '../ui'; +import { UpdatesStateManager } from './updatesManager'; + +export interface UpdatesBellButtonOptions { + onClick: () => void; + className?: string; + size?: number; +} + +export class UpdatesBellButton { + public element: HTMLButtonElement; + private stateManager: UpdatesStateManager; + private badgeDot: HTMLElement; + private unsubscribe?: () => void; + private onClickHandler: () => void; + + constructor(options: UpdatesBellButtonOptions, customManager?: UpdatesStateManager) { + this.onClickHandler = options.onClick; + this.stateManager = customManager || UpdatesStateManager.getInstance(); + + this.element = el('button', options.className || 'tok-btn tok-btn-ghost tok-btn-icon tok-updates-bell-btn', { + type: 'button', + 'aria-label': t('whatsNewTitle'), + title: t('whatsNewTitle') + }) as HTMLButtonElement; + + // Vector Icon + const iconEl = icon('sparkle', options.size || 18); + this.element.appendChild(iconEl); + + // Unread indicator dot + this.badgeDot = el('span', 'tok-updates-unread-dot', { + 'aria-hidden': 'true' + }); + this.element.appendChild(this.badgeDot); + + this.element.addEventListener('click', () => { + this.onClickHandler(); + }); + + this.updateStatus(); + + this.unsubscribe = this.stateManager.subscribe(() => { + this.updateStatus(); + }); + + i18n.onChange(() => { + this.element.setAttribute('aria-label', t('whatsNewTitle')); + this.element.setAttribute('title', t('whatsNewTitle')); + }); + } + + public updateStatus(): void { + const unreadCount = this.stateManager.getUnreadCount(); + const hasUnread = unreadCount > 0; + + if (hasUnread) { + this.badgeDot.classList.add('tok-visible'); + this.element.classList.add('tok-has-unread'); + this.element.setAttribute('aria-label', `${t('whatsNewTitle')} (${t('whatsNewUnreadAria')})`); + } else { + this.badgeDot.classList.remove('tok-visible'); + this.element.classList.remove('tok-has-unread'); + this.element.setAttribute('aria-label', t('whatsNewTitle')); + } + } + + public destroy(): void { + if (this.unsubscribe) { + this.unsubscribe(); + this.unsubscribe = undefined; + } + } +} diff --git a/packages/tok-ui/src/updates/changelogData.ts b/packages/tok-ui/src/updates/changelogData.ts new file mode 100644 index 0000000..3dd81f6 --- /dev/null +++ b/packages/tok-ui/src/updates/changelogData.ts @@ -0,0 +1,224 @@ +/** + * AUTO-GENERATED from CHANGELOG.md by scripts/sync-changelog.mjs + * DO NOT EDIT DIRECTLY. Update CHANGELOG.md in the project root instead. + */ +import { ChangelogEntry } from './types'; + +export const CHANGELOG_ENTRIES: ChangelogEntry[] = [ + { + "version": "0.30.4", + "releaseDate": "2026-10-30", + "importance": "minor", + "title": { + "he": "עדכון שיפורים, טיפוגרפיה ותאימות מקלדת", + "en": "Enhancements, Hebrew Typography & Keyboard Usability" + }, + "summary": { + "he": "עדכון זה מביא תמיכה מלאה במשקלי גופן (Regular/Bold) בסרגל המבנה ובקנבס, שיפור במקשי קיצור במקלדת עברית ותיקוני יציבות.", + "en": "This update brings font weight support (Regular/Bold) across inspector and canvas, Hebrew keyboard shortcut compatibility, and stability fixes." + }, + "changes": [ + { + "category": "typography", + "text": { + "he": "תמיכה מובנית במשקלי גופן (Bold 700 / Regular 400) בגופנים עבריים ושימורם בייצוא", + "en": "Native font weight support (Bold 700 / Regular 400) for Hebrew fonts with faithful export retention" + } + }, + { + "category": "improvements", + "text": { + "he": "תאימות פלטת הפקודות (Ctrl+K) למקלדת עברית עם המקש \"ל\" ללא צורך בהחלפת שפה", + "en": "Hebrew keyboard layout compatibility for Command Palette (Ctrl+K / Ctrl+ל)" + } + }, + { + "category": "fixes", + "text": { + "he": "מניעת השתלטות של מקש הסלאש (\"/\") בזמן הקלדה חופשית בעורך הטקסט", + "en": "Prevented slash key (\"/\") from hijacking story editor typing" + } + }, + { + "category": "performance", + "text": { + "he": "ייעול מנגנון הרינדור של מסגרות הטקסט בקנבס בצפיפויות מסך גבוהות", + "en": "Optimized canvas text frame rendering on high DPI displays" + } + } + ], + "highlights": [], + "badge": { + "he": "גרסה נוכחית", + "en": "Current Version" + } + }, + { + "version": "0.30.0", + "releaseDate": "2026-10-15", + "importance": "major", + "title": { + "he": "שחרור גרסה ראשית: מנוע עימוד רב-תזרימי ותקני דפוס מקצועיים", + "en": "Major Release: Multi-Flow Layout Engine & Professional Pre-Press" + }, + "summary": { + "he": "קפיצת מדרגה משמעותית לתוכנת TypesetOK עם מנוע Rust Native מהיר, יישור עברי תלת-שכבתי, ייצוא ISO PDF/X-1a ומגן שמות קדושים.", + "en": "A massive milestone for TypesetOK featuring a Rust Native engine, 3-tier Hebrew justification, ISO PDF/X-1a pre-press export, and holy name protection." + }, + "changes": [ + { + "category": "features", + "text": { + "he": "מבנה עמוד רב-תזרימי מלא עבור דפי גמרא (מרכז, רש\"י, תוספות, הערות) ומקראות גדולות", + "en": "Full multi-flow page architecture for Talmud pages and rabbinic commentaries" + } + }, + { + "category": "features", + "text": { + "he": "ייצוא PDF/X-1a תקני עם פרופילי צבע Fogra 39 ו-ISO Coated v2", + "en": "Compliant ISO PDF/X-1a export with Fogra 39 and ISO Coated v2 profiles" + } + }, + { + "category": "typography", + "text": { + "he": "אלגוריתם מתיחת אותיות עבריות רפויות וריווח מילים גמיש", + "en": "Hebrew dilated letter expansion and elastic word space distribution" + } + }, + { + "category": "improvements", + "text": { + "he": "ניהול פרויקטים ומסך פתיחה חדש עם תבניות מהירות לספרים, עלונים וקונטרסים", + "en": "Project manager and fresh welcome dashboard with quick templates for books and booklets" + } + }, + { + "category": "fixes", + "text": { + "he": "תיקון חישוב גבולות עמודים בצמדי עמודים (Spreads) בספרים מימין לשמאל", + "en": "Fixed page spread layout calculation for right-to-left Hebrew books" + } + } + ], + "highlights": [ + { + "id": "rust-engine", + "icon": "sparkle", + "title": { + "he": "מנוע עימוד Rust מקורי", + "en": "Native Rust Layout Engine" + }, + "description": { + "he": "ליבת עימוד דטרמיניסטית וסופר-מהירה המבצעת שבירת שורות אופטימלית בשיטת Knuth-Plass על פני פסקאות עבריות שלמות.", + "en": "Blazing fast deterministic layout engine running Knuth-Plass paragraph breaking across full Hebrew texts." + }, + "badge": { + "he": "מהיר פי 10", + "en": "10x Faster" + } + }, + { + "id": "hebrew-justifier", + "icon": "typography", + "title": { + "he": "יישור עברי מסורתי תלת-שכבתי", + "en": "3-Tier Traditional Hebrew Justifier" + }, + "description": { + "he": "שילוב עדין של רווחי מילים גמישים, מתיחת אותיות רפויות (אהלתר\"ם) וריווח בין-אותי מיקרומטרי ללא צורך במקפים מכוערים.", + "en": "Harmonious blending of word spacing, wide Hebrew letter dilation, and micro letter-spacing." + }, + "badge": { + "he": "דיוק טיפוגרפי", + "en": "Typographic Precision" + } + }, + { + "id": "pdf-x-export", + "icon": "export", + "title": { + "he": "ייצוא תקני ISO PDF/X-1a ו-PDF/X-4", + "en": "ISO PDF/X-1a & PDF/X-4 Pre-Press" + }, + "description": { + "he": "הפקת קבצים מוכנים ישירות להדפסת אופסט ודפוס דיגיטלי מסחרי, עם הפרדת צבעים מלאה של 100% K לטקסט חד ונקי.", + "en": "Production-ready PDF export for offset & digital printing, strict 100% pure black channel separation." + }, + "badge": { + "he": "תקן דפוס", + "en": "Print Standard" + } + }, + { + "id": "sacred-text-shield", + "icon": "check", + "title": { + "he": "קדושת הטקסט ונרמול קנוני", + "en": "Sacred Text Shield & Canonical Normalization" + }, + "description": { + "he": "מניעת שבירת שמות קדושים בכללי No-Break קפדניים, סנכרון גימטריה עברית לעמודים ונרמול סדר ניקוד וטעמים.", + "en": "Strict no-break protection for divine names, Hebrew gematria page numbering, and canonical niqqud normalization." + }, + "badge": { + "he": "מסורת ישראל", + "en": "Traditional Rules" + } + } + ], + "badge": { + "he": "עדכון גדול", + "en": "Major Release" + } + }, + { + "version": "0.29.0", + "releaseDate": "2026-09-20", + "importance": "minor", + "title": { + "he": "מערכת תוספים מודולרית ועורך טקסט משולב", + "en": "Modular Plugin System & Integrated Story Editor" + }, + "summary": { + "he": "הוספת סביבת הרחבות מבודדת ומאובטחת לתוספים ב-TypeScript/JavaScript, לצד עורך טקסט רציף (Story Editor) עם חיפוש והחלפה.", + "en": "Introduced an isolated plugin architecture for TypeScript/JavaScript extensions alongside a split Story Editor." + }, + "changes": [ + { + "category": "features", + "text": { + "he": "מנוע פלאגינים מאובטח (PluginEngine) עם הרשאות מוגדרות והידור בזמן ריצה", + "en": "Hardened plugin sandbox engine with strict permission boundaries and runtime compilation" + } + }, + { + "category": "features", + "text": { + "he": "עורך טקסט משולב בתצוגת Split View עם ספירת מילים חיה לכל תזרים", + "en": "Integrated Story Editor with split view and live word counts per flow" + } + }, + { + "category": "improvements", + "text": { + "he": "ייבוא ישיר של קובצי Word (.docx), Markdown (.md) וטקסט רציף", + "en": "Direct import pipeline for Word (.docx), Markdown (.md), and plain text" + } + }, + { + "category": "fixes", + "text": { + "he": "שיפור מנגנון שמירה אוטומטית ומניעת איבוד שינויים בעת יציאה", + "en": "Enhanced auto-save safety checks and unsaved change prevention on quit" + } + } + ], + "highlights": [], + "badge": { + "he": "תוספים ועריכה", + "en": "Plugins & Editing" + } + } +]; diff --git a/packages/tok-ui/src/updates/changelogParser.ts b/packages/tok-ui/src/updates/changelogParser.ts new file mode 100644 index 0000000..6efa4b4 --- /dev/null +++ b/packages/tok-ui/src/updates/changelogParser.ts @@ -0,0 +1,172 @@ +import { + ChangelogEntry, + ChangeCategory, + UpdateImportance, + HighlightCard, + LocalizedString +} from './types'; + +/** + * Extracts bilingual text from a format like: + * "עברית: טקסט בעברית | en: English text" + * or "he: טקסט בעברית | en: English text" + */ +export function parseBilingual(raw: string): LocalizedString { + const trimmed = raw.trim(); + const sepMatch = trimmed.match(/^(?:עברית|he):\s*(.*?)\s*\|\s*(?:en|אנגלית):\s*(.*)$/i); + if (sepMatch) { + return { + he: sepMatch[1].trim(), + en: sepMatch[2].trim() + }; + } + // Fallback if not separated + return { he: trimmed, en: trimmed }; +} + +/** + * Parses CHANGELOG.md content into typed ChangelogEntry objects. + */ +export function parseChangelogMarkdown(markdown: string): ChangelogEntry[] { + const entries: ChangelogEntry[] = []; + const lines = markdown.split(/\r?\n/); + + let currentEntry: Partial | null = null; + let currentSection: 'meta' | 'highlights' | 'changes' = 'meta'; + let currentHighlight: Partial | null = null; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + // Version heading: ## [0.30.4] - 2026-10-30 + const versionMatch = trimmed.match(/^##\s*\[([0-9a-zA-Z.-]+)\](?:\s*-\s*(\d{4}-\d{2}-\d{2}))?/); + if (versionMatch) { + if (currentEntry && currentEntry.version) { + if (currentHighlight && currentHighlight.id) { + currentEntry.highlights = currentEntry.highlights || []; + currentEntry.highlights.push(currentHighlight as HighlightCard); + currentHighlight = null; + } + entries.push(currentEntry as ChangelogEntry); + } + + currentEntry = { + version: versionMatch[1], + releaseDate: versionMatch[2] || new Date().toISOString().slice(0, 10), + importance: 'minor', + title: { he: '', en: '' }, + summary: { he: '', en: '' }, + changes: [], + highlights: [] + }; + currentSection = 'meta'; + currentHighlight = null; + continue; + } + + if (!currentEntry) continue; + + // Sub-headings: ### Highlights / ### Changes + if (trimmed.startsWith('### Highlights')) { + currentSection = 'highlights'; + continue; + } else if (trimmed.startsWith('### Changes')) { + if (currentHighlight && currentHighlight.id) { + currentEntry.highlights = currentEntry.highlights || []; + currentEntry.highlights.push(currentHighlight as HighlightCard); + currentHighlight = null; + } + currentSection = 'changes'; + continue; + } + + // Meta properties + if (currentSection === 'meta') { + const impMatch = trimmed.match(/^-\s*\*\*Importance\*\*:\s*([a-zA-Z]+)/i); + if (impMatch) { + currentEntry.importance = impMatch[1].toLowerCase() as UpdateImportance; + continue; + } + + const badgeMatch = trimmed.match(/^-\s*\*\*Badge\*\*:\s*(.*)/i); + if (badgeMatch) { + currentEntry.badge = parseBilingual(badgeMatch[1]); + continue; + } + + const titleMatch = trimmed.match(/^-\s*\*\*Title\*\*:\s*(.*)/i); + if (titleMatch) { + currentEntry.title = parseBilingual(titleMatch[1]); + continue; + } + + const summaryMatch = trimmed.match(/^-\s*\*\*Summary\*\*:\s*(.*)/i); + if (summaryMatch) { + currentEntry.summary = parseBilingual(summaryMatch[1]); + continue; + } + } + + // Highlights section + if (currentSection === 'highlights') { + // Highlight head: - [rust-engine] icon: sparkle | badge: עברית: מהיר פי 10, en: 10x Faster + const hlHeadMatch = trimmed.match(/^-\s*\[([a-zA-Z0-9_-]+)\](?:\s*icon:\s*([a-zA-Z0-9_-]+))?(?:\s*\|\s*badge:\s*(.*))?/i); + if (hlHeadMatch) { + if (currentHighlight && currentHighlight.id) { + currentEntry.highlights = currentEntry.highlights || []; + currentEntry.highlights.push(currentHighlight as HighlightCard); + } + currentHighlight = { + id: hlHeadMatch[1], + icon: hlHeadMatch[2] || 'sparkle', + title: { he: '', en: '' }, + description: { he: '', en: '' } + }; + if (hlHeadMatch[3]) { + currentHighlight.badge = parseBilingual(hlHeadMatch[3].replace(/,\s*en:/i, ' | en:')); + } + continue; + } + + if (currentHighlight) { + const hlTitleMatch = trimmed.match(/^-\s*Title:\s*(.*)/i); + if (hlTitleMatch) { + currentHighlight.title = parseBilingual(hlTitleMatch[1]); + continue; + } + const hlDescMatch = trimmed.match(/^-\s*Description:\s*(.*)/i); + if (hlDescMatch) { + currentHighlight.description = parseBilingual(hlDescMatch[1]); + continue; + } + } + } + + // Changes section: - [category] עברית: טקסט | en: Text + if (currentSection === 'changes') { + const changeMatch = trimmed.match(/^-\s*\[([a-zA-Z0-9_-]+)\]\s*(.*)/); + if (changeMatch) { + const cat = changeMatch[1].toLowerCase() as ChangeCategory; + const localized = parseBilingual(changeMatch[2]); + currentEntry.changes = currentEntry.changes || []; + currentEntry.changes.push({ + category: cat, + text: localized + }); + continue; + } + } + } + + // Push last entry + if (currentEntry && currentEntry.version) { + if (currentHighlight && currentHighlight.id) { + currentEntry.highlights = currentEntry.highlights || []; + currentEntry.highlights.push(currentHighlight as HighlightCard); + } + entries.push(currentEntry as ChangelogEntry); + } + + return entries; +} diff --git a/packages/tok-ui/src/updates/types.ts b/packages/tok-ui/src/updates/types.ts new file mode 100644 index 0000000..8bd49d3 --- /dev/null +++ b/packages/tok-ui/src/updates/types.ts @@ -0,0 +1,61 @@ +/** + * Types and interfaces for the TypesetOK What's New and Changelog notification system. + */ + +export type UpdateImportance = 'major' | 'minor' | 'patch'; + +export type ChangeCategory = + | 'features' + | 'improvements' + | 'fixes' + | 'typography' + | 'performance'; + +export interface LocalizedString { + he: string; + en: string; +} + +export interface LocalizedStringList { + he: string[]; + en: string[]; +} + +export interface HighlightCard { + id: string; + title: LocalizedString; + description: LocalizedString; + icon: string; + badge?: LocalizedString; +} + +export interface ChangeItem { + category: ChangeCategory; + text: LocalizedString; + tag?: LocalizedString; +} + +export interface ChangelogEntry { + version: string; + releaseDate: string; // ISO date string: YYYY-MM-DD + importance: UpdateImportance; + title: LocalizedString; + summary: LocalizedString; + badge?: LocalizedString; + highlights?: HighlightCard[]; + changes: ChangeItem[]; + readMoreUrl?: string; +} + +export interface UserUpdatesState { + lastSeenVersion: string; + lastSeenMajorVersion: string; + dismissedVersions: string[]; + lastCheckedTime?: number; +} + +export interface UpdatesFilterOptions { + category?: ChangeCategory | 'all'; + importance?: UpdateImportance | 'all'; + searchQuery?: string; +} diff --git a/packages/tok-ui/src/updates/updatePills.ts b/packages/tok-ui/src/updates/updatePills.ts new file mode 100644 index 0000000..cffb6ec --- /dev/null +++ b/packages/tok-ui/src/updates/updatePills.ts @@ -0,0 +1,71 @@ +import { UpdateImportance, ChangeCategory, LocalizedString } from './types'; +import { t, i18n, Language } from '../i18n'; +import { el, icon } from '../ui'; +import { IconName } from '../icons'; + +export function getCategoryIcon(category: ChangeCategory): IconName { + switch (category) { + case 'features': + return 'sparkle'; + case 'typography': + return 'typography'; + case 'improvements': + return 'zap'; + case 'fixes': + return 'check'; + case 'performance': + return 'zap'; + default: + return 'sparkle'; + } +} + +export function getCategoryLabel(category: ChangeCategory): string { + switch (category) { + case 'features': + return t('whatsNewCatFeatures'); + case 'typography': + return t('whatsNewCatTypography'); + case 'improvements': + return t('whatsNewCatImprovements'); + case 'fixes': + return t('whatsNewCatFixes'); + case 'performance': + return t('whatsNewCatPerformance'); + default: + return category; + } +} + +export function createCategoryPill(category: ChangeCategory, customTag?: LocalizedString): HTMLElement { + const lang: Language = i18n.getLanguage(); + const label = customTag ? (customTag[lang] || customTag.en) : getCategoryLabel(category); + const pill = el('span', `tok-update-pill tok-update-pill-${category}`); + pill.appendChild(icon(getCategoryIcon(category), 12)); + pill.appendChild(el('span', undefined, undefined, label)); + return pill; +} + +export function createVersionBadge(importance: UpdateImportance, customLabel?: LocalizedString): HTMLElement { + const lang: Language = i18n.getLanguage(); + let defaultLabel = ''; + let badgeClass = ''; + + switch (importance) { + case 'major': + defaultLabel = t('whatsNewBadgeMajor'); + badgeClass = 'tok-badge tok-badge-major'; + break; + case 'minor': + defaultLabel = t('whatsNewBadgeMinor'); + badgeClass = 'tok-badge tok-badge-info'; + break; + case 'patch': + defaultLabel = t('whatsNewBadgePatch'); + badgeClass = 'tok-badge tok-badge-neutral'; + break; + } + + const text = customLabel ? (customLabel[lang] || customLabel.en) : defaultLabel; + return el('span', badgeClass, undefined, text); +} diff --git a/packages/tok-ui/src/updates/updatesManager.ts b/packages/tok-ui/src/updates/updatesManager.ts new file mode 100644 index 0000000..c95e8d7 --- /dev/null +++ b/packages/tok-ui/src/updates/updatesManager.ts @@ -0,0 +1,267 @@ +import { + ChangelogEntry, + UpdatesFilterOptions, + UserUpdatesState, + UpdateImportance, + ChangeCategory +} from './types'; +import { CHANGELOG_ENTRIES } from './changelogData.js'; + +const STORAGE_KEY = 'tok_updates_state_v1'; + +export function compareSemver(v1: string, v2: string): number { + const clean1 = (v1 || '').replace(/^v/i, '').trim(); + const clean2 = (v2 || '').replace(/^v/i, '').trim(); + const p1 = clean1.split('.').map((x) => parseInt(x, 10) || 0); + const p2 = clean2.split('.').map((x) => parseInt(x, 10) || 0); + const len = Math.max(p1.length, p2.length); + for (let i = 0; i < len; i++) { + const num1 = p1[i] ?? 0; + const num2 = p2[i] ?? 0; + if (num1 > num2) return 1; + if (num1 < num2) return -1; + } + return 0; +} + +export class UpdatesStateManager { + private static instance: UpdatesStateManager; + private state: UserUpdatesState; + private entries: ChangelogEntry[]; + private listeners: Set<(state: UserUpdatesState) => void> = new Set(); + + private constructor(entries: ChangelogEntry[] = CHANGELOG_ENTRIES) { + this.entries = [...entries].sort((a, b) => compareSemver(b.version, a.version)); + this.state = this.loadState(); + } + + public static getInstance(customEntries?: ChangelogEntry[]): UpdatesStateManager { + if (!UpdatesStateManager.instance || customEntries) { + UpdatesStateManager.instance = new UpdatesStateManager(customEntries); + } + return UpdatesStateManager.instance; + } + + /** Clears singleton (useful for isolated tests). */ + public static resetInstance(): void { + (UpdatesStateManager as any).instance = undefined; + } + + private loadState(): UserUpdatesState { + const defaultState: UserUpdatesState = { + lastSeenVersion: '0.0.0', + lastSeenMajorVersion: '0.0.0', + dismissedVersions: [] + }; + + try { + if (typeof window !== 'undefined' && window.localStorage) { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw); + return { + lastSeenVersion: typeof parsed.lastSeenVersion === 'string' ? parsed.lastSeenVersion : defaultState.lastSeenVersion, + lastSeenMajorVersion: typeof parsed.lastSeenMajorVersion === 'string' ? parsed.lastSeenMajorVersion : defaultState.lastSeenMajorVersion, + dismissedVersions: Array.isArray(parsed.dismissedVersions) ? parsed.dismissedVersions : defaultState.dismissedVersions, + lastCheckedTime: typeof parsed.lastCheckedTime === 'number' ? parsed.lastCheckedTime : undefined + }; + } + } + } catch (e) { + console.warn('[UPDATES] Failed to load updates state from localStorage:', e); + } + + return defaultState; + } + + private persistState(): void { + try { + if (typeof window !== 'undefined' && window.localStorage) { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(this.state)); + } + } catch (e) { + console.warn('[UPDATES] Failed to persist updates state:', e); + } + this.notify(); + } + + private notify(): void { + for (const listener of this.listeners) { + try { + listener({ ...this.state }); + } catch (e) { + console.error('[UPDATES] Listener error:', e); + } + } + } + + public subscribe(listener: (state: UserUpdatesState) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + public getState(): Readonly { + return { ...this.state }; + } + + public getAllEntries(): ChangelogEntry[] { + return [...this.entries]; + } + + public getLatestEntry(): ChangelogEntry | undefined { + return this.entries[0]; + } + + public getLatestMajorEntry(): ChangelogEntry | undefined { + return this.entries.find((e) => e.importance === 'major'); + } + + public getEntry(version: string): ChangelogEntry | undefined { + return this.entries.find((e) => e.version === version.replace(/^v/i, '')); + } + + /** + * Checks whether the current version represents a major update that should prompt + * a modal popup to the user on startup. + */ + public shouldPromptMajorUpdate(targetVersion?: string): boolean { + const versionToCheck = targetVersion || this.getLatestEntry()?.version; + if (!versionToCheck) return false; + + const entry = this.getEntry(versionToCheck); + if (!entry || entry.importance !== 'major') { + return false; + } + + // Don't show if user previously dismissed this version prompt + if (this.state.dismissedVersions.includes(versionToCheck)) { + return false; + } + + // Compare with last seen major version + return compareSemver(versionToCheck, this.state.lastSeenMajorVersion) > 0; + } + + /** + * Checks if there are unread minor or patch updates that should be highlighted + * in the side drawer without an intrusive popup. + */ + public hasUnreadMinorUpdates(targetVersion?: string): boolean { + const versionToCheck = targetVersion || this.getLatestEntry()?.version; + if (!versionToCheck) return false; + + const entry = this.getEntry(versionToCheck); + if (!entry || entry.importance === 'major') { + return false; + } + + return compareSemver(versionToCheck, this.state.lastSeenVersion) > 0; + } + + /** + * Returns true if there is any update (major, minor, or patch) newer than lastSeenVersion. + */ + public hasUnreadUpdates(targetVersion?: string): boolean { + const versionToCheck = targetVersion || this.getLatestEntry()?.version; + if (!versionToCheck) return false; + + return compareSemver(versionToCheck, this.state.lastSeenVersion) > 0; + } + + /** + * Count of entries newer than lastSeenVersion. + */ + public getUnreadCount(): number { + return this.entries.filter((e) => compareSemver(e.version, this.state.lastSeenVersion) > 0).length; + } + + /** + * Marks a version (and all older versions) as seen. + */ + public markVersionAsSeen(version: string): void { + const clean = version.replace(/^v/i, ''); + let changed = false; + + if (compareSemver(clean, this.state.lastSeenVersion) > 0) { + this.state.lastSeenVersion = clean; + changed = true; + } + + const entry = this.getEntry(clean); + if (entry && entry.importance === 'major' && compareSemver(clean, this.state.lastSeenMajorVersion) > 0) { + this.state.lastSeenMajorVersion = clean; + changed = true; + } + + if (changed) { + this.persistState(); + } + } + + /** + * Dismisses a major update prompt so it won't pop up again. + */ + public dismissMajorPrompt(version: string): void { + const clean = version.replace(/^v/i, ''); + if (!this.state.dismissedVersions.includes(clean)) { + this.state.dismissedVersions.push(clean); + } + if (compareSemver(clean, this.state.lastSeenMajorVersion) > 0) { + this.state.lastSeenMajorVersion = clean; + } + if (compareSemver(clean, this.state.lastSeenVersion) > 0) { + this.state.lastSeenVersion = clean; + } + this.persistState(); + } + + /** + * Marks all entries in the registry as seen. + */ + public markAllAsSeen(): void { + const latest = this.getLatestEntry(); + if (latest) { + this.state.lastSeenVersion = latest.version; + const latestMajor = this.getLatestMajorEntry(); + if (latestMajor) { + this.state.lastSeenMajorVersion = latestMajor.version; + } + this.persistState(); + } + } + + /** + * Filters changelog entries based on category, importance, or textual search query. + */ + public filterEntries(options: UpdatesFilterOptions = {}, lang: 'he' | 'en' = 'he'): ChangelogEntry[] { + let result = [...this.entries]; + + if (options.importance && options.importance !== 'all') { + result = result.filter((e) => e.importance === options.importance); + } + + if (options.category && options.category !== 'all') { + const cat = options.category; + result = result.filter((e) => e.changes.some((c) => c.category === cat)); + } + + if (options.searchQuery && options.searchQuery.trim()) { + const q = options.searchQuery.trim().toLowerCase(); + result = result.filter((e) => { + const titleMatch = (e.title[lang] || '').toLowerCase().includes(q) || + (e.title.en || '').toLowerCase().includes(q) || + (e.title.he || '').toLowerCase().includes(q); + const summaryMatch = (e.summary[lang] || '').toLowerCase().includes(q); + const versionMatch = e.version.includes(q); + const changesMatch = e.changes.some((c) => + (c.text[lang] || '').toLowerCase().includes(q) || + (c.text.en || '').toLowerCase().includes(q) || + (c.text.he || '').toLowerCase().includes(q) + ); + return titleMatch || summaryMatch || versionMatch || changesMatch; + }); + } + + return result; + } +} diff --git a/scripts/bundle-ui.mjs b/scripts/bundle-ui.mjs index fd7669a..b2718f9 100644 --- a/scripts/bundle-ui.mjs +++ b/scripts/bundle-ui.mjs @@ -7,12 +7,14 @@ import * as esbuild from 'esbuild'; import * as fs from 'fs'; import * as path from 'path'; import { fileURLToPath } from 'url'; +import { syncChangelog } from './sync-changelog.mjs'; const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const uiDir = path.join(rootDir, 'packages/tok-ui'); const dev = process.argv.includes('--dev'); try { + syncChangelog(); await esbuild.build({ entryPoints: [path.join(uiDir, 'src/renderer.ts')], outfile: path.join(uiDir, 'dist/renderer.js'), diff --git a/scripts/sync-changelog.mjs b/scripts/sync-changelog.mjs new file mode 100644 index 0000000..7bc702f --- /dev/null +++ b/scripts/sync-changelog.mjs @@ -0,0 +1,198 @@ +// Reads CHANGELOG.md from project root and regenerates packages/tok-ui/src/updates/changelogData.ts +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const rootDir = path.resolve(__dirname, '..'); + +const changelogPath = path.join(rootDir, 'CHANGELOG.md'); +const targetPath = path.join(rootDir, 'packages', 'tok-ui', 'src', 'updates', 'changelogData.ts'); + +function parseBilingual(raw) { + const trimmed = (raw || '').trim(); + const sepMatch = trimmed.match(/^(?:עברית|he):\s*(.*?)\s*\|\s*(?:en|אנגלית):\s*(.*)$/i); + if (sepMatch) { + return { + he: sepMatch[1].trim(), + en: sepMatch[2].trim() + }; + } + return { he: trimmed, en: trimmed }; +} + +export function parseChangelogMarkdown(markdown) { + const entries = []; + const lines = markdown.split(/\r?\n/); + + let currentEntry = null; + let currentSection = 'meta'; + let currentHighlight = null; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + // Version heading: ## [0.30.4] - 2026-10-30 + const versionMatch = trimmed.match(/^##\s*\[([0-9a-zA-Z.-]+)\](?:\s*-\s*(\d{4}-\d{2}-\d{2}))?/); + if (versionMatch) { + if (currentEntry && currentEntry.version) { + if (currentHighlight && currentHighlight.id) { + currentEntry.highlights = currentEntry.highlights || []; + currentEntry.highlights.push(currentHighlight); + currentHighlight = null; + } + entries.push(currentEntry); + } + + currentEntry = { + version: versionMatch[1], + releaseDate: versionMatch[2] || new Date().toISOString().slice(0, 10), + importance: 'minor', + title: { he: '', en: '' }, + summary: { he: '', en: '' }, + changes: [], + highlights: [] + }; + currentSection = 'meta'; + currentHighlight = null; + continue; + } + + if (!currentEntry) continue; + + // Sub-headings: ### Highlights / ### Changes + if (trimmed.startsWith('### Highlights')) { + currentSection = 'highlights'; + continue; + } else if (trimmed.startsWith('### Changes')) { + if (currentHighlight && currentHighlight.id) { + currentEntry.highlights = currentEntry.highlights || []; + currentEntry.highlights.push(currentHighlight); + currentHighlight = null; + } + currentSection = 'changes'; + continue; + } + + // Meta properties + if (currentSection === 'meta') { + const impMatch = trimmed.match(/^-\s*\*\*Importance\*\*:\s*([a-zA-Z]+)/i); + if (impMatch) { + currentEntry.importance = impMatch[1].toLowerCase(); + continue; + } + + const badgeMatch = trimmed.match(/^-\s*\*\*Badge\*\*:\s*(.*)/i); + if (badgeMatch) { + currentEntry.badge = parseBilingual(badgeMatch[1]); + continue; + } + + const titleMatch = trimmed.match(/^-\s*\*\*Title\*\*:\s*(.*)/i); + if (titleMatch) { + currentEntry.title = parseBilingual(titleMatch[1]); + continue; + } + + const summaryMatch = trimmed.match(/^-\s*\*\*Summary\*\*:\s*(.*)/i); + if (summaryMatch) { + currentEntry.summary = parseBilingual(summaryMatch[1]); + continue; + } + } + + // Highlights section + if (currentSection === 'highlights') { + const hlHeadMatch = trimmed.match(/^-\s*\[([a-zA-Z0-9_-]+)\](?:\s*icon:\s*([a-zA-Z0-9_-]+))?(?:\s*\|\s*badge:\s*(.*))?/i); + if (hlHeadMatch) { + if (currentHighlight && currentHighlight.id) { + currentEntry.highlights = currentEntry.highlights || []; + currentEntry.highlights.push(currentHighlight); + } + currentHighlight = { + id: hlHeadMatch[1], + icon: hlHeadMatch[2] || 'sparkle', + title: { he: '', en: '' }, + description: { he: '', en: '' } + }; + if (hlHeadMatch[3]) { + currentHighlight.badge = parseBilingual(hlHeadMatch[3].replace(/,\s*en:/i, ' | en:')); + } + continue; + } + + if (currentHighlight) { + const hlTitleMatch = trimmed.match(/^-\s*Title:\s*(.*)/i); + if (hlTitleMatch) { + currentHighlight.title = parseBilingual(hlTitleMatch[1]); + continue; + } + const hlDescMatch = trimmed.match(/^-\s*Description:\s*(.*)/i); + if (hlDescMatch) { + currentHighlight.description = parseBilingual(hlDescMatch[1]); + continue; + } + } + } + + // Changes section: - [category] עברית: טקסט | en: Text + if (currentSection === 'changes') { + const changeMatch = trimmed.match(/^-\s*\[([a-zA-Z0-9_-]+)\]\s*(.*)/); + if (changeMatch) { + const cat = changeMatch[1].toLowerCase(); + const localized = parseBilingual(changeMatch[2]); + currentEntry.changes = currentEntry.changes || []; + currentEntry.changes.push({ + category: cat, + text: localized + }); + continue; + } + } + } + + // Push last entry + if (currentEntry && currentEntry.version) { + if (currentHighlight && currentHighlight.id) { + currentEntry.highlights = currentEntry.highlights || []; + currentEntry.highlights.push(currentHighlight); + } + entries.push(currentEntry); + } + + return entries; +} + +export function syncChangelog() { + if (!fs.existsSync(changelogPath)) { + console.error(`[CHANGELOG-SYNC] Error: ${changelogPath} not found!`); + process.exit(1); + } + + const markdown = fs.readFileSync(changelogPath, 'utf8'); + const entries = parseChangelogMarkdown(markdown); + + if (!entries || entries.length === 0) { + console.error('[CHANGELOG-SYNC] Error: No changelog entries parsed from CHANGELOG.md'); + process.exit(1); + } + + const fileContent = `/** + * AUTO-GENERATED from CHANGELOG.md by scripts/sync-changelog.mjs + * DO NOT EDIT DIRECTLY. Update CHANGELOG.md in the project root instead. + */ +import { ChangelogEntry } from './types'; + +export const CHANGELOG_ENTRIES: ChangelogEntry[] = ${JSON.stringify(entries, null, 2)}; +`; + + fs.writeFileSync(targetPath, fileContent, 'utf8'); + console.log(`[CHANGELOG-SYNC] Successfully synced ${entries.length} release entries from CHANGELOG.md to changelogData.ts`); +} + +// Run directly if invoked as main script +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + syncChangelog(); +} diff --git a/test/whats_new_system.test.mjs b/test/whats_new_system.test.mjs new file mode 100644 index 0000000..98f6c44 --- /dev/null +++ b/test/whats_new_system.test.mjs @@ -0,0 +1,292 @@ +import { test, describe, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const rootDir = path.resolve(__dirname, '..'); + +import { CHANGELOG_ENTRIES } from '../packages/tok-ui/dist/updates/changelogData.js'; +import { UpdatesStateManager, compareSemver } from '../packages/tok-ui/dist/updates/updatesManager.js'; +import { parseChangelogMarkdown } from '../scripts/sync-changelog.mjs'; +import { strings } from '../packages/tok-ui/dist/i18n.js'; + +describe("What's New System - Full Test Suite", () => { + beforeEach(() => { + UpdatesStateManager.resetInstance(); + }); + + describe('Changelog Registry & Data Integrity', () => { + test('changelog entries exist and are ordered descending by semver', () => { + assert.ok(CHANGELOG_ENTRIES.length >= 3, 'Should have at least 3 historical releases'); + + for (let i = 0; i < CHANGELOG_ENTRIES.length - 1; i++) { + const vCurr = CHANGELOG_ENTRIES[i].version; + const vNext = CHANGELOG_ENTRIES[i + 1].version; + assert.ok( + compareSemver(vCurr, vNext) >= 0, + `Version ${vCurr} should be >= ${vNext}` + ); + } + }); + + test('every entry has complete Hebrew and English metadata', () => { + for (const entry of CHANGELOG_ENTRIES) { + assert.ok(entry.version, 'Entry must have a version'); + assert.match(entry.releaseDate, /^\d{4}-\d{2}-\d{2}$/, 'Release date must be YYYY-MM-DD'); + assert.ok(['major', 'minor', 'patch'].includes(entry.importance), 'Valid importance'); + + // Bilingual title and summary + assert.ok(entry.title?.he && entry.title.he.trim().length > 0, `Hebrew title missing in v${entry.version}`); + assert.ok(entry.title?.en && entry.title.en.trim().length > 0, `English title missing in v${entry.version}`); + assert.ok(entry.summary?.he && entry.summary.he.trim().length > 0, `Hebrew summary missing in v${entry.version}`); + assert.ok(entry.summary?.en && entry.summary.en.trim().length > 0, `English summary missing in v${entry.version}`); + + // Changes + assert.ok(Array.isArray(entry.changes) && entry.changes.length > 0, `Changes missing in v${entry.version}`); + for (const change of entry.changes) { + assert.ok(change.category, 'Category must be defined'); + assert.ok(change.text?.he, 'Hebrew text must be defined'); + assert.ok(change.text?.en, 'English text must be defined'); + } + + // Major releases must contain visual highlight cards + if (entry.importance === 'major') { + assert.ok(Array.isArray(entry.highlights) && entry.highlights.length > 0, `Major release v${entry.version} must have highlights`); + for (const hl of entry.highlights) { + assert.ok(hl.title?.he && hl.title?.en, 'Highlight must have bilingual title'); + assert.ok(hl.description?.he && hl.description?.en, 'Highlight must have bilingual description'); + assert.ok(hl.icon, 'Highlight must specify an icon'); + } + } + } + }); + }); + + describe('CHANGELOG.md Markdown Source-of-Truth & Parser', () => { + test('CHANGELOG.md exists in project root and parses cleanly', () => { + const changelogFile = path.join(rootDir, 'CHANGELOG.md'); + assert.ok(fs.existsSync(changelogFile), 'CHANGELOG.md must exist in root'); + + const content = fs.readFileSync(changelogFile, 'utf8'); + const parsed = parseChangelogMarkdown(content); + + assert.ok(parsed.length >= 3, 'Must parse at least 3 releases'); + assert.equal(parsed[0].version, '0.30.4'); + assert.equal(parsed[1].version, '0.30.0'); + assert.equal(parsed[1].importance, 'major'); + assert.ok(parsed[1].highlights.length >= 4, 'Major release must parse highlights'); + }); + + test('Strict No-Emoji & No-Vibe-Coding Policy in changelog and UI text', () => { + const emojiRegex = /[\u{1F300}-\u{1F5FF}\u{1F600}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1F900}-\u{1F9FF}\u{1FA70}-\u{1FAFF}]/u; + + const changelogContent = fs.readFileSync(path.join(rootDir, 'CHANGELOG.md'), 'utf8'); + assert.equal(emojiRegex.test(changelogContent), false, 'CHANGELOG.md must NOT contain any emojis (official vector icons only)'); + + const dataContent = fs.readFileSync(path.join(rootDir, 'packages/tok-ui/src/updates/changelogData.ts'), 'utf8'); + assert.equal(emojiRegex.test(dataContent), false, 'changelogData.ts must NOT contain any emojis'); + }); + }); + + describe('Semver Comparison Logic', () => { + test('compares standard versions correctly', () => { + assert.equal(compareSemver('0.30.4', '0.30.0'), 1); + assert.equal(compareSemver('0.30.0', '0.30.4'), -1); + assert.equal(compareSemver('0.30.0', '0.30.0'), 0); + }); + + test('handles v prefix and varying component lengths', () => { + assert.equal(compareSemver('v1.0.0', '0.9.9'), 1); + assert.equal(compareSemver('0.30', '0.30.0'), 0); + assert.equal(compareSemver('1.2.3', 'v1.2.3'), 0); + }); + }); + + describe('UpdatesStateManager Business Rules', () => { + const mockEntries = [ + { + version: '1.1.0', + releaseDate: '2026-11-01', + importance: 'minor', + title: { he: 'עדכון קל', en: 'Minor Update' }, + summary: { he: 'תיקונים ושיפורים קטנים', en: 'Small fixes and tweaks' }, + changes: [ + { category: 'improvements', text: { he: 'שיפור ביצועים', en: 'Performance tweak' } }, + { category: 'fixes', text: { he: 'תיקון באג', en: 'Bug fix' } } + ] + }, + { + version: '1.0.0', + releaseDate: '2026-10-15', + importance: 'major', + title: { he: 'גרסה ראשית 1.0', en: 'Major Release 1.0' }, + summary: { he: 'מהפכה במערכת', en: 'Massive milestone' }, + highlights: [ + { + id: 'engine', + title: { he: 'מנוע חדש', en: 'New Engine' }, + description: { he: 'מהיר', en: 'Fast' }, + icon: 'sparkle' + } + ], + changes: [ + { category: 'features', text: { he: 'פיצ׳ר ענק', en: 'Huge feature' } }, + { category: 'typography', text: { he: 'ניקוד מדויק', en: 'Precise niqqud' } } + ] + }, + { + version: '0.9.0', + releaseDate: '2026-09-01', + importance: 'minor', + title: { he: 'גרסה ישנה', en: 'Old version' }, + summary: { he: 'בסיס', en: 'Base' }, + changes: [ + { category: 'features', text: { he: 'בסיס', en: 'Base' } } + ] + } + ]; + + test('shouldPromptMajorUpdate triggers only for unread major releases', () => { + const manager = UpdatesStateManager.getInstance(mockEntries); + + // 1.0.0 is major, hasn't been seen -> true + assert.equal(manager.shouldPromptMajorUpdate('1.0.0'), true); + + // 1.1.0 is minor -> false (minor updates do NOT trigger popup!) + assert.equal(manager.shouldPromptMajorUpdate('1.1.0'), false); + + // After user sees 1.0.0 -> should no longer prompt + manager.markVersionAsSeen('1.0.0'); + assert.equal(manager.shouldPromptMajorUpdate('1.0.0'), false); + + // Dismissing major prompt prevents prompt + UpdatesStateManager.resetInstance(); + const manager2 = UpdatesStateManager.getInstance(mockEntries); + assert.equal(manager2.shouldPromptMajorUpdate('1.0.0'), true); + manager2.dismissMajorPrompt('1.0.0'); + assert.equal(manager2.shouldPromptMajorUpdate('1.0.0'), false); + }); + + test('hasUnreadMinorUpdates identifies unread minor releases without prompting popup', () => { + const manager = UpdatesStateManager.getInstance(mockEntries); + + // Initially user has seen 0.0.0, so 1.1.0 is unread + assert.equal(manager.hasUnreadMinorUpdates('1.1.0'), true); + + // 1.0.0 is major, so hasUnreadMinorUpdates should return false for it + assert.equal(manager.hasUnreadMinorUpdates('1.0.0'), false); + + // Once marked as seen, 1.1.0 is no longer unread + manager.markVersionAsSeen('1.1.0'); + assert.equal(manager.hasUnreadMinorUpdates('1.1.0'), false); + }); + + test('filterEntries accurately filters by importance, category and query', () => { + const manager = UpdatesStateManager.getInstance(mockEntries); + + // Filter by importance + const majorEntries = manager.filterEntries({ importance: 'major' }); + assert.equal(majorEntries.length, 1); + assert.equal(majorEntries[0].version, '1.0.0'); + + // Filter by category + const typoEntries = manager.filterEntries({ category: 'typography' }); + assert.equal(typoEntries.length, 1); + assert.equal(typoEntries[0].version, '1.0.0'); + + // Filter by textual query in Hebrew + const searchHeb = manager.filterEntries({ searchQuery: 'ביצועים' }); + assert.equal(searchHeb.length, 1); + assert.equal(searchHeb[0].version, '1.1.0'); + + // Filter by textual query in English + const searchEng = manager.filterEntries({ searchQuery: 'Huge feature' }); + assert.equal(searchEng.length, 1); + assert.equal(searchEng[0].version, '1.0.0'); + }); + }); + + describe('Localization & i18n Key Coverage', () => { + test('all What\'s New translation keys exist and are non-empty in Hebrew and English', () => { + const requiredKeys = [ + 'whatsNewTitle', + 'whatsNewSubtitle', + 'whatsNewMajorTitle', + 'whatsNewMajorSubtitle', + 'whatsNewBadgeMajor', + 'whatsNewBadgeMinor', + 'whatsNewBadgePatch', + 'whatsNewFilterAll', + 'whatsNewFilterMajor', + 'whatsNewFilterMinor', + 'whatsNewFilterFixes', + 'whatsNewFilterTypography', + 'whatsNewSearchPlaceholder', + 'whatsNewExploreAll', + 'whatsNewGotIt', + 'whatsNewDontShowAgain', + 'whatsNewViewHistory', + 'whatsNewCheckUpdates', + 'whatsNewNoResults', + 'whatsNewCatFeatures', + 'whatsNewCatImprovements', + 'whatsNewCatFixes', + 'whatsNewCatTypography', + 'whatsNewCatPerformance', + 'whatsNewUnreadAria', + 'menuWhatsNew', + 'commandPaletteWhatsNew' + ]; + + for (const k of requiredKeys) { + assert.ok(strings[k], `Missing i18n key: ${k}`); + assert.ok(strings[k].he && strings[k].he.trim().length > 0, `Key ${k} must have Hebrew string`); + assert.ok(strings[k].en && strings[k].en.trim().length > 0, `Key ${k} must have English string`); + } + }); + }); + + describe('UI Component Integration & Wiring', () => { + test('UpdatesDrawer source defines side panel with filter chips and search', () => { + const drawerSrc = fs.readFileSync(path.join(rootDir, 'packages/tok-ui/src/components/UpdatesDrawer.ts'), 'utf8'); + assert.ok(drawerSrc.includes('tok-updates-drawer'), 'Must declare drawer class'); + assert.ok(drawerSrc.includes('tok-filter-chip'), 'Must support filter chips'); + assert.ok(drawerSrc.includes('tok-updates-search-input'), 'Must support search input'); + assert.ok(drawerSrc.includes('populateCards'), 'Must have card populating logic'); + }); + + test('MajorUpdateModal source defines popup dialog with highlights grid and dismissal checkbox', () => { + const modalSrc = fs.readFileSync(path.join(rootDir, 'packages/tok-ui/src/components/MajorUpdateModal.ts'), 'utf8'); + assert.ok(modalSrc.includes('tok-major-dialog'), 'Must declare major dialog class'); + assert.ok(modalSrc.includes('tok-major-showcase-grid'), 'Must include showcase grid'); + assert.ok(modalSrc.includes('dontShowAgainCheckbox'), 'Must include dont-show-again checkbox'); + assert.ok(modalSrc.includes('dismissMajorPrompt'), 'Must hook into dismissal logic'); + }); + + test('TopSystemBar includes unread bell button and quick menu entry', () => { + const barSrc = fs.readFileSync(path.join(rootDir, 'packages/tok-ui/src/components/TopSystemBar.ts'), 'utf8'); + assert.ok(barSrc.includes('UpdatesBellButton'), 'Must use UpdatesBellButton'); + assert.ok(barSrc.includes('menuWhatsNew'), 'Must declare menuWhatsNew quick action'); + assert.ok(barSrc.includes('open-whats-new'), 'Must dispatch open-whats-new action'); + }); + + test('App shell exposes openWhatsNew, openMajorUpdate, and command palette action', () => { + const appSrc = fs.readFileSync(path.join(rootDir, 'packages/tok-ui/src/app.ts'), 'utf8'); + assert.ok(appSrc.includes('openWhatsNew('), 'Must provide openWhatsNew method'); + assert.ok(appSrc.includes('openMajorUpdate('), 'Must provide openMajorUpdate method'); + assert.ok(appSrc.includes('cmd-whats-new'), 'Must register palette command'); + assert.ok(appSrc.includes('checkStartupUpdatesPrompt'), 'Must check updates prompt on startup'); + }); + + test('AboutModal and SettingsModal offer direct links to What\'s New', () => { + const aboutSrc = fs.readFileSync(path.join(rootDir, 'packages/tok-ui/src/components/AboutModal.ts'), 'utf8'); + assert.ok(aboutSrc.includes('whatsNewTitle'), 'AboutModal must link to What\'s New'); + + const settingsSrc = fs.readFileSync(path.join(rootDir, 'packages/tok-ui/src/components/SettingsModal.ts'), 'utf8'); + assert.ok(settingsSrc.includes('whatsNewViewHistory'), 'SettingsModal updates tab must link to What\'s New history'); + }); + }); +});