From cfee89a73f49ff4e01f69776569ffbb23338f273 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 03:47:46 +0000 Subject: [PATCH 1/5] =?UTF-8?q?fix(settings):=20=D7=97=D7=99=D7=95=D7=95?= =?UTF-8?q?=D7=99=20=D7=94=D7=A9=D7=9E=D7=99=D7=A8=D7=94=20=D7=91=D7=9B?= =?UTF-8?q?=D7=A8=D7=98=D7=99=D7=A1=20=D7=92=D7=95=D7=A4=D7=9F=20=D7=94?= =?UTF-8?q?=D7=A4=D7=AA=D7=A7=D7=99=D7=9D=20=D7=94=D7=95=D7=90=20=D7=98?= =?UTF-8?q?=D7=95=D7=A1=D7=98=20=D7=97=D7=95=D7=9C=D7=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "נשמר" נשאר על המסך עד ריענון, ושינוי מתג נוסף לא הפיק שום חיווי. שני התסמינים הם אותו שורש: הכרטיס השתמש בשורת סטטוס קבועה, שאף אחד לא מסתיר, ושהשמירה השנייה כותבת בה את אותה מחרוזת בדיוק — אפס שינוי ב-DOM ולכן אפס חיווי. שורת סטטוס מתארת מצב; שמירה היא אירוע חוזר. נוספו webapp/static/css/toast.css ו-webapp/static/js/toast.js, שמשכפלים את המראה של טוסט המועדפים/הנעץ (bookmarks.css:342-355) בלי התלות ב-BookmarkManager וב-#fileId שבלעדיהם המנגנון שם כלל אינו נוצר. שניהם נטענים רק מעמוד ההגדרות. - מרחב שמות .ck-toast ולא .notification: bookmarks.css ו-multi-select.css כבר מגדירים את אותם סלקטורים עם ערכים סותרים. - הפלטה מועתקת מ-bookmarks.css. גזירה מ---bg-secondary נבדקה ונפסלה: ב-classic היא נותנת 3.9:1, מתחת ל-WCAG AA. הנמדד עכשיו: 11.92 בגרוע. ocean צורף לקבוצה הכהה — במקור הוא יורש ערכים בהירים. - window.ckToast מדווחת כשל בערך ההחזרה, והקורא בודק אותו ונופל לשורת ההודעה. בלי זה הודעת כשל שמירה הייתה נבלעת כש-toast.js אינו זמין. - ההודעה נכתבת ב-textContent, וסוג הטוסט עובר רשימת היתר לפני שהוא משורשר לשם מחלקה. - key אחיד: שמירה חדשה מחליפה את הקודמת ומבטלת את הטיימרים שלה. - pointer-events על המכל: בלעדיו הרצועה התחתונה בולעת קליקים כל עוד טוסט מוצג. אימות: 17 בדיקות ב-tests/toast.test.js ו-14 ב-settings-note-fonts, כל אחת הופלה במוטציה מכוונת — כולל חיווי שמדלג על טקסט זהה, שהוא הבאג המדויק. בנוסף הרצה בכרומיום מול /settings האמיתי בשבע ערכות הנושא: מדידת ניגודיות, אנימציית כניסה, החלפה, היעלמות, ומסלול הכשל. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019YULCppaQRPYN1RgeY6NBu --- tests/settings-note-fonts.test.js | 83 ++++++++- tests/toast.test.js | 282 ++++++++++++++++++++++++++++++ webapp/static/css/toast.css | 115 ++++++++++++ webapp/static/js/toast.js | 110 ++++++++++++ webapp/templates/settings.html | 39 ++++- 5 files changed, 617 insertions(+), 12 deletions(-) create mode 100644 tests/toast.test.js create mode 100644 webapp/static/css/toast.css create mode 100644 webapp/static/js/toast.js diff --git a/tests/settings-note-fonts.test.js b/tests/settings-note-fonts.test.js index f6589dab1..79ae3987f 100644 --- a/tests/settings-note-fonts.test.js +++ b/tests/settings-note-fonts.test.js @@ -57,8 +57,12 @@ function mkSelect(surface, value) { /** * ``fetchImpl`` מקבל את גוף הבקשה המפורסר ומחזיר ``{ok, body}``. * ברירת המחדל: הצלחה. + * + * ``opts.toast`` — ``undefined``: טוסט שמצליח (המסלול הרגיל); + * ``false``: טוסט שאינו מצליח להציג; ``null``: אין ``ckToast`` כלל. + * שני האחרונים חייבים ליפול לשורת ההודעה, אחרת הודעת כשל שמירה נעלמת. */ -function load(fetchImpl) { +function load(fetchImpl, opts = {}) { const sent = []; const msg = { textContent: '', style: { display: 'none', color: '' } }; const selects = [ @@ -87,10 +91,18 @@ function load(fetchImpl) { }; }, }; + const toasts = []; + if (opts.toast !== null) { + sandbox.ckToast = (message, type, o) => { + toasts.push({ message, type, key: o && o.key }); + return opts.toast !== false; + }; + } + vm.createContext(sandbox); sandbox.window = sandbox; vm.runInContext(fontsScript(), sandbox); - return { sent, msg, selects, scopeSel }; + return { sent, msg, selects, scopeSel, toasts }; } // ─── גוף הבקשה ────────────────────────────────────────────────────────── @@ -131,14 +143,15 @@ await acheck('שינוי התחולה שולח גם את הערכים', async () // ─── כשל ──────────────────────────────────────────────────────────────── -await acheck('כשל מחזיר את הבורר ומציג הודעה', async () => { +await acheck('כשל מחזיר את הבורר ומציג חיווי שגיאה', async () => { const h = load(async () => ({ ok: false, status: 500, body: null })); h.selects[1].value = '1'; await h.selects[1].__fire('change'); eq(h.selects[1].value, '0', 'הבורר הוחזר'); - eq(h.msg.style.display, 'block', 'ההודעה מוצגת'); - eq(h.msg.textContent.includes('נכשלה'), true, 'נוסח ההודעה'); + eq(h.toasts.length, 1, 'מספר החיוויים'); + eq(h.toasts[0].type, 'error', 'סוג החיווי'); + eq(h.toasts[0].message.includes('נכשלה'), true, 'נוסח ההודעה'); }); await acheck('הצלחה מציגה אישור ואינה מחזירה כלום', async () => { @@ -146,7 +159,9 @@ await acheck('הצלחה מציגה אישור ואינה מחזירה כלום' h.selects[0].value = '1'; await h.selects[0].__fire('change'); eq(h.selects[0].value, '1', 'הבורר נשאר'); - eq(h.msg.textContent, 'נשמר', 'ההודעה'); + eq(h.toasts.length, 1, 'מספר החיוויים'); + eq(h.toasts[0].message, 'נשמר', 'ההודעה'); + eq(h.toasts[0].type, 'success', 'סוג החיווי'); }); await acheck('כשל מחזיר למצב האחרון שנשמר, לא לערך הקודם', async () => { @@ -235,7 +250,7 @@ await acheck('הצלחה ואז כשל באותו תור — ההחזרה תוא eq(h.sent.length, 2, 'שתי בקשות'); eq(h.selects[0].value, '1', 'מה שהשרת קיבל נשאר על המסך'); eq(h.selects[1].value, '0', 'מה שנכשל הוחזר'); - eq(h.msg.textContent.includes('נכשלה'), true, 'ההודעה'); + eq(h.toasts[h.toasts.length - 1].type, 'error', 'החיווי האחרון'); }); await acheck('הגוף שנשלח תואם ל-snapshot של אותו רגע', async () => { @@ -262,5 +277,59 @@ await acheck('הגוף שנשלח תואם ל-snapshot של אותו רגע', as JSON.stringify({ repo: true, md: false, board: true }), 'השנייה'); }); +// ─── החיווי ───────────────────────────────────────────────────────────── + +await acheck('שתי שמירות מוצלחות מפיקות שני חיוויים', async () => { + // **זה הבאג שדווח.** בגרסה הקודמת החיווי היה שורת סטטוס קבועה, ושמירה + // שנייה כתבה בה את אותה מחרוזת בדיוק — אפס שינוי ב-DOM, ולכן אפס חיווי. + // בדיקה על תוכן השורה הייתה עוברת גם על הקוד השבור; רק ספירת אירועים + // מבחינה ביניהם. + const h = load(); + h.selects[0].value = '1'; + await h.selects[0].__fire('change'); + h.selects[1].value = '1'; + await h.selects[1].__fire('change'); + + eq(h.sent.length, 2, 'שתי בקשות'); + eq(h.toasts.length, 2, 'שני חיוויים — לא אחד'); + eq(h.toasts[1].message, 'נשמר', 'גם השני'); +}); + +await acheck('כל החיוויים נושאים מפתח אחיד', async () => { + // המפתח הוא מה שגורם לחיווי חדש להחליף את הקודם במקום להיערם לצידו. + const h = load(); + h.selects[0].value = '1'; + await h.selects[0].__fire('change'); + h.scopeSel.value = 'device'; + await h.scopeSel.__fire('change'); + + eq(h.toasts.length, 2, 'שני חיוויים'); + eq(h.toasts[0].key, 'note-fonts', 'מפתח ראשון'); + eq(h.toasts[1].key, 'note-fonts', 'מפתח שני'); +}); + +await acheck('בלי ckToast — ההודעה נופלת לשורת הגיבוי', async () => { + // ``toast.js`` נטען ב-``defer``. אם הוא לא הגיע, הודעת כשל שמירה + // חייבת עדיין להגיע למשתמש. + const h = load(async () => ({ ok: false, status: 500, body: null }), { toast: null }); + h.selects[1].value = '1'; + await h.selects[1].__fire('change'); + + eq(h.msg.style.display, 'block', 'השורה מוצגת'); + eq(h.msg.textContent.includes('נכשלה'), true, 'נוסח ההודעה'); +}); + +await acheck('ckToast שהחזירה false — נופלת לשורת הגיבוי', async () => { + // ``ckToast`` מדווחת כשל בערך ההחזרה ולא בזריקה. קורא שמתעלם ממנו + // מציג "הכול תקין" על חיווי שלא הוצג — K11 בדפוסי הבאגים. + const h = load(async () => ({ ok: false, status: 500, body: null }), { toast: false }); + h.selects[1].value = '1'; + await h.selects[1].__fire('change'); + + eq(h.toasts.length, 1, 'הטוסט נוסה'); + eq(h.msg.style.display, 'block', 'ובכל זאת השורה הוצגה'); + eq(h.msg.textContent.includes('נכשלה'), true, 'נוסח ההודעה'); +}); + console.log(`${passed} עברו, ${failed} נכשלו`); process.exit(failed === 0 ? 0 : 1); diff --git a/tests/toast.test.js b/tests/toast.test.js new file mode 100644 index 000000000..468493bd6 --- /dev/null +++ b/tests/toast.test.js @@ -0,0 +1,282 @@ +'use strict'; +// בדיקות ל-``webapp/static/js/toast.js`` — טוסט החיווי של עמוד ההגדרות. +// +// **מה הבדיקות האלה כן מוכיחות:** את הלוגיקה — בריחה מ-HTML, רשימת ההיתר +// של הסוג, ההחלפה לפי מפתח, ביטול הטיימרים, וערוץ הכשל. +// +// **ומה לא:** שהטוסט באמת נראה ומונפש. DOM מזויף לא אוכף פריסה, מעברים או +// ערכות נושא. את זה מוכיחה רק הרצה בדפדפן אמיתי — ראו כלל 2 ב- +// ``claude-md-snippets/testing.md``. + +import fs from 'fs'; +import path from 'path'; +import vm from 'vm'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const SRC = fs.readFileSync( + path.join(__dirname, '..', 'webapp', 'static', 'js', 'toast.js'), 'utf8'); + +let passed = 0, failed = 0; +function check(name, fn) { + try { fn(); passed += 1; } + catch (e) { failed += 1; console.error(`✗ ${name}\n ${e && e.message}`); } +} +function eq(a, b, what) { + if (a !== b) throw new Error(`${what || ''} — ציפיתי ל-${JSON.stringify(b)}, קיבלתי ${JSON.stringify(a)}`); +} +function ok(v, what) { if (!v) throw new Error(what || 'ציפיתי לאמת'); } + +/** אלמנט מדומה — רק מה ש-``toast.js`` באמת נוגע בו. */ +function mkEl() { + const el = { + id: '', className: '', textContent: '', + attrs: {}, children: [], parentNode: null, + // קריאתו היא שמכריחה reflow בקוד האמיתי; כאן רק סופרים. + reads: 0, + get offsetWidth() { el.reads += 1; return 1; }, + setAttribute: (k, v) => { el.attrs[k] = v; }, + appendChild: (c) => { c.parentNode = el; el.children.push(c); return c; }, + removeChild: (c) => { + const i = el.children.indexOf(c); + if (i >= 0) el.children.splice(i, 1); + c.parentNode = null; + return c; + }, + classList: { + add: (c) => { if (!el.className.split(' ').includes(c)) el.className += ' ' + c; }, + remove: (c) => { + el.className = el.className.split(' ').filter((x) => x && x !== c).join(' '); + }, + contains: (c) => el.className.split(' ').includes(c), + }, + }; + return el; +} + +/** + * טוען את הסקריפט לסנדבוקס עם DOM וטיימרים נשלטים. + * ``withBody: false`` מדמה קריאה לפני שה-``body`` קיים. + */ +function load({ withBody = true } = {}) { + const body = withBody ? mkEl() : null; + const byId = new Map(); + let now = 0, seq = 0; + const timers = new Map(); // id ← { at, fn } + + const sandbox = { + Object, String, Array, JSON, Error, + document: { + get body() { return body; }, + getElementById: (id) => byId.get(id) || null, + createElement: () => mkEl(), + }, + setTimeout: (fn, ms) => { + const id = ++seq; + timers.set(id, { at: now + ms, fn }); + return id; + }, + clearTimeout: (id) => { timers.delete(id); }, + }; + vm.createContext(sandbox); + sandbox.window = sandbox; + vm.runInContext(SRC, sandbox); + + // הקונטיינר נרשם ב-``byId`` ברגע שנוצר, כמו ``document.getElementById``. + const origAppend = body ? body.appendChild : null; + if (body) { + body.appendChild = (c) => { if (c.id) byId.set(c.id, c); return origAppend(c); }; + } + + return { + toast: (...a) => sandbox.window.ckToast(...a), + body, + container: () => byId.get('ckToastContainer') || null, + pending: () => timers.size, + // שעון אירועים בדיד: ``now`` מתקדם **אל** כל טיימר לפי סדר, ולא קופץ + // ואז מנקז. ההבדל אינו קוסמטי — טיימר שנקבע מתוך callback נמדד מזמן + // הירי שלו, וקפיצה מראש הייתה דוחה אותו מעבר לחלון הנבדק. + advance: (ms) => { + const target = now + ms; + for (;;) { + let nextId = null, next = null; + for (const [id, t] of timers) { + if (t.at <= target && (next === null || t.at < next.at)) { nextId = id; next = t; } + } + if (next === null) { now = target; return; } + now = next.at; + timers.delete(nextId); + next.fn(); + } + }, + }; +} + +// ─── בריחה וולידציה ───────────────────────────────────────────────────── + +check('ההודעה נכתבת כטקסט, לא כ-HTML', () => { + // הקורא מעביר גם ``data.error`` מהשרת. ``bulk-actions.js:440`` מזריק + // ערך כזה ל-``innerHTML``; כאן זה חייב להישאר טקסט. K4 בדפוסי הבאגים. + const h = load(); + const evil = ''; + h.toast(evil, 'error'); + const card = h.container().children[0]; + const msg = card.children[1]; + eq(msg.textContent, evil, 'ההודעה'); + eq(msg.children.length, 0, 'לא נוצרו צאצאים מההודעה'); +}); + +check('סוג לא מוכר אינו נכנס לשם המחלקה', () => { + // ``type`` משורשר לתוך ``className``. בלי רשימת היתר, ערך שרירותי + // מזריק מחלקות. + const h = load(); + h.toast('שלום', 'evil ck-toast--success'); + const card = h.container().children[0]; + eq(card.className.includes('ck-toast--info'), true, 'נפילה ל-info'); + eq(card.className.includes('evil'), false, 'הערך לא חלחל'); +}); + +check('כל סוג מוכר מקבל את המחלקה ואת האייקון שלו', () => { + const expect = { success: '✓', error: '✕', warning: '⚠', info: 'ℹ' }; + for (const [type, icon] of Object.entries(expect)) { + const h = load(); + h.toast('טקסט', type); + const card = h.container().children[0]; + eq(card.className.includes('ck-toast--' + type), true, `מחלקה ל-${type}`); + eq(card.children[0].textContent, icon, `אייקון ל-${type}`); + } +}); + +check('האייקון מוסתר מקורא מסך', () => { + const h = load(); + h.toast('טקסט', 'success'); + eq(h.container().children[0].children[0].attrs['aria-hidden'], 'true', 'aria-hidden'); +}); + +// ─── כניסה ────────────────────────────────────────────────────────────── + +check('מאפיין הפריסה נקרא לפני הוספת מחלקת הכניסה', () => { + // בלי הקריאה הזו אין reflow, הדפדפן מקבץ את שני הסגנונות, ואין ממה + // להנפיש — הטוסט פשוט מופיע. זו הסיבה היחידה שהשורה קיימת. + const h = load(); + h.toast('נשמר', 'success'); + const card = h.container().children[0]; + eq(card.reads > 0, true, 'offsetWidth נקרא'); + eq(card.classList.contains('is-shown'), true, 'מחלקת הכניסה'); +}); + +check('הקונטיינר נוצר פעם אחת בלבד', () => { + const h = load(); + h.toast('א', 'info'); + h.toast('ב', 'info'); + eq(h.body.children.length, 1, 'ילדי body'); + eq(h.container().children.length, 2, 'שני טוסטים באותו קונטיינר'); + eq(h.container().attrs['aria-live'], 'polite', 'aria-live'); +}); + +// ─── מפתח והחלפה ──────────────────────────────────────────────────────── + +check('אותו מפתח מחליף במקום לערום', () => { + // **זה הבאג שדווח.** בלי זה, שלוש שמירות ברצף מייצרות שלושה כרטיסים + // זהים זה לצד זה. + const h = load(); + h.toast('נשמר', 'success', { key: 'k' }); + const first = h.container().children[0]; + h.toast('נשמר', 'success', { key: 'k' }); + + eq(h.container().children.length, 1, 'כרטיס אחד'); + eq(first.parentNode, null, 'הקודם נותק'); + eq(h.container().children[0] === first, false, 'זהו כרטיס חדש'); + eq(h.container().children[0].classList.contains('is-shown'), true, 'נכנס מחדש'); +}); + +check('מפתחות שונים מצטברים', () => { + const h = load(); + h.toast('א', 'info', { key: 'a' }); + h.toast('ב', 'info', { key: 'b' }); + eq(h.container().children.length, 2, 'שני כרטיסים'); +}); + +check('בלי מפתח — מצטבר', () => { + const h = load(); + h.toast('א', 'info'); + h.toast('ב', 'info'); + eq(h.container().children.length, 2, 'שני כרטיסים'); +}); + +check('ההחלפה מבטלת את הטיימרים של הקודם', () => { + // בלי ``clearTimeout``, כל שמירה משאירה טיימר תלוי על כרטיס מנותק. + // בהחלפה מהירה חוזרת הם נערמים בלי גבול. + const h = load(); + h.toast('נשמר', 'success', { key: 'k' }); + eq(h.pending(), 1, 'טיימר אחד אחרי הראשון'); + h.toast('נשמר', 'success', { key: 'k' }); + eq(h.pending(), 1, 'עדיין אחד אחרי ההחלפה — לא שניים'); +}); + +check('טיימר של כרטיס שהוחלף אינו נוגע בחדש', () => { + const h = load(); + h.toast('ראשון', 'success', { key: 'k' }); + h.advance(3900); // כמעט הגיע זמנו של הראשון + h.toast('שני', 'success', { key: 'k' }); + h.advance(200); // הזמן שבו הראשון היה נעלם + const card = h.container().children[0]; + eq(h.container().children.length, 1, 'הכרטיס עדיין שם'); + eq(card.classList.contains('is-shown'), true, 'ועדיין מוצג'); + eq(card.children[1].textContent, 'שני', 'וזה החדש'); +}); + +// ─── היעלמות ──────────────────────────────────────────────────────────── + +check('נעלם מעצמו אחרי ברירת המחדל', () => { + const h = load(); + h.toast('נשמר', 'success', { key: 'k' }); + h.advance(3999); + eq(h.container().children.length, 1, 'לפני הזמן — עדיין שם'); + h.advance(1); + eq(h.container().children[0].classList.contains('is-shown'), false, 'יציאה החלה'); + eq(h.container().children.length, 1, 'עדיין ב-DOM בזמן היציאה'); + h.advance(300); + eq(h.container().children.length, 0, 'הוסר'); + eq(h.pending(), 0, 'לא נשארו טיימרים'); +}); + +check('duration מותאם מכובד', () => { + const h = load(); + h.toast('נשמר', 'success', { duration: 1000 }); + h.advance(1000); + eq(h.container().children[0].classList.contains('is-shown'), false, 'יציאה מוקדמת'); +}); + +check('אחרי היעלמות, מפתח זהה נפתח מחדש', () => { + const h = load(); + h.toast('א', 'success', { key: 'k' }); + h.advance(4300); + eq(h.container().children.length, 0, 'נוקה'); + h.toast('ב', 'success', { key: 'k' }); + eq(h.container().children.length, 1, 'נפתח מחדש'); + eq(h.container().children[0].classList.contains('is-shown'), true, 'ומוצג'); +}); + +// ─── ערוץ הכשל ────────────────────────────────────────────────────────── + +check('מחזירה true כשהוצגה', () => { + const h = load(); + eq(h.toast('נשמר', 'success'), true, 'ערך ההחזרה'); +}); + +check('מחזירה false כשאין body — ולא זורקת', () => { + // הקורא בעמוד ההגדרות נשען על הערך הזה כדי ליפול לשורת הגיבוי. אם + // הפונקציה הייתה מחזירה undefined בשקט, הודעת כשל שמירה הייתה נעלמת. + const h = load({ withBody: false }); + eq(h.toast('השמירה נכשלה', 'error'), false, 'ערך ההחזרה'); +}); + +check('הודעה ריקה או חסרה אינה מייצרת "undefined"', () => { + const h = load(); + h.toast(undefined, 'info'); + eq(h.container().children[0].children[1].textContent, '', 'טקסט ריק'); +}); + +console.log(`${passed} עברו, ${failed} נכשלו`); +process.exit(failed === 0 ? 0 : 1); diff --git a/webapp/static/css/toast.css b/webapp/static/css/toast.css new file mode 100644 index 000000000..b490ffbcb --- /dev/null +++ b/webapp/static/css/toast.css @@ -0,0 +1,115 @@ +/* טוסט חיווי — התראה חולפת לפעולות שנשמרות מיד. + * + * המראה משוכפל מטוסט המועדפים/הנעץ (``bookmarks.css:342-355``), שנטען רק + * דרך ``bookmarks_snippet.html``. הקובץ הזה עומד בפני עצמו: אין לו תלות + * בשום קובץ אחר, ואפשר לטעון אותו לכל עמוד. + * + * **מרחב שמות משלו, בכוונה.** ``bookmarks.css`` ו-``multi-select.css`` + * מגדירים שניהם את ``.notification`` / ``.notification-container`` / + * ``#notificationContainer`` עם ערכים סותרים לגמרי (פינה תחתונה מול + * עליונה, טוקני נושא מול גרדיאנט כהה קשיח). היום אין עמוד שטוען את + * שניהם, אבל דייר שלישי על אותם שמות היה הופך את זה לוודאי. + */ + +:root { + /* טוקני רכיב (Level 3 לפי ``docs/webapp/theming_and_css.rst``), עם + * הערכים של ``bookmarks.css:2-73`` — הטוסט שהמראה שלו נבחר. + * + * **הרקע אינו נגזר מ-``--bg-secondary``, ונמדד למה.** גזירה כזו + * מתאימה את הטוסט לערכה מאליה, אבל ב-``classic`` היא נותנת סגול + * ``#8e63ff`` עם טקסט לבן — **3.9:1**, מתחת ל-4.5 של WCAG AA. נמדד + * בכרומיום, לא הוערך. כרטיס בהיר קבוע נותן שם 16:1. + * + * **``ocean`` נוסף לקבוצה הכהה.** ב-``bookmarks.css`` הוא יורש את + * ערכי ה-``:root`` הבהירים, ולכן מקבל כרטיס לבן על ערכה כהה. קריא, + * אבל מסנוור. כאן זה מתוקן. */ + --ck-toast-bg: rgba(255, 255, 255, 0.97); + --ck-toast-color: #212529; + --ck-toast-accent: var(--info); +} + +:root[data-theme="dark"], +:root[data-theme="dim"], +:root[data-theme="nebula"], +:root[data-theme="ocean"] { + --ck-toast-bg: rgba(18, 20, 28, 0.98); + --ck-toast-color: var(--text-primary, #f2f4ff); +} + +:root[data-theme="high-contrast"] { + --ck-toast-bg: #111111; + --ck-toast-color: #ffffff; +} + +.ck-toast-container { + position: fixed; + bottom: 20px; + left: 20px; + right: 20px; + z-index: 10000; + display: flex; + flex-direction: column; + gap: 10px; + align-items: flex-end; + /* המכל פרוס על כל רוחב המסך ב-z-index גבוה. בלי זה, כל עוד טוסט מוצג + * הרצועה התחתונה בולעת קליקים על מה שמתחתיה. */ + pointer-events: none; +} + +.ck-toast { + background: var(--ck-toast-bg); + color: var(--ck-toast-color); + border-radius: 8px; + padding: 12px 20px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + display: flex; + align-items: center; + gap: 10px; + min-width: 250px; + max-width: min(420px, calc(100vw - 40px)); + transform: translateX(400px); + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); + pointer-events: auto; + /* ``border-inline-end`` ולא ``border-left``: ב-```` + * שתיהן מרנדרות לאותו מקום פיזי, והלוגית שורדת גם LTR. */ + border-inline-end: 4px solid var(--ck-toast-accent); +} + +.ck-toast.is-shown { + transform: translateX(0); +} + +.ck-toast--success { --ck-toast-accent: var(--success); } +.ck-toast--error { --ck-toast-accent: var(--danger); } +.ck-toast--warning { --ck-toast-accent: var(--warning); } +.ck-toast--info { --ck-toast-accent: var(--info); } + +.ck-toast__icon { + font-size: 18px; + font-weight: bold; + color: var(--ck-toast-accent); + flex-shrink: 0; +} + +.ck-toast__msg { + flex: 1; + font-size: 14px; +} + +@media (max-width: 768px) { + .ck-toast-container { + left: 10px; + right: 10px; + bottom: 10px; + } + .ck-toast { + min-width: auto; + width: 100%; + } +} + +@media (prefers-reduced-motion: reduce) { + .ck-toast { + transition: none; + } +} diff --git a/webapp/static/js/toast.js b/webapp/static/js/toast.js new file mode 100644 index 000000000..82c6db71a --- /dev/null +++ b/webapp/static/js/toast.js @@ -0,0 +1,110 @@ +/** + * טוסט חיווי — התראה חולפת לפעולות שנשמרות מיד. + * + * מבוסס על ``bookmarks.js:944-962`` (טוסט המועדפים/הנעץ), אבל עומד בפני + * עצמו: בלי מחלקה, בלי ``BookmarkManager``, ובלי הדרישה ל-``#fileId`` + * שבלעדיה המנגנון שם כלל אינו נוצר (``bookmarks.js:1645-1656``). + * + * ה-CSS ב-``webapp/static/css/toast.css``. שניהם נטענים היום רק מעמוד + * ההגדרות. + * + * **השם אינו ``window.showNotification`` בכוונה.** שלושה מקומות בקוד + * (``collections.js:124``, ``dashboard.html:2756``, ``base.html:4291``) + * בודקים את השם ההוא ונופלים אחורה לטוסט מאולתר. הגדרתו בעמוד אחד בלבד + * הייתה משנה את התנהגותם שם ולא בשאר — חוסר עקביות גרוע מהמצב הנוכחי. + */ +(function () { + 'use strict'; + + var ICONS = { success: '✓', error: '✕', warning: '⚠', info: 'ℹ' }; + var DEFAULT_DURATION = 4000; + /** חייב להתאים ל-``transition`` של ``.ck-toast`` ב-``toast.css``. */ + var EXIT_MS = 300; + + /** ``key`` ← ``{ el, hideTimer, removeTimer }`` של הטוסט שמוצג כרגע. */ + var active = Object.create(null); + + function getContainer() { + if (!document.body) return null; + var el = document.getElementById('ckToastContainer'); + if (el) return el; + el = document.createElement('div'); + el.id = 'ckToastContainer'; + el.className = 'ck-toast-container'; + el.setAttribute('role', 'status'); + el.setAttribute('aria-live', 'polite'); + document.body.appendChild(el); + return el; + } + + /** מסיר מיד, בלי אנימציה, ומבטל את שני הטיימרים שלו. */ + function drop(entry) { + if (!entry) return; + if (entry.hideTimer) clearTimeout(entry.hideTimer); + if (entry.removeTimer) clearTimeout(entry.removeTimer); + if (entry.el && entry.el.parentNode) entry.el.parentNode.removeChild(entry.el); + } + + /** + * מציג טוסט. + * + * ``type`` — ``success`` / ``error`` / ``warning`` / ``info``. כל ערך + * אחר נקרא כ-``info``; הוא נכנס לתוך שם מחלקה, ולכן חייב + * לעבור רשימת היתר ולא רק שרשור. + * ``options.key`` — טוסט עם אותו מפתח **מחליף** את קודמו במקום + * להצטבר לצידו. בלי זה, שלוש שמירות ברצף + * מייצרות שלושה כרטיסים זהים; ועם זה אנימציית + * הכניסה רצה מחדש בכל פעם, וזה החיווי. + * ``options.duration`` — מילישניות עד ההיעלמות. + * + * **ערוץ הכשל הוא ערך ההחזרה:** ``true`` אם הטוסט נכנס ל-DOM, + * ``false`` אם לא היה ``document.body``. הקורא חייב לבדוק — טוסט הוא + * ערוץ הדיווח למשתמש, ואם הוא לא הוצג צריך ליפול למשהו אחר. + */ + window.ckToast = function (message, type, options) { + var opts = options || {}; + var kind = Object.prototype.hasOwnProperty.call(ICONS, type) ? type : 'info'; + + var container = getContainer(); + if (!container) return false; + + if (opts.key) drop(active[opts.key]); + + var el = document.createElement('div'); + el.className = 'ck-toast ck-toast--' + kind; + + var icon = document.createElement('span'); + icon.className = 'ck-toast__icon'; + icon.setAttribute('aria-hidden', 'true'); + icon.textContent = ICONS[kind]; + + var msg = document.createElement('span'); + msg.className = 'ck-toast__msg'; + // ``textContent`` ולא ``innerHTML``: ההודעה נושאת גם טקסט שגיאה + // שמגיע מהשרת. ``bulk-actions.js:440`` מזריק אותו גולמי. + msg.textContent = message == null ? '' : String(message); + + el.appendChild(icon); + el.appendChild(msg); + container.appendChild(el); + + // קריאת מאפיין פריסה מכריחה את הדפדפן לחשב את הסגנון ההתחלתי + // (``translateX(400px)``) לפני שינוי המחלקה, ובלעדיה אין ממה + // להנפיש. זה הדטרמיניסטי; ``setTimeout(.., 10)`` היה מרוץ. + void el.offsetWidth; + el.classList.add('is-shown'); + + var entry = { el: el, hideTimer: null, removeTimer: null }; + + entry.hideTimer = setTimeout(function () { + el.classList.remove('is-shown'); + entry.removeTimer = setTimeout(function () { + if (el.parentNode) el.parentNode.removeChild(el); + if (opts.key && active[opts.key] === entry) delete active[opts.key]; + }, EXIT_MS); + }, opts.duration || DEFAULT_DURATION); + + if (opts.key) active[opts.key] = entry; + return true; + }; +})(); diff --git a/webapp/templates/settings.html b/webapp/templates/settings.html index 4959a8fac..e79d0c615 100644 --- a/webapp/templates/settings.html +++ b/webapp/templates/settings.html @@ -1,5 +1,12 @@ {% extends "base.html" %} {% block title %}הגדרות - Code Keeper Bot{% endblock -%} {% block content %} +%} +{% block extra_css %} +{# טוסט החיווי של כרטיס "גופן הפתקים". עומד בפני עצמו — אין לו תלות + ב-bookmarks.css או ב-multi-select.css, ומרחב השמות שלו נפרד מ- + .notification שאותם שני קבצים כבר חולקים עם ערכים סותרים. #} + +{% endblock %} +{% block content %}

הגדרות @@ -365,6 +372,10 @@

{% endif %} +{# מגדיר window.ckToast. defer מספיק: הקריאה מתרחשת רק באינטראקציה של + המשתמש, הרבה אחרי ש-defer סיים לרוץ. #} + + {# הוראות לסוכן — הטקסט שנטען לכל סשן של סוכן AI דרך GET /api/agent/primer #}

@@ -1122,8 +1133,9 @@

התחולה חלה על שלוש הבחירות יחד. כשבוחרים כתב יד בלוחות, המתג שבמודאל ההגדרות של הלוח ננעל.

- {# שורת משוב, באותו דפוס של ``setMessage`` בכרטיס "הוראות לסוכן": #} - {# לא מודאל ולא ``alert`` — הבורר נשאר על המסך לצד ההודעה. #} + {# רשת ביטחון בלבד. החיווי הרגיל הוא ``window.ckToast``; השורה הזו #} + {# נכנסת לפעולה רק אם ``toast.js`` לא נטען, כדי שהודעת כשל שמירה #} + {# לא תיעלם בשקט. במסלול התקין היא נשארת ריקה ובלתי נראית. #}

@@ -2186,6 +2198,23 @@

msgEl.style.color = ok ? 'var(--success, #4ade80)' : 'var(--danger, #f87171)'; } + // **החיווי הוא אירוע חוזר, לא מצב.** שורת סטטוס קבועה לא מסתירה + // את עצמה, ושמירה שנייה שכותבת בה את אותה מחרוזת אינה משנה כלום + // ב-DOM — כלומר אין חיווי בכלל. טוסט חולף נכנס מחדש בכל שמירה. + // + // ``key`` אחיד לשלושת הבוררים: שמירה חדשה מחליפה את הקודמת במקום + // לערום עוד כרטיס זהה לצידה. + // + // ``ckToast`` מחזירה ``false`` כשלא הצליחה להציג. בלי הבדיקה, + // כשל שמירה היה נבלע לגמרי כשהסקריפט אינו זמין. + function notify(text, type) { + if (typeof window.ckToast === 'function' + && window.ckToast(text, type, { key: 'note-fonts' })) { + return; + } + setMessage(text, type === 'success'); + } + // צילום של כל הבוררים, כדי שאפשר יהיה להחזיר אותם בכשל. function snapshot() { @@ -2258,11 +2287,11 @@

await send(sent); lastSaved = sent; } while (pending); - setMessage('נשמר', true); + notify('נשמר', 'success'); } catch (err) { console.error('Failed to persist note fonts:', err); restore(lastSaved); - setMessage('השמירה נכשלה — הבחירה הוחזרה', false); + notify('השמירה נכשלה — הבחירה הוחזרה', 'error'); } finally { inFlight = false; } From 4bb80e3f572b1035a6c124afbeb950d237aa15b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 03:51:45 +0000 Subject: [PATCH 2/5] =?UTF-8?q?docs(theming):=20=D7=AA=D7=99=D7=A2=D7=95?= =?UTF-8?q?=D7=93=20=D7=9E=D7=A9=D7=A4=D7=97=D7=AA=20--ck-toast-*,=20?= =?UTF-8?q?=D7=95-ocean=20=D7=97=D7=95=D7=96=D7=A8=20=D7=9C=D7=91=D7=A8?= =?UTF-8?q?=D7=99=D7=A8=D7=AA=20=D7=94=D7=9E=D7=97=D7=93=D7=9C=20=D7=94?= =?UTF-8?q?=D7=91=D7=94=D7=99=D7=A8=D7=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit הפלטה של הטוסט משוכפלת מ-bookmarks.css אחד לאחד. ההוספה של ocean לקבוצת ה-override הכהה הוסרה — שם הוא יורש את ברירת המחדל הבהירה, וזה נשאר כפי שהוא. בנוסף, לפי הצ'קליסט בתבנית ה-PR, טוקנים חדשים מתועדים ב- docs/webapp/theming_and_css.rst וב-FEATURE_SUGGESTIONS/theme_matrix.md. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019YULCppaQRPYN1RgeY6NBu --- FEATURE_SUGGESTIONS/theme_matrix.md | 1 + docs/webapp/theming_and_css.rst | 3 ++- webapp/static/css/toast.css | 11 ++++------- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/FEATURE_SUGGESTIONS/theme_matrix.md b/FEATURE_SUGGESTIONS/theme_matrix.md index e0817c2f0..09d58ce61 100644 --- a/FEATURE_SUGGESTIONS/theme_matrix.md +++ b/FEATURE_SUGGESTIONS/theme_matrix.md @@ -16,6 +16,7 @@ | ``--btn-primary-*`` | `color-mix` על סמך ``--bg-tertiary`` | טקסט כהה על לבן | טקסט כחול כהה על לבן | טקסט ירוק כהה על לבן | `color-mix` עם ורוד | שחור/לבן/צהוב בלבד | | ``--md-surface`` / ``--md-text`` | כהה אחיד | כהה מותאם (`#15141f`) | כהה כחול (`#1a365d`) | כהה ירוק (`#1a4731`) | נשאר כהה כדי להגן על Highlight | שחור/לבן בהתאם לחוקים | | ``--split-preview-*`` | מוגדר בערכי ברירת מחדל ב-`split-view.css` | ✔️ | ✔️ (rgba בגוון כחול) | ✔️ (rgba ירוק) | ✔️ (rgba סגלגל) | ✔️ (שחור/לבן/צהוב) | +| ``--ck-toast-*`` | Override כהה ב-`toast.css` | ברירת המחדל הבהירה | ברירת המחדל הבהירה | ברירת המחדל הבהירה | ברירת המחדל הבהירה | שחור/לבן ייעודי | | ``--search-*`` | משתמש בערכי ברירת מחדל כהים | צל ייחודי `rgba(7,7,31,0.35)` | צל כחול + הדגשת info | צל ירוק | מדגיש צבעי עץ | שחור/לבן עם צהוב (WCAG) | ## בדיקות רגרסיה diff --git a/docs/webapp/theming_and_css.rst b/docs/webapp/theming_and_css.rst index 13e813e86..0b037c326 100644 --- a/docs/webapp/theming_and_css.rst +++ b/docs/webapp/theming_and_css.rst @@ -321,6 +321,7 @@ Component Tokens ו‑Theme Builder - Collections (`webapp/static/css/collections.css`) עדיין מכיל צבעים קשיחים ישנים – כל שינוי חייב להמיר ל‑`var()` לפי טבלת הטוקנים. - Split View ו‑Markdown Enhanced משתמשים ב‑``--split-*`` ו‑``--md-*`` בהתאמה – הוסיפו טוקן לפני שמוסיפים Class חדש. - Sticky Notes, Reader Modes (`md_preview.html`) וה‑``theme_preview.html`` הם חריגים שנשארים Hardcoded כדי לשמור על תצוגת Preset. +- ``toast.css`` מגדיר את משפחת ``--ck-toast-*`` (Level 3) ונטען כיום רק מ-``settings.html``. מרחב השמות שלו נפרד מ-``.notification`` בכוונה: ``bookmarks.css`` ו-``multi-select.css`` מגדירים את הסלקטור ההוא בשניהם, עם ערכים סותרים. את ערכי הרקע והטקסט הוא מעתיק מ-``bookmarks.css`` במקום לגזור אותם מ-``--bg-secondary``; הגזירה נבדקה ונפסלה כי ב-Classic היא יורדת מתחת ליחס הניגודיות הנדרש. - `global_search.css` הינו דוגמה מצוינת לרכיב Component Tokens – כאשר מוסיפים תכונה חדשה (למשל badge נוסף) המשיכו את התבנית שם. - Collections / Split View / Markdown Enhanced מוזכרים בדף זה כדי שמפתחים ידעו להצליב בין הרכיבים ולזהות אילו טוקנים משותפים. @@ -336,7 +337,7 @@ Component Tokens ו‑Theme Builder - ``webapp/templates/base.html`` – בלוק ה‑``