עקרונות להוספת פיצ’ר לסטיקי-נוטס — הממשק בדפדפן: גאומטריה, אינטראקציה וניגודיות
העמוד הזה הוא חלק מ-עקרונות להוספת פיצ’ר לסטיקי-נוטס, ומחזיק את הפתק כווידג’ט בדפדפן: מיקום וגודל, אינטראקציה, וצבעים שנמדדים על הנייר. מה שנשמר במסד ועובר בין השירותים — ב-עמוד השרת, ומנוע הרינדור של הפתק — ב-עמוד מנוע המארקדאון.
עיגון שורה בפתקי ריפו: למה anchored אינו זמין
anchored מחייב עוגן שורה יציב. דפדפן הריפו משתמש ב-CodeMirror, שמרנדר רק את השורות שבתחום הנראה; לכן אין אלמנט DOM יציב שאפשר להיקשר אליו עבור שורה מחוץ למסך. פתקי ריפו תומכים רק ב-surface וב-screen. תיאור ההתנהגות למשתמש נמצא ב-פתקים דביקים (Sticky Notes).
פתקי ריפו: המשטח הוא התוכן של הגולל, לא הקונטיינר
בלוח, המשטח והקונטיינר הם אותו אלמנט: #boardSurface נגלל עם העמוד וגדל עם הפתקים שעליו. בדפדפן הריפו הם שניים. #code-viewer-container הוא מסגרת בגובה קבוע — עמודת flex עם min-height: 0 ו-overflow: hidden ב-repo-browser.css — והתוכן נגלל בתוך הגולל הפנימי: .cm-scroller של CodeMirror, או תצוגת ה-Markdown. repo-notes.js מזריק את הגולל כ-scroller, ו-_positionOrigin ממיר דרך _surfaceScrollShift בין מרחב התוכן, שבו המיקום נשמר, למרחב הקונטיינר, שבו הוא מרונדר.
שלוש החלטות גאומטריה שאלו את הקונטיינר גם כאן, וכל אחת מהן הגיעה למשתמש:
איפה פתק חדש נולד.
createNoteשאל כמה מהקונטיינר נגלל מעל החלון — בדפדפן הריפו תמיד אפס — ופתק חדש נשמר בראש הקובץ, מחוץ לתחום הנראה. היום_newNotePositionמודד את הפינה של מה שרואים (_visibleTopLeft: החיתוך של החלון, הקונטיינר והגולל) וממיר אותה עם_positionOrigin— ההמרה ש-_notePayloadFromElעושה בכיוון ההפוך. כך פתק חדש עובר באותה המרה כמו גרירה ונעיצה, ולא בנוסחה משלו, ולכן מה שנשמר לו הוא מה שמוצג.כמה המשטח גדל.
_applySurfaceExtentכתבmin-heightעל הקונטיינר, כמו בלוח. הסגנון האינליין גבר עלmin-height: 0, הקונטיינר גדל מעבר ל-.repo-content, העורך נמתח איתו, וטווח הגלילה שלו התקצר בדיוק בכמה שנחתך — סוף הקובץ לא היה נגיש בשום גלילה. והערך עצמו חושב מ-style.topשל הפתקים, שבדפדפן הריפו כולל את היסט הגלילה; לכן אותו פתק נעל או שחרר לפי המקום שבו המשתמש עמד כשהחישוב רץ, וגם גרירה של פתקscreenהפעילה אותו. בדפדפן הריפו המשטח אינו גדל — את אורך התוכן קובע CodeMirror — והפונקציה אינה כותבת שם דבר.עד איפה מותר לפתק לשבת לרוחב.
_clampToSurfaceהצמיד גם בדפדפן הריפו לרוחב הקונטיינר, בזמן ש-xהשמור נמדד בתוכן — ושורות ארוכות נגללות הצידה. פתק שנגרר בזמן גלילה הצידה קפץ בגלילה הבאה, ופתק חדש בשורות ארוכות נחסם אל מחוץ לתחום. היום_applyPositionModeמעביר ל-_clampToSurfaceאת_surfaceReach— המסגרת שהגרירה מצמידה אליה, על כל מיקומי הגלילה, במרחב התוכן — ורוחב הקונטיינר נשאר ברירת המחדל: לגרירה, שמודדת במרחב הרינדור מול המסגרת שרואים, וללוח. הטווח נגזר מהמסגרת ולא מהקופסה של הגולל עצמו בכוונה: בתצוגת ה-Markdown הגולל צר מהמסגרת (ה-marginוה-borderשל.markdown-preview-containerב-markdown-preview.css), וטווח שנגזר ממנו הזיז בגלילה הבאה פתק שנגרר אל הקצה הימני.
חשוב
החלטת גאומטריה חדשה על פתק ``surface`` שואלת את המשטח, לא את הקונטיינר. בלוח שני אלה אותו אלמנט, ולכן בדיקה על הלוח לא תתפוס את ההבדל; בדפדפן הריפו הוא שובר את הגלילה. ההמרה בין המרחבים היא _positionOrigin ו-_surfaceScrollShift, והטווח הוא _surfaceReach — לא נוסחה חדשה במקום שלישי.
הערה
הכיוון של הגולל קובע לאיזה צד הטווח מתרחב. בגולל rtl — תצוגת ה-Markdown — ה-scrollLeft הוא 0 בקצה הימני ושלילי בגלילה שמאלה (MDN, Element.scrollLeft), כלומר התוכן שנחשף בגלילה שמאלה יושב במיקומים שליליים. השרת מצמיד מיקום שלילי ל-0 (_coerce_int ב-webapp/sticky_notes_api.py), ולכן שם הטווח אינו מתרחב: מה שמוצג הוא מה שנשמר, אבל פתק אינו יכול לשבת מעל התוכן שמשמאל לנקודת ההתחלה. זה מקרה נדיר — תצוגת Markdown שתוכנה רחב מהחלונית, כמו מילה ארוכה מאוד שאינה נשברת — והפתרון שלו הוא מיקום שמור שאינו נמדד מהקצה השמאלי, כלומר שינוי במודל ולא בהצמדה. ומאותה סיבה, פתק שנגרר אל השוליים השמאליים של תצוגת ה-Markdown (ה-margin של .markdown-preview-container) זז מהם בגלילה הבאה: הגרירה מצמידה למסגרת ואינה מכירה את הגבול של השרת. כך היה גם לפני _surfaceReach — נמדד באוקטובר 2026, בשני הכיוונים של הטאבלט.
המדידות, בדף /repo/ האמיתי ובמגע של טאבלט, נמצאות ב-tests/test_repo_notes_browser.py; ההיגיון עצמו ננעל גם ב-node, בבדיקות שנפתחות ב-”ריפו:“ ב-tests/sticky-notes-target.test.js.
סדר: flush לפני כל פעולה הרסנית
תור השמירה עובד עם debounce, ולכן עריכה שנעשתה זה עתה עדיין ממתינה בו. כל פעולה שעלולה למחוק את התור — פירוק המנהל בהחלפת יעד, סימון צ’קבוקס שמפעיל כתיבה משלו — חייבת לרוקן את התור תחילה. אחרת העריכה האחרונה נעלמת בלי שום סימן. זו תקלת סדר שכבר נתפסה כאן פעמיים (_flushFor לפני ה-toggle; destroy שמרוקן לפני שהוא מפרק).
גודל: מה שנקבע לעומת מה שנכנס
מיקום וגודל נשמרים בפיקסלים מוחלטים, ולכן פתק שנוצר במסך אחד מוצג במסך אחר בערכים שאינם שלו. המיקום נפתר בהצמדה — היא אינה מאבדת מידע. הגודל אינו: צמצום לגבול משנה את מה שיוצג, וכל עוד אין מקום שני שזוכר את מה שנקבע, ההקטנה היא דריסה.
לכן הגודל מוחזק בשני ערכים: הכוונה, שנקבעת ברינדור מהערך שב-DB ומתעדכנת אך ורק ב-_enableResize; ו**המוצג**, שנגזר ממנה דרך _fitSizeToBounds. המיקום נשאר ערך אחד.
תשובה אחת לשאלה ”מה הרוחב של הפתק הזה“. ארבעה קוראים שואלים אותה — שלושת הענפים ב-_applyPositionMode, ו-_clampToSurface (_reflowWithinViewport מגיע אליה דרך _fitAndClampToViewport) — וכולם עוברים דרך _resolveDisplaySize, בסדר אחד: הכוונה שעל האלמנט ← note.size ← el.style ← המלבן ← ברירת המחדל. הכוונה קודמת כי היא ה-DOM המקומי, ו-_enableResize מעדכן אותה לפני שהשמירה יוצאת לדרך. סדר שונה באותה שאלה אינו כפילות סגנונית: ההצמדה מחשבת כמה מקום נשאר לפי רוחב אחד בזמן שהתצוגה כותבת רוחב אחר, והפתק נצמד למקום שנכון לגודל שאינו הגודל שיוצג.
לא כל הצמדה מצמצמת. _clampToViewport אינו נוגע בגודל כלל — הוא קורא את המלבן ומצמיד x/y בלבד, בשונה מ-_fitAndClampToViewport שמצמצם קודם. הקורא היחיד שלו הוא לולאת הגרירה של פתק צף, ושם הגודל כבר נקבע קודם לכן. ההבחנה חשובה למי שיוסיף שם צמצום: הוא יצמצם לפי המלבן, כלומר לפי המוצג ולא לפי הכוונה, ובכך יהפוך כל גרירה במסך צר לדריסה של הגודל — בדיוק הבאג שהמבנה הזה מונע.
שינוי גודל ידני מסתיים בהצמדה. _enableResize.onMove חוסם את הרוחב ל-120..1200 בלי שום מודעות לגבולות — במכוון, כי שם נקבעת הכוונה, וחסימתה לגבולות הייתה הופכת מסך צר לתקרה קבועה על מה שהמשתמש רשאי לבקש. לכן onUp כותב את הכוונה ואז קורא ל-_applyPositionMode, שמצמיד את המוצג. הסדר הוא כל העניין: הכוונה לפני, ולכן מה שנבחר נשמר; _notePayloadFromEl אחרי, ולכן מה שנשמר כמיקום הוא המוצמד. בלי הקריאה הזו פתק שהוגדל ליד קצה הלוח גלש מעבר לו ואף אחד לא הצמיד עד אירוע resize או גלילה — שבלוח אין. נמדד בכרומיום: גלישה של 54 פיקסלים, עם ידית שינוי הגודל עצמה מחוץ לחלון — כלומר אי אפשר לכווץ בחזרה.
חשוב
כל השמירות עוברות דרך משפך אחד — ``_notePayloadFromEl`` — וחמש נקודות קוראות לו: גרירה, נעיצה, ביטול עיגון, החלפת מצב, ושינוי גודל ידני. הוא קורא את המיקום מהמלבן ואת הגודל מהכוונה, ובכך כל חמשתן מוגנות בלי הגנה נפרדת בכל אתר.
קריאת הגודל מהמלבן היא הבאג שהמבנה הזה מונע, והוא היה קיים עוד לפני ההקטנה: פתק ממוזער מקבל height: auto !important ומתכווץ לגובה הכותרת, ולכן מיזעור ואז גרירה שמרו את גובה הכותרת כגובה הפתק.
הצמצום קודם להצמדה, לא להפך: maxLeft נגזר מהרוחב, וחישובו לפי רוחב שכבר לא יוצג מצמיד את הפתק למקום שנכון לגודל אחר. זה בדיוק המצב שבו פתק ”נדחף לקצה וממשיך לגלוש“.
שלושת מצבי המיקום נבדלים במסגרת הייחוס שלהם, ולכן גם במה שמצטמצם ובאיזה ציר מוצמד:
מצב |
מה מצטמצם |
איזה ציר מוצמד |
למה |
|---|---|---|---|
|
רוחב, לרוחב המשטח |
X בלבד |
המשטח גליל וגדל כלפי מטה, ולכן פתק גבוה נשאר נגיש בעוד שפתק רחב גולש ואין דרך להגיע לידית שלו. |
|
רוחב, לרוחב אזור התצוגה |
X בלבד |
הפתק הוא |
|
רוחב וגובה, לאזור התצוגה |
שניהם |
הפתק הוא |
המרווחים בענפי ה-X המוצמדים (screen ו-anchored) באים מ-NOTE_EDGE_GAP; בענף המשטח אין מרווח קצה — הוא מצמיד את X ל-0 ומתאים את הרוחב ל-clientWidth המלא, בלי קבועים בכל אתר. בדפדפן הריפו הגבול הימני של ההצמדה אינו רוחב הקונטיינר אלא _surfaceReach, כי שם המשטח נגלל לרוחב — ראו ”פתקי ריפו: המשטח הוא התוכן של הגולל, לא הקונטיינר“.
הערה
X של פתק מעוגן נשמר במרחב המסמך, וההצמדה מודדת מול אזור התצוגה — שני מרחבים שונים, שמתלכדים כאן כי scroll.x הוא תמיד אפס. נמדד בכרומיום על /md/<id>: documentElement.scrollWidth שווה ל-clientWidth גם עם בלוק קוד רחב, טבלה ברוחב 2864 פיקסלים ומילה בת 300 תווים — כולם נבלמים ב-overflow-x: auto פר-אלמנט — וטווח הגלילה האופקית הוא 0 לשני הכיוונים. מי שיוסיף גלילה אופקית לעמוד חייב להוסיף להצמדה את ההיסט.
ומדוע ההצמדה נחוצה בכלל, אם למסמך אין קצה ימני קשיח כמו למשטח: פתק ב-left: 1500px על מסך 420 יושב מחוץ למסך ואינו מרחיב את שטח הגלילה — נמדד. כלומר אין שום דרך להגיע אליו, בדיוק כמו פתק שגלש מהמשטח.
אזהרה
is-pinned מסמן גם surface וגם anchored, ולכן _reflowWithinViewport מדלג על שניהם. מי שיוסיף מצב מיקום חדש ויסמן אותו ב-is-pinned יקבל בירושה גם את הדילוג הזה: הצמצום שלו חייב לחיות בענף שלו ב-_applyPositionMode, שאליו _reapplyPinnedLayout מגיע בשינוי גבולות.
כך זה נשבר בפעם הקודמת: המצב anchored נוסף בלי מדיניות גודל כלל, ופתק ברוחב 600 גלש במסך 320 כבר בטעינה — לא רק אחרי סיבוב. היום יש לו מדיניות, כמתואר בטבלה שלמעלה.
שינוי גבולות חייב מסלול משלו לפתקים הנעוצים. _reflowWithinViewport מדלג על is-pinned בכוונה: המיקום של פתק משטח נמדד במרחב המשטח ולא במסך, והצמדה לפי ה-viewport הייתה מזיזה אותו למקום שגוי. לכן resize קורא גם ל-_reapplyPinnedLayout, שמריץ _applyPositionMode על הפתקים הנעוצים — הפונקציה היחידה שמצמצמת פתק משטח לרוחב המשטח. בלעדיה לוח שנטען במסך רחב ואז סובב נשאר עם פתקים ברוחב הישן, גולשים מעבר לקצה עם הידית מחוץ למסך. גם המאזין של visualViewport קורא לה, ומאותה סיבה בדיוק. פעם זה לא היה נחוץ: פתק משטח גוזר את רוחבו מ-parent.clientWidth, שפינץ«-זום ופתיחת מקלדת אינם משנים. הפתק המעוגן גוזר אותו מ-_viewportBox(), שקורא את visualViewport עצמו — ולכן בלי הקריאה הזו הוא נשאר ברוחב שנגזר מהזום הקודם. במשטח זו עבודה מיותרת אך לא מזיקה, וזה המחיר הנכון לעומת חיווט נפרד לכל מצב.
גודל של פתק חדש, והגיבוי של פתק בלי גודל
עד אוקטובר 2026 שתי שאלות נראו כמו שאלה אחת, כי לשתיהן הייתה אותה תשובה, 260×200: באיזה גודל נפתח פתק חדש, ו**מה מקבל פתק שאין לו גודל בכלל**. ההתנהגות הנראית מתוארת ב-פתקים דביקים (Sticky Notes); כאן מה שמסביבה. היום אלה שני זוגות של קבועים ב-sticky-notes.js:
NEW_NOTE_W/NEW_NOTE_Hו-NEW_NOTE_PHONE_W/NEW_NOTE_PHONE_H— מה ש-createNoteשולח לשרת, ומשם הכוונה של הפתק (הסעיף הקודם).NEW_NOTE_PHONE_MAX_VWמכריע בין השניים: דרךmatchMediaעםmax-width, ודרךinnerWidthכשאיןmatchMedia. עד אז היצירה לקחה מספרים מוקלדים, והקבוע שנקראNOTE_DEFAULT_Wלא היה הגודל שלה כלל.NOTE_FALLBACK_W/NOTE_FALLBACK_H(עד אוקטובר 2026NOTE_DEFAULT_*) — הגיבוי בלקוח, ב-_renderNote, ב-_sizeIntentFallbackוב-_clampToViewport. הוא נשאר 260×200 בהחלטה: פתק בלי גודל שמור אינו גדל עם ברירת המחדל החדשה.
במסך רחב — הגודל שנבחר בעין, ביחס של הגודל הקודם. המשתמש גרר פתק לגודל שנראה לו נכון, והגודל השמור שלו נקרא מהמסד ב-16.10.2026: 503×373. NEW_NOTE_W/NEW_NOTE_H שומרים על השטח הזה ועל היחס 13:10 של 260×200 — כלומר בדיוק פי 1.9 מהגודל הקודם. בטלפון הגודל לא השתנה, בהחלטה של המשתמש.
חשוב
על פתק בלי גודל מכריע השרת, ולא הגיבוי בלקוח. _as_note_response ב-webapp/sticky_notes_api.py שולח size עם כל פתק, ולמסמך בלי width/height הוא נותן ערכים משלו. לכן NOTE_FALLBACK_* חל רק על אובייקט פתק שהגיע בלי size בכלל — קאש ישן ב-localStorage — ועל המדידות שנופלות אליו כשאין מלבן. שני הגיבויים לא השתנו, ולכן פתק בלי גודל שמור מוצג כמו קודם. test_notes_that_exist_keep_their_size ב-tests/test_sticky_note_copy_id_browser.py בודק את הגיבוי בלקוח; בזה שבשרת השינוי לא נגע.
הערה
הכותב השני — ה-MCP — יוצר פתקים בגודל משלו, ב-_new_note_doc וב-create_note של mcp_server/backend.py, בלי קשר למסך שבו הפתק יוצג. השינוי כאן הוא בוובאפ בלבד, ולכן פתק שסוכן יוצר עדיין קטן מפתק שנוצר בדפדפן במסך רחב. ההערה שליד הערכים שם אמרה עד היום שהם ברירות המחדל של הוובאפ, ותוקנה.
שורת הפעולות נכנסת כולה בגודל החדש. נמדד בכרומיום 153 ב-24.10.2026, בצעדים של פיקסל ובנוסחה של ROW_JS ב-tests/test_sticky_note_copy_id_browser.py: הכפתורים בשורה נכנסים בפתק מרוחב 268 — זה הרוחב שבו ⋮, האחרון בשורה, כבר אינו חתוך. עד שנוסף ⋮ ו-🔔 עבר לתפריט שלו, הרוחב הזה היה 272 והאחרון בשורה היה ×, ושם הוא היה הנחתך (נמדד בכרומיום 141 ב-16.10.2026, ושוב ב-24.10.2026 על הקוד שלפני השינוי); הסעיף על השרשורים מתאר את השורה ההיא במדידה בצעדים של 20 — ופתק חדש במסך רחב רחב ממנו בהרבה. test_a_new_note_on_a_wide_screen_is_bigger_and_its_whole_row_fits יוצר פתק דרך createNote, ודורש שאף כפתור בשורה לא ייחתך ושהרוחב שהיא צריכה (needed) יהיה קטן מרוחב הפתק. בטלפון השורה רחבה מהפתק כמו קודם (#3478).
העתקת מזהה הפתק: בשורת השם, והמזהה נקרא ברגע הלחיצה
ההתנהגות הנראית מתוארת ב-העתקת המזהה; כאן הנימוק.
בשורת השם, ליד החיפוש — ולא בשורת הפעולות. זו הכרעה של המשתמש, ומאותה סיבה שכפתור החיפוש יושב שם (הסעיף ”התיבה: ילד של הפתק“): שורת הפעולות אינה נשברת. באותה מדידה, עם כפתור המזהה בתוכה השורה הייתה צריכה 308 במקום 272 — כלומר בכל פתק צר מ-308 היה נחתך עוד כפתור: בפתק ברוחב 260, ברירת המחדל במחשב עד אז, גם המזעור ולא רק המחיקה, ובפתק חדש בטלפון ארבעה כפתורים במקום שלושה. בדיקה במסד באותו יום מצאה כשליש מהפתקים צרים מ-308. המספרים האלה נמדדו עם האייקון הראשון של הכפתור, 🆔. היום האייקון הוא #, תו טקסט שמקבל את צבע הכפתור כמו ✎, ולא אימוג’י בצבעים משלו; איתו השורה הייתה צריכה 304, ומספר הכפתורים שנחתכים זהה — שניים בפתק ברוחב 260 וארבעה ברוחב 200 (נמדד בכרומיום 141 ב-16.10.2026, בנוסחה של ROW_JS ב-tests/test_sticky_note_copy_id_browser.py). כלומר ההחלפה לא שינתה את ההכרעה. ובזמן חיפוש, כשהפתק אינו חותך (.is-unclipped), השורה הארוכה יצאה בפתק צר מהקצה השמאלי של החלון ופתחה לעמוד גלילה לרוחב — test_nothing_the_note_clipped_leaks_out_while_searching נפל בדיוק על זה. בשורת השם הכפתור גלוי בכל רוחב, כי בפתק צר השורה הזו יורדת לשורה משלה ומקבלת את כל הרוחב.
המחיר, שנמדד גם הוא: בפתק צר מ-136 בערך שדה השם נדחק לרוחב המינימלי שלו ונחתך מצד שמאל — עד 16 פיקסלים ברוחב המינימלי של פתק (120). במסד כמעט אין פתקים כאלה (נבדק ב-16.10.2026).
המזהה נקרא ברגע הלחיצה, מהאלמנט, ולא נלכד בכפתור. _copyNoteId קורא את el.dataset.noteId — הזהות שכל המנהל שואל עליה (_getEntry) — ולא note.id שהיה נלכד בסגור של המאזין. מה שמועתק הוא מה שהפתק נושא ברגע הלחיצה, והבדיקה ”מזהה: נקרא ברגע הלחיצה, ולא כשהכפתור נבנה“ נופלת על לכידה בבנייה.
מזהה שהשרת לא נתן אינו מועתק. isSavedNoteId בודק את הצורה ש-codekeeper_get_note מקבל: הוא מפענח ב-ObjectId(str(note_id)), שמקבל מחרוזת רק באורך 24 שעוברת bytes.fromhex (ObjectId.__validate ב-bson/objectid.py, pymongo 4.15.3) — וזו הצורה שהשרת שולח, str של ה-ObjectId. על כל דבר אחר מוצג חיווי הכשל של ההעתקה, עם ההסבר ”הפתק עוד לא נשמר“. אין כפתור מושבת ואין מסלול שמדליק אותו אחרי השמירה: הבדיקה רצה מחדש בכל לחיצה. אותו כלל, למזהה של קובץ, יושב ב-OBJECT_ID_RE של card-preview.js, ובדיקה ב-tests/sticky-notes-target.test.js דורשת ששני הביטויים יישארו זהים. והחוזה מול השרת נבדק בפייתון: TestSavedNoteIdRule ב-tests/test_sticky_notes_api.py קורא את הביטוי מקובץ ה-JS, ובודק שכל מזהה ש-_as_note_response שולח עובר אותו, ושכל מה שהוא מקבל ObjectId מפענח לאותו מזהה בדיוק. ”לאותו מזהה“ ולא רק ”בלי חריגה“, כי bytes.fromhex מדלג על רווחים בין זוגות ספרות: מחרוזת של 24 תווים עם שני רווחים בתוכה עוברת את ObjectId, ומפוענחת למזהה אחר של 22 ספרות. לכן הכלל בלקוח צר ממה ש-ObjectId מקבל, ובכוונה.
הערה
היום הענף הזה אינו נפתח מהממשק, וזה נאמר במפורש. פתק מוצג רק אחרי שהשרת החזיר לו מזהה: createNote מרנדר אחרי התשובה, ו-loadNotes והקאש מרנדרים את מה שהשרת שלח. מזהה כזה מגיע רק מתשובת יצירה בלי id, או מקאש פגום ב-localStorage. מי שיוסיף יצירה אופטימית, עם מזהה זמני עד שהשרת עונה, מקבל את ההתנהגות הנכונה בלי לגעת כאן — בתנאי שהמזהה הקבוע נכתב ל-el.dataset.noteId כשהוא מגיע. test_a_note_the_server_gave_no_id_says_not_saved_and_copies_nothing יוצר בדיוק את המצב הזה: createNote מול שרת שעונה {"ok": true} בלי מזהה.
מנגנון אחד. ההעתקה עוברת ב-_copyText והחיווי ב-_flashCopyResult, כמו ⧉ ובלוק הקוד (הסעיף ”ההעתקה: מהמקור, לא מה-DOM“). _flashCopyResult קיבל failTitle — ההסבר לכשל שהדפדפן אינו הסיבה שלו — ולא נוסף חיווי שני. כלל ה-CSS של החיווי חל על שני הכפתורים.
סכנה
ובאותו מנגנון היה באג, והכפתור החדש היה הופך אותו לנגיש בכל לחיצה כפולה. כל לחיצה לכדה את ה-title של אותו רגע, כדי לשחזר אותו כשהחיווי יורד — ואחרי כשל, ה-title של אותו רגע הוא הודעת הכשל עצמה. שתי העתקות שנכשלו בהפרש קצר השאירו את הכפתור עם ההודעה לתמיד; נמדד בכרומיום לפני התיקון, על ⧉ עם קליפבורד חסום. בכפתור המזהה, על פתק בלי מזהה, כל לחיצה נכשלת. היום _copyFlashes מחזיק לכל כפתור את החיווי שמוצג עליו: לחיצה בזמן חיווי מבטלת את הטיימר שלו, וה-title שישוחזר הוא זה שנלכד לפני הראשונה. וה-catch שעטף את הפונקציה כבר אינו בולע בשקט — כשל בה נרשם בקונסול.
מה לבדוק, ובמה. ההיגיון — מה נשלח להעתקה ומתי, איפה הכפתור יושב, הכלל המשותף עם card-preview.js, החיווי בלחיצה כפולה, והגודל שנשלח בפתק חדש — בבדיקות שבסוף tests/sticky-notes-target.test.js, ב-node. רץ ב-CI. החוזה של המזהה מול השרת — ב-TestSavedNoteIdRule (tests/test_sticky_notes_api.py), בפייתון, וגם הוא רץ ב-CI. הקליפבורד, החיווי בדפדפן, הגודל שמוצג, שורת הפעולות, והכפתור הגלוי בטלפון — ב-tests/test_sticky_note_copy_id_browser.py; ופתק ריפו בעמוד /repo/ האמיתי — ב-test_the_id_button_copies_the_id_of_a_repo_note (tests/test_repo_notes_browser.py). בדיקות הדפדפן אינן רצות ב-CI.
גודל הטקסט: טוקן אחד, ולמה דווקא בפיקסלים
הצד של הנושא הזה בשרת: העדפה שיש לה גם מצב ”רק במכשיר זה“ ו-גודל הטקסט בעמוד ההגדרות: מה שמסוכן יושב סביב הבורר.
font-size: 14px היה מוצהר פעמיים — על תיבת העריכה ועל התצוגה שמחליפה אותה — ולידו הערה שהן ”אותו ארגז בדיוק“. שני מספרים שחייבים להישאר שווים ואיש לא אכף. הבורר החדש לא הוסיף שלישי אלא איחד את השניים ל---sticky-note-font.
וגם ברירת המחדל של הטוקן מוצהרת פעם אחת — על ``:root`` ב-``sticky-notes.css``. עד אוקטובר 2026 היא ישבה כ-fallback בתוך ה-var() של שני הצרכנים, כלומר אותו מספר בשני מקומות, ושינוי של ”רגיל“ הוא שחשף את זה. מחלקות הגודל על <html> גוברות על ההצהרה הזו בספציפיות ולא בסדר הכללים: :root הוא 0-1-0 ו-:root.sticky-font-* הוא 0-2-0 (MDN, :root). הצרכנים קוראים var() בלי fallback, כי עותק שם היה נראה כמו ברירת המחדל בלי להיות היא, ובדיקה ב-tests/board-handwriting.test.js מוודאת שיש הצהרה אחת בדיוק ושאין עותק.
חשוב
הערכים מוחלטים, וזו החלטה שכבר נכשלה כאן פעם. ההערה על כתב היד בסוף sticky-notes.css מתעדת ניסיון קודם להגדיל גופן ב-1.08em: em ב-font-size נפתר מול ההורה ולא מול הבסיס של האלמנט, ולכן הוא החליף 14px ב-16px × 1.08 = 17.28px — 23% במקום 8%.
המדידה הפעם מצאה משהו חמור יותר ממה שתועד שם. בריצת בקרה עם --sticky-note-font: 1.14em, שני הצרכנים קיבלו ערכים שונים: התצוגה 18.24px ותיבת העריכה 14.82px. כלומר em אינו רק נותן מספר שגוי — הוא שובר את ההבטחה ששני הארגזים זהים, וזו בדיוק ההבטחה שהטוקן נועד לאכוף.
כל השאר בפתק נגזר מהבסיס ב-``em``, ולכן גדל איתו בחינם. נמדד בכרומיום בספטמבר 2026, על הסולם שהיה אז, בשלושת המצבים: תשעת הרכיבים שנבדקו — תצוגה, תיבת עריכה, כותרת, קוד בשורה, בלוק קוד, תווית שפה, טבלה, גוף אלרט והזחת הקינון — יצאו ביחס זהה בדיוק לבסיס. אף רכיב לא נשאר מאחור, ולא נדרש אף כלל נוסף. הסולם של אוקטובר 2026 לא נמדד מחדש: הוא שינה רק את ערכי הטוקן, ואף כלל שנגזר ב-em לא השתנה.
הערה
הזחת הקינון הייתה הרכיב שהכי קל היה לטעות לגביו: --sticky-li-indent: 1.1em מוגדר על .sticky-note, שאין לו font-size משלו. אבל em בערך של תכונה מותאמת נפתר במקום השימוש ולא במקום ההגדרה, ולכן הוא נגזר מ-.sticky-task-line וגדל עם הבסיס. נמדד, לא הונח.
המפה אומרת אילו גדלים קיימים; רשימה שנייה אומרת מי מציע מה. STICKY_NOTE_FONT_SIZES ב-_note_fonts_head.html מחזיקה את הטוקנים ואת שמות המחלקות, ומתוכה נגזרים הוולידציה של הערך השמור ושם המחלקה שמוחלת. לצידה STICKY_NOTE_FONT_MENUS מחזיקה את מה שכל תפריט מציע.
כל עוד היה תפריט אחד, ”אילו גדלים קיימים“ ו“מה מוצע“ היו אותה שאלה, והבדיקה השוותה את הבורר ישירות למפה. משהצטרף הבורר שבעמוד ההגדרות הן נפרדו, כי כל אחד מהשניים הציע גדלים אחרים, והמפה הייתה גדולה משניהם. מקור אחד לשתי השאלות היה מכריח כל תפריט להציע את מה ששייך לשני.
מאוקטובר 2026 שני התפריטים מציעים בדיוק אותו דבר, וההפרדה נשארה בכוונה. אלה עדיין שתי שאלות, ותפריט שיחזור להציע גודל משלו לא יחייב לפצל מחדש. המחיר: מי שמשנה את מה שמוצע בשני המקומות משנה את שתי הרשימות. שום בדיקה אינה דורשת ששתיהן יהיו זהות — הזהות היא החלטת מוצר של היום, לא חוזה.
אזהרה
תפריט שגדל מרחיב גם את מה שהמשטח השני צריך לדחות. הלוח מציב את הערך השמור על ה-<select>; ערך שאין לו <option> מרוקן אותו (selectedIndex נעשה -1) במקום ליפול ל“רגיל“. לכן הלוח מאמת מול התפריט שלו ולא מול המפה — בשני המקומות: בהצבת הבורר, וגם בסקריפט הראש שמחיל את הגודל, אחרת התצוגה והבורר מציגים שני דברים שונים.
שתי השאלות אינן זהות ולכן יש שני מסננים: applyStickyNoteFontSize שואלת ”האם הגודל קיים“, והמשטח שואל ”האם אני מציע אותו“. כששני התפריטים זהים המצב הזה אינו נוצר מעצמו, ולכן הבדיקה של המסנן ב-tests/board-handwriting.test.js יוצרת אותו בעצמה: היא מוציאה גודל מהתפריט של הלוח אחרי שההכללה בנתה אותו, ומוודאת שהוא אינו מוחל.
שלוש הצלבות שומרות על השרשרת, וכולן בבדיקות ולא בהבטחות: אפשרויות ה-HTML של כל בורר זהות לרשימה שלו (האפשרויות חייבות להישאר ב-HTML כי הן בעברית ומשתמש רואה אותן), כל טוקן ברשימות קיים במפה, ולכל מחלקה שבמפה יש כלל ב-sticky-notes.css. בלי החוליה האחרונה אפשר להוסיף גודל, לראות אותו בבורר, ולגלות שבחירה בו פשוט אינה עושה דבר.
אזהרה
הערך השמור אינו מורכב לשם מחלקה. localStorage הוא קלט חיצוני לכל דבר — ערך שנערך ביד, נשאר מגרסה קודמת, או הגיע ממקום אחר. 'sticky-font-' + size היה מכניס כל מחרוזת ל-classList, ולכן החיפוש הוא hasOwnProperty על המפה. הבדיקה מריצה שבעה ערכים פגומים, ובהם __proto__ ו-constructor.
ההחלה מנקה לפני שהיא מוסיפה. מעבר מ-xlarge ל-large בלי הניקוי היה משאיר את שתי המחלקות על <html>, ואז מנצח הכלל שמופיע אחרון בקובץ ולא זה שנבחר — כשל שנראה כמו ”הבחירה לא נשמרה“.
ומזהה הלוח נלכד פעם אחת בסקריפט הראש, ושני המפתחות נגזרים ממנו. זה גם ניקיון וגם תנאי לבדיקה: ההארנס מחליף את ביטוי ה-Jinja במחרוזת, וביטוי שני היה נשאר בקוד ושובר את ההרצה. זה קרה בפועל בסבב הראשון.
שם שיצא משימוש אינו חוזר עם גודל אחר. באוקטובר 2026 הסולם הוזז: ”רגיל“ קיבל את הגודל שהיה עד אז ”גדול מאוד“ בלוח, ושתי ההגדלות שמעליו קיבלו שמות חדשים — large ו-xlarge — ולא את lg ו-xl. השמות הישנים עדיין שמורים אצל משתמשים: lg ו-xl ב-localStorage של לוחות, ו-mid ו-lg של עמוד ההגדרות ב-ui_prefs.note_font_size וב-cookie ui_note_font_size. שם ישן עם גודל חדש היה הופך בשקט כל בחירה שמורה לגודל שהמשתמש לא בחר. בשמות חדשים ערך ישן אינו מוכר בשום מקום, ולכן הוא עובר באותו מסלול כמו ערך פגום — בלי קוד מיגרציה ובלי מחיקה; הוא נשאר באחסון ופשוט אינו נקרא, עד שבוחרים מחדש. בלוח אף אחד משני המסננים שלמעלה אינו מקבל אותו, והלוח נשאר ב“רגיל“ גם בתצוגה וגם בבורר. בשרת _decode_note_font_size מחזירה None, ולכן שדה ישן במסד נופל לברירת המחדל, ו-cookie ישן במצב device נופל לבחירה המשותפת שבמסד, כמו cookie פגום.
מי שמשנה שוב את הסולם ממציא שמות חדשים, ולא ממחזר שם קיים. הבדיקות שמקבעות את זה: test_a_size_name_from_the_old_scale_is_rejected ו-test_a_size_saved_on_the_old_scale_falls_back ב-tests/test_note_font_size.py, והבדיקה על בחירה מהסולם הקודם ב-tests/board-handwriting.test.js, שבודקת גם את התצוגה וגם את הבורר.
מקור אחד למחלקה, ומקור אחד לסדר
הצד של הנושא הזה בשרת: העדפה שיש לה גם מצב ”רק במכשיר זה“ ו-גודל הטקסט בעמוד ההגדרות: מה שמסוכן יושב סביב הבורר.
מחלקה אחת ולא שתיים. sticky-handwriting מוחלת משני מקורות — ההגדרה הגלובלית והמתג שלכל לוח — כי המראה זהה. שתי מחלקות עם אותם כללים היו נסחפות: אחת מתעדכנת והשנייה לא. מסיבה דומה ההחלה עצמה חיה בפונקציה אחת (applyStickyHandwriting) ב-_note_fonts_head.html, ולא משוכפלת בכל משטח.
שם המחלקה השתנה; מפתח האחסון לא. localStorage עדיין מכיל board-handwriting:<id> אצל משתמשים קיימים, ויישור השם למחלקה היה מוחק בשקט את ההעדפה של כל לוח קיים. מפתח אחסון הוא נתוני משתמש, לא סגנון.
הנעילה אינה דורסת. כשההגדרה הגלובלית ללוחות דלוקה, המתג במודאל ננעל אבל writeHandwriting אינו נקרא. ביטול הבחירה בהגדרות מחזיר את כל הלוחות בדיוק למצב שלהם. נעילה שדורסת הייתה מוחקת את ההעדפות של כל הלוחות בבת אחת, בפעולה שנראית כמו הדלקת מתג אחד.
וה-guard בחיווט הוא נושא משקל. בלעדיו הענף הלא-נעול היה מריץ applyHandwriting(readHandwriting()) ומסיר את המחלקה שההכללה כבר החילה, כשההעדפה המקומית של הלוח כבויה. אומת במוטציה: המחלקה ירדה מ-<html> בדפדפן. הבדיקה שמכסה זאת חייבת להריץ את הצירוף גלובלי דלוק + מקומי כבוי; כל צירוף אחר עובר גם בלי ה-guard.
סדר בקידוד. NOTE_FONT_SURFACES הוא המקום היחיד בקוד שיודע את סדר הביטים ב-cookie, והקידוד והפענוח שניהם נגזרים ממנו. משטח רביעי נספח בסוף. צימוד משמעות למיקום כבר נשך פעם בפרויקט הזה, ב-MD_INLINE_RE — הרג’קס שקרא פעם את המארקדאון בשורה של פתק (קוד בשורה: יחידה אחת, שקודמת לכל מפריד) — שם הוספת חלופה באמצע הייתה מזיזה בשקט את כל אינדקסי הקבוצות שאחריה.
חיפוש בתוך פתק: מודול הדגשה אחד, ומה שהפתק מוסיף עליו
הצד של הנושא הזה בשרת: חיפוש בפתקים: למה רג’קס ולא $text.
ההתנהגות הנראית — הכפתור, התיבה, המקשים, מה נמצא ומה לא — מתוארת ב-חיפוש בתוך פתק. כאן רק הנימוק, ומה שנמדד בדרך. יש כאן שני חלקים, והגבול ביניהם מכוון: ההדגשה עצמה חיה ב-utils/text-highlight.js ואינה יודעת דבר על פתקים; כל מה שנובע מהמנוע של הפתקים — תצוגה שנבנית מאפס, טקסט שהמנוע מייצר, בלוקים מתקפלים, לחיצה ממושכת — חי בקבוצת המתודות ”חיפוש בתוך פתק“ ב-sticky-notes.js.
ההדגשה: מודול אחד, ולא עותק נוסף
לפני הפיצ’ר הזה אותה ליבה חיה פעמיים: highlightTerm בחיפוש במסמך (md_preview.html) ו-highlightInto בעמוד חיפוש הפתקים (notes_search.js) — פיצול טקסט לקטעים רגילים ולקטעים מסומנים, בריחה ליטרלית, בלי רגישות לרישיות. החיפוש בתוך פתק היה צריך בדיוק את אותה ליבה, ועותק נוסף היה עוד מקום שבו כללי ההתאמה יכולים לסטות. לכן הליבה עברה ל-window.TextHighlight, ושני המימושים הקיימים עברו אליו באותו שינוי. מה שייחודי לכל צרכן — שם המחלקה, מה עוד מדלגים עליו, ומתי מריצים — נשאר אצלו. החיפוש בקוד ב-view_file.html הוא מימוש נוסף שעדיין אינו עובר דרך המודול, ויש לו ממצאים משלו (#3477) — כולל אחד שהמודול לבדו אינו פותר: התאמה שחוצה כמה טוקנים של Pygments.
כללי ההתאמה הם של השרת. note_search_filter מחפש בדפוס ליטרלי, בלי רישיות ובלי נרמול ניקוד (הנימוק בסעיף על החיפוש בפתקים), והמודול עושה בדיוק אותו דבר. כך פתק שעמוד החיפוש מצא לפי מילה מציג הדגשה כשמחפשים את אותה מילה בתוכו. tests/text-highlight.test.js נועל את זה, כולל את היעדר נרמול הניקוד: נרמול בצד הלקוח בלבד היה יוצר בדיוק את הפער שהפונקציה המשותפת בשרת קיימת כדי למנוע.
שתי צורות, לשני סוגי צרכנים, וליבה אחת מתחתיהן. appendHighlighted בונה תוכן חדש מתוך מחרוזת — זה עמוד החיפוש, שמרכיב את התצוגה המקדימה בעצמו. highlightWithin עובר על תצוגה שכבר קיימת — המסמך והפתק. שתיהן עוברות דרך splitMatches, ולכן אין שתי תשובות לשאלה ”מהי התאמה“. className הוא חובה (TypeError בלעדיו), כי עטיפה בלי מחלקה היא עטיפה ש-clearHighlights לא ימצא. והאובייקט קפוא (Object.freeze): בלי זה סקריפט אחר בעמוד היה יכול להחליף פונקציה ולשנות בבת אחת את ההדגשה אצל כל הצרכנים.
התאמה שחוצה גבול בין צמתי טקסט אינה נתפסת — כל צומת נבדק לבדו, כמו במימוש הקודם במסמך. בפתק זה אומר שמילה שחציה מודגש, או ביטוי שנמשך לשורה הבאה, לא יימצאו; עמוד המשתמש מתאר את זה כהתנהגות.
חשוב
מודול שלא נטען הוא היעדר יכולת קבוע ולא כשל רגעי — ולכן כל צרכן מכבה את היכולת במפורש, ולא מדווח ”אין תוצאות“. בחיפוש במסמך השדה מושבת עם הסבר. בעמוד החיפוש הטקסט מוצג בלי הדגשה, ו-highlightInto מחזירה null ולא 0 — אפס היה אומר ”המילה אינה כאן“ ומדליק את הרמז ”המילה מופיעה בהמשך הפתק“ על כל תוצאה, כלומר תשובה שגויה במקום תשובה חסרה. בפתק הכפתור מושבת עם הסבר (_searchUnavailableReason). בכולם console.warn פעם אחת. זו אותה הכרעה שכבר מתועדת על window.RtlCode (יישור לימין: אותה שאלה, ולכן אותה פונקציה).
אזהרה
ולכן תג הטעינה הוא החוליה החלשה, והבדיקה עליו נגזרת מהקוד ואינה מונה צרכנים. בלי התג ההדגשה נעלמת מהעמוד בשקט — בדיוק מה שקרה עם מפת האלרטים ב-note_board.html (האזהרה בסעיף על בלוקי אלרט). בדיקת החיווט ב-tests/text-highlight.test.js מוצאת כל קובץ JS שקורא window.TextHighlight, וכל תבנית שטוענת קובץ כזה או קוראת למודול בסקריפט משלה, ודורשת שהמודול ייטען לפני השימוש הראשון, עם ?v= ובלי defer. צרכן חדש, או תבנית חדשה שמארחת פתקים, נכנסים לבדיקה מאליהם — ולכן אין בדיקת חיווט נפרדת לפתקים.
הדילוג על SVG: סלקטור, ולא tagName
המימוש הקודם במסמך דילג על script, style ו-svg בהשוואת tagName לרשימה באותיות גדולות. אבל במסמך HTML, לאלמנט במרחב השמות של SVG יש tagName באותיות קטנות — svg, וגם style שבתוכו — ולכן הדילוג על דיאגרמות לא פעל אף פעם. נמדד בכרומיום: tagName של <svg> הוא svg, ו-matches('svg') מחזיר true. לכן DEFAULT_SKIP הוא סלקטור שעובר ל-Element.matches ולא רשימת שמות: הוא מתאים את שני מרחבי השמות בלי תלות ברישיות. tests/test_text_highlight_browser.py בודק את זה בדפדפן אמיתי, כי דמות שנכתבה ביד הייתה עונה לפי מה שכותב הדמות חושב — וזו בדיוק ההנחה שנשברה כאן.
סכנה
התיקון הוא שינוי התנהגות בחיפוש במסמך, ולכן הוא נמדד לפני שהוכרע. מה שהדילוג השבור עשה בפועל, עם mermaid אמיתי: מילה שמופיעה ב-CSS של הדיאגרמה נספרה כהתאמה שם, והעטיפה שברה את ה-CSS באמצע מילה — צבע הצמתים השתנה עד לניקוי; ומילה בתרשים רצף נעלמה מהדיאגרמה בזמן שנספרה, כי עטיפת HTML בתוך <text> של SVG אינה מצוירת. המספרים בהערה שמעל בלוק החיפוש ב-md_preview.html.
והמחיר, שנמצא באותה מדידה: תווית של צומת בתרשים זרימה היא HTML בתוך foreignObject, והיא הייתה מודגשת — ועכשיו לא, כי הדילוג חל על כל תת-העץ של ה-SVG (FILTER_REJECT). זה בדיוק מה ש-K15 ב-amir-bug-patterns מזהיר ממנו: התנהגות מנוונת שקיימת מזמן היא לפעמים משהו שמישהו נשען עליו, ולפני שמסירים אותה מודדים מי. השינוי תועד ב-What’s New.
ומקור ה-TeX של נוסחה. DEFAULT_SKIP כולל גם semantics > :not(:first-child): בנוסחה מוצג רק הילד הראשון של <semantics>, וכל השאר מקבלים display: none — זה הכלל בגיליון ברירת המחדל (MDN, <semantics>). KaTeX שומר שם את מקור ה-TeX (<annotation encoding="application/x-tex">), ובלי הדילוג חיפוש מילה מתוך נוסחה ספר התאמה שאיש לא רואה. הפתקים לא מרנדרים נוסחאות, ולכן השינוי נוגע בחיפוש במסמך ובחיפוש בתוך קובץ בדפדפן הריפו (חיפוש בתוך הקובץ בתצוגה המרונדרת). נמדד על פלט אמיתי של KaTeX, מהבאנדל של התצוגה: \text{alpha} נספר שלוש פעמים לפני הדילוג ופעמיים אחריו, וה-annotation הוא display: none — test_the_tex_source_inside_a_formula_is_not_scanned ב-tests/test_text_highlight_browser.py.
תצוגה שנבנית מאפס: המצב ברשומה, וההדגשה בסוף כל בנייה
_syncTaskView מוחק ובונה את התצוגה בכל סימון צ’קבוקס ובכל יציאה מעריכה, ולכן הדגשה שנעשתה פעם אחת הייתה נעלמת בבנייה הבאה. מה שצריך לשרוד חי ב-entry.search — על רשומת הפתק (_getEntry), לצד מצב הביטול וזיכרון הבלוקים הפתוחים, ולא על האלמנט — ו-_afterViewSync רץ בסוף כל סנכרון: אחרי בנייה הוא מדגיש מחדש; בעריכה הוא משהה, והמונה ריק ולא ”אין תוצאות“, כי מה שריק הוא התצוגה ולא החיפוש; וכשלפתק אין עוד מבנה הוא סוגר את החיפוש.
``_afterViewSync`` רץ בתוך ``try`` משלו, עם ``console.warn``. _syncTaskView כולו עטוף ב-try אחד שבולע בשקט, והוא מוחק את התצוגה בתחילתו. ההדגשה היא תוספת על תצוגה שכבר נבנתה ונחשפה, וכשל בה אסור שיגיע לשם — אחרת הפתק היה נשאר בלי שום סימן לכשל, ובלי הלוג זו הייתה בליעה שקטה שנייה. test_a_failing_highlight_pass_is_logged_and_keeps_the_markdown מפיל את ההדגשה בכוונה ובודק שהמארקדאון נשאר והכשל נרשם.
ההתאמה הפעילה נשמרת לפי מספר סידורי, ולא לפי היסט. זה הלקח מהסעיף על ::: details (הכרעת הקליק, וההיסט שאינו נוגע בו): ההיסט הוא זהות בתוך רינדור אחד בלבד. הבנייה הנפוצה בזמן חיפוש היא סימון צ’קבוקס, שבה הטקסט המוצג לא משתנה, ולכן הסידורי מדויק. כשההתאמות פוחתות הפעילה נצמדת לאחרונה, כי איפוס לראשונה היה מאבד את המקום בכל סימון. test_highlight_survives_a_checkbox_toggle_and_keeps_the_active_match.
אין גלילה אחרי בנייה. נמדד שהגלילה הפנימית של התצוגה שורדת את הבנייה מחדש — אותו scrollTop לפני ואחרי — ולכן אין מה לשחזר.
טקסט שהמנוע מייצר אינו נסרק. _markGenerated מסמן ב-GENERATED_TEXT_CLASS צמתים שכל הטקסט בהם נוצר על ידי המנוע ולא נכתב בפתק — למשל תבליט של רשימה לא ממוספרת, תווית ברירת המחדל של אלרט מ-ADMONITION_TITLES, ו-DETAILS_DEFAULT_TITLE — ו-_applyNoteSearch מעביר את המחלקה ל-skip. בלי זה חיפוש ”הערה“ היה מוצא התאמה בכל ::: note בלי כותרת, מילה שאינה כתובה בפתק — וגם היה סותר את עמוד החיפוש, שמוצא פתקים לפי מה שכתוב בהם. מה שנגזר מילה במילה מהמקור אינו מסומן: כותרת מותאמת, תווית השפה של בלוק קוד, והמספר שהוקלד ברשימה ממוספרת. טקסט חדש שהמנוע ייצר צריך לעבור דרך ``_markGenerated``, אחרת הוא נכנס לחיפוש בשקט. test_text_the_engine_generates_is_not_matched בודק את זה בדפדפן, והבדיקה ”חיפוש: טקסט שהמנוע מייצר מסומן, וטקסט שנכתב לא“ בסנדבוקס.
בלוק מתקפל שהחיפוש פתח אינו של המשתמש
_revealSearchHit פותח <details> סגור שההתאמה בתוכו — אבל לא כשההתאמה בשורת ה-summary, שגלויה ממילא. הפתיחה הזו נרשמת ב-entry.search.openedDetails, ולא בזיכרון הפתיחה של המשתמש (entry.detailsOpen), לפי אותו מפתח — הסידור של הבלוק בתצוגה.
שני הכיוונים האחרים שבורים. אילו הלכידה שבראש _syncTaskView הייתה קוראת את הבלוק הזה מה-DOM כמו כל בלוק פתוח, הוא היה נכנס לזיכרון ונשאר פתוח גם אחרי החיפוש. ואילו לא היה נלכד כלל, הוא היה נסגר בבנייה הבאה — באמצע החיפוש, מתחת ליד. לכן הלכידה מדלגת על הסידורים שב-openedDetails, _applyNoteSearch פותח אותם מחדש אחרי כל בנייה, ו-_closeNoteSearch סוגר אותם. בלוק שהחיפוש פתח והמשתמש סגר בעצמו משתחרר מהרשימה — מאותו רגע הוא של המשתמש.
הבדיקות ”חיפוש: בלוק שהחיפוש פתח אינו נכנס לזיכרון הפתיחה“ ו-”חיפוש: בלוק שהחיפוש פתח והמשתמש סגר חוזר להיות של המשתמש“ ב-tests/sticky-notes-target.test.js בודקות את הזיכרון עצמו — דרך הממשק אין מסלול שמראה אותו, כי הוא נלכד מחדש מה-DOM בכל בנייה. test_details_opened_by_search_close_again_and_are_not_remembered בודק בדפדפן את מה שהמשתמש רואה, כולל סגירה מתוך עריכה.
לחיצה רגילה, לחיצה ממושכת, ומקלדת
בזמן חיפוש לחיצה רגילה אינה נכנסת לעריכה — הכרעה של המשתמש: לחיצות בטעות על התצוגה נפוצות, וכל אחת הייתה מעלימה את ההדגשה. _enterEditFromView מחזיר false על click כשהחיפוש פתוח, ורק עליו: מסלול המקלדת (keydown) אינו נחסם, כי במקלדת אין לחיצה ממושכת. _isViewControl היא התשובה היחידה לשאלה ”מהו פקד בתצוגה“ (תיבת סימון, קישור, כפתור ההעתקה, summary), וכל מסלול שמחליט אם לחיצה נכנסת לעריכה שואל אותה.
הלחיצה הממושכת (``_enableViewLongPress``) נכנסת לעריכה בשחרור, ולא כשהטיימר מסתיים. הטיימר (LONG_PRESS_MS) רק דורך ומסמן את השורה; השחרור מבצע. שתי סיבות, ושתיהן מהדפדפן. אחרי לחיצה ממושכת במגע אין click אלא רק pointerup — נמדד בכרומיום במגע מדומה (Input.synthesizeTapGesture עם duration) — ולכן מאזין הלחיצה הקיים לא היה רץ. ו-pointerup של מגע הוא אירוע שנותן לדף ”פעולת משתמש“ (HTML Standard, ”activation triggering input event“: pointerup כש-pointerType אינו mouse), ובלעדיה דפדפן נייד עשוי למקד את ה-textarea בלי לפתוח מקלדת. pointercancel אחרי דריכה — הדפדפן לקח את המחווה לעצמו — מכובד כשחרור.
מה נחסם בזמן הלחיצה, ורק במגע. המחלקה is-pressing מחילה user-select: none ו--webkit-touch-callout: none (האחרון פועל ב-Safari ב-iOS בלבד), והמאזינים מבטלים contextmenu ו-selectstart — רק כשהלחיצה הפעילה היא מגע, ורק בזמנה. בעכבר אין חסימה: שם אין תפריט על לחיצה ממושכת, וגרירה לבחירת טקסט מבטלת את הלחיצה הממושכת ממילא (תזוזה מעל LONG_PRESS_SLOP_PX).
קבוע אחד לכל הפתק. LONG_PRESS_MS ו-LONG_PRESS_SLOP_PX הם גם מה שהכותרת משתמשת בו (_enableDrag); שני מספרים מקומיים היו נסחפים זה מזה. הבדיקה ”לחיצה ממושכת: קבוע אחד לכותרת ולתצוגה“ ב-tests/sticky-notes-target.test.js מוודאת שאין מספר מוקלד באף אחד מהם.
חשוב
מה נבדק רק באמולציה — ונאמר כאן במפורש. כרומיום בלי ממשק אינו מייצר את תפריט ההקשר ואת בחירת הטקסט המקוריים של לחיצה ממושכת במגע: נמדד ב-2026-09-28, החזקה של 900 מילישניות דרך Input.dispatchTouchEvent לא הוציאה contextmenu ולא בחירה. לכן test_touch_long_press_enters_edit_in_emulation בודק שהקשה קצרה אינה נכנסת לעריכה, שבזמן לחיצה פעילה contextmenu מבוטל, ושהחזקה ארוכה נכנסת לעריכה בשחרור בלי להשאיר בחירה — לא שדפדפן במכשיר אמיתי אכן לא יציג תפריט או בחירה, ולא שהמקלדת נפתחת. זה לא נבדק על טאבלט או טלפון אמיתיים, לא ב-Safari ב-iOS ולא ב-Chrome ב-Android. מי שמשנה את המסלול הזה בודק אותו במכשיר.
התיבה: ילד של הפתק, מעליו, ובלי לשנות את המלבן שלו
_buildSearchBar מכניס את התיבה לתוך הפתק, מיד אחרי הכותרת. כך היא זזה איתו בגרירה ובשינוי גודל, בכל מצבי המיקום, ונמחקת איתו — בלי מעקב מיקום משלה. היא ממוקמת absolute מעל הפתק, ולכן אינה משנה את המלבן שלו. זה תנאי ולא נוחות: _notePayloadFromEl קורא את המיקום מהמלבן (הסעיף על גודל), ותיבה שמשנה את המלבן הייתה נשמרת כמיקום חדש בגרירה הבאה. test_the_bar_moves_with_the_note_and_does_not_change_what_is_saved גורר פתק עם חיפוש פתוח ובלעדיו, בכל מצב מיקום, ומשווה את מה שנשלח לשרת.
הכפתור בשורת השם, ליד העיפרון — ולא בשורת הפעולות. שורת הפעולות אינה נשברת, ובפתק צר היא כבר חתוכה (#3478); כפתור נוסף בה היה דוחף את כפתור המחיקה אל מחוץ לפתק גם ברוחב הרגיל. והאייקון קווי ולא אימוג’י (SEARCH_ICON_SHAPES): אימוג’י צבעוני מתעלם מ-color, ולכן כפתור מושבת היה נראה זמין בדיוק כמו פעיל.
פתק שיש לו ילדים מחוץ לגבולותיו אינו חותך (.is-unclipped, overflow: visible), אחרת הפתק עצמו חותך את התיבה שיושבת מחוץ לו. המצב נכתב בשביל החיפוש, והיום הוא משותף לו, לשרשורי התגובות (שרשורי תגובות: השרת והממשק בוובאפ), שהבועות שלהם, החיישן, החלונית ורשימת ”+N“ יושבים גם הם מחוץ לפתק, ול-תפריט ⋮. ההכרעה במקום אחד — _syncNoteClipping: החיפוש, השרשורים והתפריט שואלים שם, ולא מדליקים ומכבים כל אחד לבד, אחרת סגירת החיפוש הייתה מחזירה את החיתוך מעל בועות שעדיין שם. אבל ה-overflow: hidden של הפתק הסתיר עד אז דברים, ובמדידה בכרומיום, פיקסל מול פיקסל, הם יצאו החוצה: שורת הכפתורים של פתק צר צוירה מחוץ לפתק, מעל פתק שכן; רקע הכותרת בלט בפינות המעוגלות; והקצה המרובע של פס הגלילה בלט בפינה התחתונה. התשובה היא כלל אחד ולא רשימה: כל ילד ישיר של הפתק חותך את עצמו (clip-path, בתוך :where שמאפס את הספציפיות) — חוץ ממי שמסומן sticky-note-outside, כלומר קיים כדי לצאת מהפתק. ילד שיתווסף לפתק בעתיד מכוסה מאליו, וילד חדש שיוצא מהפתק מקבל את הסימון.
אזהרה
``clip-path`` ולא ``border-radius`` — ההבדל נמדד, לא הונח. רדיוס על מכל גלילה אינו חותך את פס הגלילה שלו: הפיקסל בפינה נשאר בצבע המסילה עם רדיוס ובלעדיו. clip-path כן. הרדיוס הפנימי נגזר מ---note-radius פחות עובי הגבול — ולכן --note-radius הוא טוקן ולא מספר. ו-clip-path חותך גם את טבעת המיקוד שמחוץ לתיבה, ולכן לתצוגה יש במצב הזה טבעת פנימית.
test_nothing_the_note_clipped_leaks_out_while_searching משווה את מה שמחוץ לפתק לפני הפתיחה ואחריה, ורץ בדפדפן עם פסי גלילה: Playwright מסתיר אותם כברירת מחדל (--hide-scrollbars), ובלעדיהם הקצה של הפס פשוט לא מצויר — כלומר הבדיקה הייתה עוברת גם על הבאג.
כשהתיבה מעל הפתק, הפינות העליונות שלו ישרות: הלשונית והפתק הם צורה אחת, ופינות מעוגלות מתחת ללשונית ישרה השאירו שני חריצים. ובלי תיבה — כשמעל הפתק יש רק בועות של שרשורים — הכותרת חותכת את הפינות העליונות המעוגלות בעצמה (.is-unclipped:not(.is-searching) .sticky-note-header); test_nothing_the_note_clipped_leaks_out_once_it_has_bubbles ב-tests/test_note_threads_browser.py הוא אותה השוואת פיקסלים, במצב הזה.
כשאין מקום מעל: חיישן, ולא חישוב
פתק בקצה העליון של המסך, של הלוח או של מכל הריפו היה משאיר את התיבה מחוץ למסך או חתוכה. _watchSearchRoom מכריע לפי חיישן — .sticky-note-search-room, אלמנט בלתי נראה שיושב תמיד מעל הפתק בגובה התיבה — ולא לפי התיבה עצמה: ברגע שהתיבה עוברת פנימה היא גלויה במלואה, וההכרעה הייתה מתהפכת בחזרה בלי סוף.
IntersectionObserver ולא חישוב ידני, כי הוא סופר חיתוך של כל אב בשרשרת — משטח לוח עם overflow, מכל הריפו — ולא רק של המסך (W3C Intersection Observer, ”compute the intersection“). threshold: [1]: כל חיתוך של החיישן מכניס את התיבה פנימה. ו-observe מציב previousThresholdIndex = -1, ולכן ההודעה הראשונה מגיעה מיד. ResizeObserver מסנכרן את גובה החיישן לגובה התיבה, שנשברת לשתי שורות בפתק צר. והחיישן הוא גם הסיבה שה-``overflow: visible`` שלמעלה הכרחי: הפתק הוא אב של החיישן, ובלי זה החיישן היה נמדד חתוך תמיד.
בתוך הפתק התיבה דוחפת ואינה מכסה. תיבה absolute מתחת לכותרת הייתה מכסה את השורות הראשונות — אולי בדיוק את ההתאמה — ובפתק שאינו נגלל לא הייתה דרך לחשוף אותן. במצב is-search-inside היא static בזרימה (הפתק הוא עמודת flex, והתיבה יושבת ב-DOM מיד אחרי הכותרת), ולוקחת את מקומה מגובה התצוגה, שנגללת ממילא. המלבן של הפתק לא משתנה גם כאן. test_the_bar_sits_above_the_note_and_moves_inside_at_the_top_edge.
גלילה, מיקוד ומקשים: רק הפתק זז, ורק התיבה שומעת
scrollIntoView גולל כל אב נגלל — בפתק מעוגן הוא היה מזיז את המסמך שמתחת לפתק, ובפתק על לוח את הלוח. scrollIntoViewWithin ב-utils/text-highlight.js גולל רק את האבות שבין ההתאמה לבין התצוגה (כולל עטיפה של טבלה רחבה), ורק כשההתאמה אינה גלויה במלואה. הוא נכתב כאן, ועבר למודול כשהחיפוש בתוך קובץ בדפדפן הריפו היה צריך אותו בדיוק (חיפוש בתוך הקובץ בתצוגה המרונדרת) — _revealSearchHit קורא לו משם, אחרי שפתח את הבלוק הסגור. התאמה שאינה מצוירת, כמו בתוך <details> סגור, מקבלת במקומה את האב הקרוב שכן מצויר (checkVisibility), כי המלבן שלה אינו מקום על המסך. אב עם overflow: hidden אינו נגלל, כי תוכן היה נעלם בלי דרך לגלול חזרה. החשבון הוא הפרש בין מלבנים, ו-scrollLeft += הפרש נכון ב-RTL וב-LTR כאחד: ב-RTL scrollLeft מתחיל ב-0 בקצה הימני ושלילי לשמאל, ובשני הכיוונים הגדלתו מזיזה את החלון ימינה (MDN, ``Element.scrollLeft``). ופתיחת התיבה ממקדת את השדה עם preventScroll, כי מיקוד רגיל גולל את העמוד כדי לחשוף את השדה (MDN, ``HTMLElement.focus()``). test_navigation_scrolls_the_note_and_not_the_page, test_opening_the_search_does_not_scroll_the_page.
מקשים בתיבה אינם מבעבעים. keydown, keyup ו-keypress נעצרים בתיבה, כי יש מאזינים ברמת העמוד — בדפדפן הריפו, למשל, Escape סוגר חלוניות. בזמן הרכבה במקלדת IME ה-Enter שייך להרכבה ולא לנו: ev.isComposing || ev.keyCode === 229 (MDN, ``Element: keydown event``). יוצא דופן אחד, ומתועד בבדיקה: ה-keyup של ה-Escape שסוגר את החיפוש נוחת כבר על כפתור החיפוש — הפוקוס חזר אליו והתיבה אינה קיימת — ולכן מאזין keyup ברמת העמוד כן רואה אותו. test_keys_in_the_bar_do_not_reach_the_page_and_ime_enter_is_ignored.
פתיחת חיפוש באמצע עריכה יוצאת מהעריכה במסלול הקיים. הפוקוס עובר לשדה החיפוש, ה-textarea מקבל blur, וה-blur הוא שמריץ את _flushFor (הסעיף ”סדר: flush לפני כל פעולה הרסנית“). מסלול יציאה שני היה יכול לשכוח את השמירה — ולכן גם אין קריאה מפורשת ל-blur. test_opening_search_while_editing_saves_the_last_keystrokes.
הצבעים: משטח קשיח, ומדידה בפיקסלים
הנייר של הפתק אינו משתנה עם הערכה (הסעיף על צבעי ::: details, הצבעים והכיול: אותה מלכודת של האלרט, ואותו כלל מובייל), ולכן הצבעים כאן ליטרליים ולא טוקני ערכה. נקודת המוצא היא .md-highlight של החיפוש במסמך — זהב וטקסט כהה — אבל ההתאמה בפתק שונה ממנו בכוונה:
בלי ``padding`` ובלי שינוי משקל. בפתק צר שינוי רוחב של מילה שובר את השורות מחדש בכל הקלדה. גם ה-
outlineשל ההתאמה הפעילה אינו תופס מקום.test_highlight_does_not_change_text_metrics.``span`` ולא ``mark``. לאפליקציה יש כללי ערכה על
markכאלמנט, והמרקר של המשתמש (==) הואmarkבעצמו.``NOTE_SEARCH_HIT_CLASS`` ולא ``md-highlight``. החיפוש במסמך מנקה לפי המחלקה ההיא, והוא היה מוחק גם את ההדגשה בפתקים. הפתקים יושבים מחוץ למכל המסמך (
test_notes_live_outside_the_document_search_container), אבל מחלקה משותפת הייתה הופכת את זה מעובדה על המבנה להנחה.
שני מדדים, ולכל אחד תפקיד. מילוי ההתאמה נמדד במרחק RGB מהנייר ומהמרקר — המדד שכבר משמש את המרקר (הסעיף ”הצבעים והמדידה“) — כי ניגודיות WCAG בין שני צבעים בהירים נמוכה ממילא (1.10–1.36 כאן) ואינה אומרת אם העין מבחינה ביניהם. טקסט וטבעות נמדדים בניגודיות WCAG.
הערכים נמדדו ב-2026-09-28 בדגימת פיקסלים בכרומיום, על כל נייר שהיה אז בפלטה, באותה שיטה ש-test_highlight_colors_by_pixel_sampling ו-test_the_bar_is_readable_in_every_theme מריצות — ושתיהן רצות על כל נייר ב-NOTE_COLOR_ORDER, ולכן נייר שנוסף אחרי המדידה נבדק בספים שלהן ואינו בטווחים שבסעיף הזה (הצבעים והמדידה). הבדיקות אוכפות ספים עם שוליים, ולא את המספרים עצמם. הנייר אינו תלוי בערכה, ולכן ערכי ההתאמה זהים בכל הערכות.
מה נמדד |
מול מה |
נמדד |
הסף בבדיקה |
|---|---|---|---|
מילוי ההתאמה |
הנייר |
מרחק RGB 190–258 |
150 |
מילוי ההתאמה |
המרקר של המשתמש |
מרחק RGB 210 |
150 |
טקסט בתוך ההתאמה |
המילוי |
11.35:1 |
7:1 |
טבעת ההתאמה הפעילה |
המילוי |
7.41:1 |
4.5:1 |
טבעת ההתאמה הפעילה |
הנייר |
8.14–10.10:1 |
4.5:1 |
טקסט מוקלד בשדה |
רקע השדה |
10.39:1 |
7:1 |
ה-placeholder בשדה |
רקע השדה |
4.68:1 |
4.5:1 |
טבעת המיקוד של השדה |
רקע התיבה (הנייר) |
5.25–6.52:1 |
3:1 |
המונה |
הנייר |
8.14–10.10:1 |
לא נבדק |
לשם השוואה, המרקר עצמו רחוק מהנייר 69–110 באותו מדד — ולכן ההתאמה בולטת ממנו בבירור.
השדה לבן אטום, ולא שקוף מעל הנייר. מילוי שקוף לוקח את גוון הנייר, וה-placeholder ירד אז מתחת ל-4.5:1 בחלק מהניירות; ההערה על .sticky-note-search-input ב-CSS מפרטת באילו, ו-test_the_bar_is_readable_in_every_theme נופל בדיוק עליהם כשמחזירים את המילוי השקוף. הגבול .7 הוא אותו ערך שנמדד לגבול העיגולים בבורר הצבע; מול המילוי הוא 4.43:1, ומול הנייר 3.47–4.31:1.
חשוב
ערכת הניגודיות הגבוהה: התיבה מצטרפת לערכה, ולא נלחמת בה. high-contrast.css כופה ב-!important על button, input, textarea ו-select רקע שחור, טקסט לבן וגבול לבן של 2px, ועל כל אלמנט במיקוד (*:focus) טבעת צהובה של 3px. את זה אין לדרוס — זו ערכת נגישות. על נייר בהיר זה נכשל בשני מקומות: הטבעת הצהובה מול הנייר יצאה 1.04:1, כמעט בלתי נראית, וה-placeholder שלנו על השחור הכפוי 4.48:1.
איך התיבה נראית שם: פס שחור צמוד לפתק, עם גבול לבן; בתוכו שדה שחור עם גבול לבן של 2px וטקסט לבן, ו-placeholder בלבן-אפור (#d0d0d0); כשהשדה במיקוד — טבעת צהובה של 3px; כפתורי ▲▼✕ שחורים עם גבול לבן; והמונה בלבן. והקריאוּת אחרי התיקון, באותה מדידה: טקסט מוקלד 21:1, placeholder 13.62:1, מונה 21:1, טבעת המיקוד מול הפס 19.56:1, וגבול השדה 21:1.
מה לבדוק, ובמה
ליבת ההתאמה והחיווט של התבניות —
tests/text-highlight.test.js, ב-node. רץ ב-CI.המנוע של הפתקים — מתי לחיצה נכנסת לעריכה, סיבות ההשבתה, מה מסומן כמיוצר, זיכרון הבלוקים: הבדיקות שנפתחות ב-”חיפוש:“ וב-”לחיצה ממושכת:“ ב-
tests/sticky-notes-target.test.js, ב-node. רץ ב-CI. שני הסנדבוקסים שם טוענים גם את ``utils/text-highlight.js``, והבדיקה הראשונה מוודאת את זה — אותו תנאי מקדים שכבר מתועד עלutils/rtl-code.js(יישור לימין: אותה שאלה, ולכן אותה פונקציה): בלעדיו כל טענה רצה דרך מסלול היעדר-היכולת ומאשרת את עצמה.כל מה שרק דפדפן עונה עליו —
TreeWalker,matchesעל SVG,IntersectionObserver, חיתוך, צבעים, אירועי מגע:tests/test_text_highlight_browser.py,tests/test_md_preview_search_browser.pyו-tests/test_sticky_note_search_browser.py.
אזהרה
בדיקות הדפדפן אינן רצות ב-CI. playwright מותקן שם (requirements/base.txt), אבל אף workflow אינו מתקין Chromium, ולכן chromium_executable ב-tests/conftest.py מדלג עליהן. הן רצות מקומית בלבד — מי שנוגע בחיפוש בתוך פתק, בהדגשה או בחיפוש במסמך מריץ אותן לפני PR. ירוק ב-CI אומר כאן רק שהן דולגו.
שרשורי תגובות: השרת והממשק בוובאפ
ההתנהגות הנראית — בועות, חלונית, כפתורים — מתוארת ב-שרשורי תגובות על פתקים, והמודל, כללי הכתיבה וצד ה-MCP ב-שרשורי תגובות על פתקים — codekeeper_comment_note. כאן הנימוק של הוובאפ: איך השרת חושף את השרשורים לממשק (שרשורי תגובות: השרת), ואיך הממשק בנוי סביב הפתק.
הממשק: ילדים של הפתק, והמצב במקום אחד
המצב חי ב-``entry.threads``, לצד entry.search: התקצירים, הטיוטות (לפי מזהה שרשור, ו-'new' לשרשור חדש), החלונית הפתוחה ורשימת ”+N“. הבועות נבנות מאפס מהמצב בכל רינדור (_renderThreadRow), והפוקוס עובר לבועה שמחליפה את זו שהייתה בפוקוס. _initNoteThreads מוחק את note.threads מאובייקט הפתק אחרי שקרא אותו: העותק שם היה מתיישן בכתיבה הראשונה, ומי שהיה קורא ממנו היה מקבל מצב שכבר אינו קיים — מכאן והלאה המקור היחיד הוא entry.threads. הקאש המקומי הוא עניין נפרד: _saveCache מסיר את threads מכל פתק לפני השמירה, כדי שבועה ישנה לא תוצג מהקאש.
האלמנטים הם ילדים של הפתק, כמו תיבת החיפוש: זזים איתו, נמחקים איתו, וממוקמים absolute — ולכן אינם משנים את המלבן שלו, ש-_notePayloadFromEl קורא ממנו את המיקום. הם יושבים מחוץ לגבולותיו, ולכן נושאים sticky-note-outside ומדליקים את .is-unclipped — המנגנון המשותף עם החיפוש, בסעיף ”התיבה: ילד של הפתק“. חלונית אחת לכל המנהל (_closeThreadUiElsewhere), ופתיחתה מעלה את הפתק לקדמת הערימה (_raiseToFront): ה-z-index של החלונית כלוא בתוך הפתק, ופתק שכן שמצויר אחריו היה מכסה אותה.
💬 בשורת הפעולות, בין 📌 ל-↶ — המקום שלו בעיצוב, בהכרעה מפורשת. בעיצוב ישב לפניו 🔔, שעבר מאז ל-תפריט ⋮ שבסוף השורה. זה בדיוק מה שכפתור החיפוש נמנע ממנו (חיפוש בתוך פתק: מודול הדגשה אחד, ומה שהפתק מוסיף עליו): שורת הפעולות אינה נשברת, וכפתור שמיני דוחף את כפתור המחיקה אל קצה הכותרת. נמדד בכרומיום, כשהשורה עוד הסתיימה ב-×: ברוחב 240 וב-260 — רוחב פתק חדש במחשב עד אוקטובר 2026 — ה-× חתוך, ומ-280 הוא נכנס (#3478; במדידה בצעדים של פיקסל — מ-272, ראו גודל של פתק חדש, והגיבוי של פתק בלי גודל). מאז ש-⋮ בסוף השורה, ברוחבים האלה הוא הנחתך ולא ×. ולחיתוך יש תוצאה שנייה, במגע: נגיעה קרובה לכפתור מגיעה אליו גם כש-elementFromPoint מחזיר את הכותרת — עד 8.5px מתחת ל-×, ומ-10.5px כבר לא — ולכן פינת שורת השם שמתחת ל-× החתוך אינה ידית גרירה. header_point ב-tests/test_repo_notes_browser.py בוחר לכן נקודת גרירה רחוקה מכל כפתור (DRAG_POINT_BUTTON_CLEARANCE_PX); לפני כן הוא בחר נקודה חצי פיקסל מתחת לשורת הכפתורים, והנגיעה נבלעה ב-× שהגיע לשם.
כמה בועות נכנסות — הנוסחה מהעיצוב, והמספרים מה-CSS. _threadRowCapacity מחשב floor((W − 2·inset) / slot) מתוך --thread-row-inset ו---thread-slot שעל השורה, ולא ממספרים מוקלדים: אותם משתנים קובעים גם את הפריסה, ושני עותקים של אותו מימד נסחפים. לכן הם ליטרלים ב-px — מאפיין מותאם שאינו רשום נשאר מחרוזת, ו-calc בתוכו לא היה מחושב בקריאה. _threadRowLayout (טהור, ונבדק ב-node) מחליט: כשהכול נכנס — הכול מוצג, ואחרת capacity − 1 בועות ו-”+N“. בועת השרשור החדש מוצגת תמיד, כי החלונית שלה מצביעה עליה.
שטח לחיצה של 44×44, בלי חפיפה. הכפתור הוא הבועה עצמה, ו-::after מרחיב אותו: 4px לכל צד, ולגובה — מראש אזור הבועות עד קו הפתק. התאים צמודים, ולכן השטחים נוגעים ואינם חופפים. הכפתור נשאר בגודל הבועה בכוונה: ערכת הניגודיות הגבוהה כופה על כל button רקע שחור וגבול לבן, וכך היא מציירת בועה שחורה, ולא קופסה. בדיקת הפגיעה של כרומיום מעגלת קופסאות לפיקסלים שלמים (נמדד ב-14.10.2026: הפתק עצמו נתפס מ-0.8px לפני המלבן שלו), ולכן test_bubbles_sit_on_the_note_as_designed בודק את הגבולות החיצוניים בפער של 1.5px, ואת הגבול בין שכנות בחצי פיקסל — שתיהן באותה שכבה, והעיגול זהה לשתיהן.
פתק בקצה העליון — חיישן, כמו בחיפוש. .sticky-threads-room יושב תמיד מעל הפתק בגובה אזור הבועות, ומשקיף אחד לכל המנהל (_observeThreadRoom, threshold: [1]) מדליק .is-threads-below כשהחיישן חתוך — במסך, במשטח הלוח או במכל הריפו, וגם אחרי גלילה. הבועות עוברות לתחתית, הפוכות, ושטח הלחיצה מתהפך איתן; החלונית נפתחת כלפי מעלה, ותיבת הכתיבה מצטמצמת לשורה כל עוד היא ריקה ואין בה פוקוס. הפוקוס של השדה עצמו, ולא ``:focus-within``: פתיחת שרשור ממקדת את החלונית, ו-:focus-within היה מבטל את הצמצום מיד.
מיקום מול המסך: מצמצמים לפני שמזיזים (H7). _layoutThreadPopup, לחלונית ולרשימת ”+N“, מגביל קודם את הרוחב למסך פחות השוליים (NOTE_EDGE_GAP), ורק אז מזיז אל תוך המסך. ההזזה היא הפרש בין מלבנים שנמדדו, ולכן נכונה ב-RTL וב-LTR כאחד, והצד שאליו הרשימה מיושרת ל-”+N“ נקרא מ-direction ולא מונח. ”פתק צר“ (.is-narrow) נקבע מ---thread-panel-width ו---thread-panel-inset, והרוחב הצר יושב במשתנה נפרד: אילו .is-narrow היה משנה את המשתנה שה-JS קורא, ההכרעה הייתה מתהפכת. החלונית ממוקמת מחדש בכל גלילה (document, בשלב הלכידה), בשינוי גודל של החלון, של הפתק או של החלונית, ובסוף אנימציית הכניסה של הפתק — שבזמנה המלבנים קטנים מהאמיתיים.
הבקשות: הצלחה רק כשהשרת אמר, ותקרת זמן משלה
_threadFetch מחזיר {result, abort}: abort מבטל את הבקשה (בסגירת החלונית), ו-result נפתר תמיד ל-{ok, status, data} — לעולם אינו נדחה. ok רק כשהתשובה 2xx וגם {ok: true}: fetch אינו נכשל על 4xx/5xx, ודף שגיאה שאינו JSON אינו הצלחה (K11). לכל בקשה תקרת זמן משלה, THREAD_FETCH_TIMEOUT_MS: AbortController עם טיימר — ולא AbortSignal.timeout, כי אותו בקר משמש גם לביטול טעינה כשהחלונית נסגרת. busy של החלונית נכבה ב-finally, ולכן בקשה שנכשלה או נתקעה אינה משאירה את הכפתורים נעולים (wait-without-own-deadline).
מה שמוצג אחרי הצלחה הוא מה שהשרת החזיר (_sendThreadAction) — המצב, התגובה כפי שנשמרה והתקצירים — ולא מה שנשלח. כשמספר התגובות שהשרת מדווח אינו מה שמוצג — סוכן כתב בינתיים — השרשור נטען מחדש. סירוב שאומר שהשרשור השתנה (already_resolved, not_resolved, conflict, thread_full, thread_too_long) טוען אותו מחדש ומשאיר את ההודעה גלויה. ובכל כשל הטקסט נשאר בתיבה ובטיוטה.
כל מה שהגיע מהשרת נבדק בטיפוס שלו (U3) — _threadsFromServer, _commentFromServer ו-_threadStateFromServer. ערך שאינו רשימה הוא null: ”לא ידוע“, ולא ”אין שרשורים“.
המונה סופר נקודות קוד (codePointLength, [...s].length), כמו השרת. maxlength של textarea אינו התשובה: הוא סופר יחידות UTF-16 (HTML Standard, ”code-unit length“), ולכן היה עוצר תגובת אימוג’י באמצע — הפער שכבר נשך את אזהרת האורך של הפתק.
``?thread=`` נלכד בזמן טעינת הסקריפט, כמו ?note=, ונצרך רק כשהפתק נמצא (_maybeScrollToNoteFromUrl); דפדפן הריפו מסיר את שניהם מהכתובת לפני שהמנהל נבנה (consumeOneShotUrlParams ב-repo-browser.js). הצורה אינה נבדקת בלקוח: הקישור הקבוע /note/<id> כבר זרק ערך שאינו מזהה (note_threads.parse_thread_id), ושרשור שאינו של הפתק נענה בשרת ב-404, שהחלונית מציגה.
הזמן היחסי בחלונית מגיע מ-utils/relative-time.js, המודול המשותף שגם formatDate ב-base.html ו-formatRelativeDate ב-repo-history.js עוברים דרכו (tests/relative-time.test.js). בחלונית צרה — הצורה המקוצרת.
ערכות וניגודיות
החלונית היא חלק מ“נייר“ הפתק, ולכן צבעיה ליטרליים מהעיצוב ואינם משתנים עם הערכה — אותה הכרעה כמו בדיו של הפתק ובתיבת החיפוש. מה שיושב מחוץ לפתק ונוגע ברקע הלוח — הטבעת של נקודת ”מחכה לך“ ורקע הבועה החדשה — לוקח את --bg-primary.
כללי ערכה על ``textarea``.
[data-theme="dark"] textareaצובע רקע, טקסט וגבול, וב-:focus(0,2,1) גם גבול ומתאר. הכלל הבסיסי של השדה שלנו הוא (0,2,0), ולכן ה-``outline`` חייב לשבת ב-``:focus`` שלנו (0,3,0), ולא רק בכלל הבסיס: בלעדיו נוסף בערכה הכהה מתאר מעל הטבעת של העיצוב.test_the_panel_in_other_themesבודק את זה. Rose Pine Dawn מחריג את השדה בכלל שלו ב-base.html, כמו את.sticky-note-content.ניגודיות גבוהה — החלונית מצטרפת לערכה, כמו תיבת החיפוש: שחור, טקסט לבן וגבול לבן. הבועות הן כפתורים, ולכן שם הן בועות שחורות עם גבול לבן, והמספר או ה-✓ מבדילים ביניהן.
סטייה מכוונת מהעיצוב: טקסט עמום שלא עמד ב-4.5:1 (WCAG 1.4.3). הזמן, המונה, ”N תגובות“ וה-placeholder קיבלו גוונים כהים יותר; המספרים בהערה שבראש סעיף השרשורים ב-
sticky-notes.css, ו-test_text_in_the_panel_is_readableמודד כל טקסט בחלונית מול הרקע שהוא יושב עליו.
מה לבדוק, ובמה
הלוגיקה —
tests/sticky-notes-threads.test.js, ב-node. רץ ב-CI: הנוסחה של ”+N“, בדיקת הטיפוסים,_threadFetch(הצלחה רק עםok: true, התקרה והביטול), המונה, ומה כל כפתור שולח.השרת —
tests/test_note_threads_webapp.py(מונגו אמיתי),tests/test_note_comment_push.pyו-tests/test_note_permalink.py.כל השאר — בדפדפן:
tests/test_note_threads_browser.py, מול השרת האמיתי ומונגו אמיתי, בשלושת המשטחים, וכל פעולה נקראת בסוף מהמסד. מה נבדק שם, וממה הערכים — ב-docstring שלו.
אזהרה
בדיקות הדפדפן ובדיקות המונגו האמיתי אינן רצות ב-CI — אין שם Chromium, ומונגו של ה-CI אינו נגיש (docs/testing.rst). מי שנוגע בשרשורים מריץ אותן לפני PR, עם NOTE_FONTS_TEST_MONGO_URI.
ארכיון: המנהל במצב ארכיון, העמוד וחלון השם
הצד של הנושא הזה בשרת: ארכיון: היעד הרביעי. ומה המשתמש רואה: ארכיון.
יעד רביעי של המנהל, ומשטח מסוג אחר. new StickyNotesManager({archive: true, container, onArchivedNoteClosed, onArchivedNoteDeleted}). archive נספר כיעד, ולכן ארכיון יחד עם יעד אחר הוא חריגה, כמו כל שני יעדים. אין בו פתקים פזורים: העמוד מציג רשת של כרטיסים, ופתק אחד נפתח מעליהם בכל פעם (openArchivedNote). לכן המנהל במצב הזה אינו טוען רשימה, אינו מציג FAB, אינו שומר קאש (_cacheKey הוא null), ואינו צורך את ?note= בעצמו — העמוד צורך אותו. _resolveMode מחזיר screen לפני כל שדה בפתק: line_start שסוכן כתב על פתק ארכיון היה מעביר אותו ל-anchored, מול עוגן שאינו קיים.
אותו רכיב פתק, וההבדלים. אין 📌 — הפתק הפתוח תמיד צמוד למסך, ואין משטח להצמיד אליו. ”—“ סוגר את הפתק ומחזיר אותו לכרטיס, ואינו כותב מיזעור. ו-× מוחק כמו בכל פתק, והעמוד מוריד את הכרטיס רק אחרי שהשרת אישר. המיקום אינו נשמר, והגודל כן: _queueSave — המקום שכל מסלולי השמירה עוברים בו — מוריד את position במצב הזה, כי הפתק נפתח במרכז המסך בכל פעם. הגודל מצומצם קודם לאותם גבולות שכל מסלול הצפה משתמש בהם (_viewportFitBounds, שגם _fitAndClampToViewport נשען עליו), ורק אז ממורכז — מצמצמים ואז ממקמים (H7). הפתק מקבל role="dialog" ואת המיקוד. בסגירה: flush, ואחריו onArchivedNoteClosed עם השם והצבע (hex) שבפתק עכשיו, והתכווצות אל המלבן של הכרטיס — הפרש בין שני מלבנים שנמדדו, ולכן נכון בשני הכיוונים — ובלי אנימציה כשהמשתמש ביקש פחות תנועה.
מחיקה מורידה פתק מהמסך רק כשהשרת אמר כן — תיקון שחל על כל המשטחים. _deleteNoteEl הוריד עד היום את הפתק גם כשהבקשה נכשלה, כלומר פתק שנשאר במסד נראה כאילו נמחק. עכשיו הפתק יורד רק על תשובה ok (או 404: הפתק כבר אינו קיים), ובכשל המשתמש רואה הודעה. ההסרה המקומית עברה ל-_removeNoteLocally, שגם ההעברה לארכיון עוברת בו.
🗃️ בתפריט ⋮ (תפריט ⋮: הפעולות הנדירות, ומה קובע לאן הוא נפתח). לפריט ב-NOTE_MENU_ITEMS יש עכשיו available, פונקציה שמקבלת את המנהל ומחליטה אם הפריט מוצג — וכך 🗃️ אינו מוצג בפתק שכבר בארכיון. _archiveNote שולח קודם עריכה שממתינה בתור (flush, כמו לפני כל פעולה הרסנית), אחר כך פותח את חלון השם לפתק בלי שם, שולח את הבקשה, ורק כשהשרת ענה ok הפתק יורד מהמשטח ומוצגת הודעה עם קישור לארכיון. גם פתק שנראה עם שם עשוי לקבל title_required — שם שעוד לא נשמר — ואז החלון נפתח והבקשה נשלחת שוב. לחיצה כפולה בזמן שהבקשה באוויר אינה שולחת בקשה שנייה (entry.archiving).
חלון השם. ההצעה היא כותרת ה-Markdown הראשונה, ואם אין — השורה הראשונה, בלי סמן הבלוק שלה (_suggestNoteTitle). ”כותרת“ היא מה שמנוע הפתק עצמו מרנדר ככותרת (_classifyLine), ולא הגדרה שנייה. הקיצור הוא לשישים נקודות קוד בגבול מילה (clipTitleSuggestion, דרך Array.from): slice סופר יחידות UTF-16 וחוצה אימוג’י באמצע (H6). השדה מוגבל ב-maxlength לתקרה של השרת, מקבוע אחד — MAX_NOTE_TITLE_CHARS, וטסט משווה אותו ל-MAX_NOTE_TITLE בפייתון. Escape נתפס בשלב הלכידה ואינו מבעבע, כי בעמוד הארכיון Escape סוגר את הפתק הפתוח. חלון שני מבטל את הראשון דרך הסגירה של הראשון (cancelOpenArchiveDialog), ולא בהסרת הצמתים שלו: הסרה בלבד השאירה את ההבטחה שלו תלויה, ו-🗃️ של הפתק ההוא מת עד טעינה מחדש. לשני חלונות מגיעים במקלדת, כי אין מלכודת מיקוד.
ההודעה — הטוסט המשותף, עם קישור. window.ckToast (toast.js) קיבל options.link ({href, label}), ונטען עכשיו גם בלוח, בתצוגת Markdown, בדפדפן הריפו ובארכיון. href חייב להיות נתיב בתוך האתר — מתחיל ב-/ אחד, כי //host היא כתובת לשרת אחר — ואחרת הקישור פשוט אינו מוצג. הקישור בצבע הטקסט של הטוסט, כי צבע הקישור של הערכה כויל לרקע העמוד ולא לרקע של הטוסט.
העמוד (note_archive.html, notes-archive.js). שלושה מצבים נפרדים — טעינה (כרטיסי שלד), שגיאה עם ”נסו שוב“, וריק — כי טעינה שנכשלה אינה ”הארכיון ריק“. בזמן שפתק פתוח #archiveMain מסומן inert, והשכבה של הפתק יושבת מחוצה לו. הרקע של השכבה הוא אח של הפתק ולא אב: backdrop-filter הופך אלמנט ל-containing block של צאצאים fixed, ופתק במצב screen היה נמדד מולו. Escape של העמוד סוגר את הפתק רק אם רכיב בתוך הפתק לא טיפל בו קודם (defaultPrevented). הקישור העמוק (?note= ו-?thread=) נצרך פעם אחת ויורד מהכתובת, ונפתח גם אחרי ”נסו שוב“: הטעינה ו“נסו שוב“ עוברים באותה פונקציה (loadAndFollowLink), וקישור שהטעינה הראשונה נכשלה בו נשאר בכתובת עד שנצרך.
הכרטיס. סגנון ”רצועה“, והשם נחתך אחרי ארבע שורות (-webkit-line-clamp). כל רצף לטיני בשם עברי מבודד כרצף LTR משלו (titleRuns): בלעדיו /admin/mcp מאבד את הלוכסן שבתחילתו לצד השני של המילה — נמדד מול בקרה בלי הבידוד. הכרטיס הוא נייר של פתק: הצבע שלו הוא צבע הפתק, והדיו היא דיו הפתק, בכל ערכה. והוא <button>, ולכן נאבק בכלל ערכה: [data-theme="high-contrast"] button צובע כל כפתור שחור ב-!important, וסגנון inline מפסיד לו — הכרטיס יצא שחור, והדיו עליו. לכן הצבע מוצב על הכרטיס כטוקן (--archive-card-color) ונקרא בכלל !important שספציפי יותר מכלל הערכה, כמו הקישור בפתק (מערכת ערכות הנושא והטוקנים החדשה, ”שימושים נוספים וחריגים“). tests/test_note_archive_card_css.py גוזר את כללי ה-button מקבצי הערכות ודורש שהכלל יגבור על כל אחד מהם. הטקסט שעל רקע העמוד — הספירה ו“אין פתקים“ — יורש את צבע הטקסט של העמוד: --text-muted, ואפילו --text-secondary, ירדו על רקע העמוד מתחת ל-4.5:1 בחלק מהערכות (נמדד בדגימת פיקסל). השם בכתב יד תמיד, ולא לפי ההגדרה (הכרעת המשתמש): הגופן מוצהר בכלל של .archive-card-title ב-note-boards.css בלי .sticky-handwriting מעליו, ו-note_archive.html טוען אותו בלי תנאי (ensureHandwritingFont). עד אוקטובר 2026 השם ישב תחת המחלקה, ולכן כתב היד הופיע רק כשההגדרה הכללית של הלוחות דלוקה — המתג של לוח אינו מגיע לארכיון, כי הארכיון אינו לוח. applyStickyHandwriting אינה מתאימה כאן, כי היא מחילה גם את המחלקה, וזו הייתה מעבירה לכתב יד גם את הפתק הפתוח, שממשיך לפי ההגדרה. הכלל היחיד על השם — tests/test_note_archive_card_css.py; טעינת הגופן בהגדרה כבויה — tests/test_note_permalink.py; השם והפתק הפתוח בשתי ההגדרות, בכרומיום — tests/test_note_archive_browser.py.
מה לבדוק, ובמה
חיווט הלקוח (הרכבה, שמירה, החלפת יעד): מול דפדפן אמיתי. השוואת מחרוזות אינה מוכיחה שהפתק באמת נשמר ונטען.
גאומטריה של פתקי ריפו (איפה פתק חדש נולד, גלילת הקוד, הטווח לרוחב): מול העמוד
/repo/האמיתי, עם CodeMirror ובמגע —tests/test_repo_notes_browser.py. הבדיקות האלה אינן רצות ב-CI (ראו האזהרה בסוף החלק על חיפוש בתוך פתק), ולכן מי שנוגע במיקום או בהצמדה של פתקים מריץ אותן לפני PR.תפריט ⋮ (תפריט ⋮: הפעולות הנדירות, ומה קובע לאן הוא נפתח): המבנה והחיווט — איפה ⋮ יושב, מה בתפריט, הסגירה, המקלדת והפוקוס — בבדיקות שנפתחות ב-”תפריט ⋮:“ ב-
tests/sticky-notes-target.test.js, ב-node, ורצות ב-CI. כיוון הפתיחה, ההצמדה למסך בשתי הפריסות, מה מצויר מעל מה, והפינות של פתק ממוזער — ב-tests/test_sticky_note_menu_browser.py; והלוח ודפדפן הריפו האמיתיים, שבהם המשטח חותך — ב-tests/test_note_menu_pages_browser.py. שני האחרונים אינם רצים ב-CI.ארכיון (ארכיון: המנהל במצב ארכיון, העמוד וחלון השם): המנהל במצב ארכיון, 🗃️, חלון השם והמחיקה — בבלוק ”ארכיון“ ב-
tests/sticky-notes-target.test.js; דבק העמוד —tests/notes-archive.test.js; הקישור בטוסט —tests/toast.test.js; צבע הכרטיס מול כללי הערכות —tests/test_note_archive_card_css.py. כל אלה רצים ב-CI. הזרימה כולה בכרומיום, מול השרת ומונגו אמיתיים — לוח ← ארכיון, מרכוז, גודל בלי מיקום, מחיקה, קישור עמוק, מצב שגיאה, טלפון, וניגודיות בכל ערכה — ב-tests/test_note_archive_browser.py, שאינו רץ ב-CI.