עקרונות להוספת פיצ’ר לסטיקי-נוטס — הממשק בדפדפן: גאומטריה, אינטראקציה וניגודיות

העמוד הזה הוא חלק מ-עקרונות להוספת פיצ’ר לסטיקי-נוטס, ומחזיק את הפתק כווידג’ט בדפדפן: מיקום וגודל, אינטראקציה, וצבעים שנמדדים על הנייר. מה שנשמר במסד ועובר בין השירותים — ב-עמוד השרת, ומנוע הרינדור של הפתק — ב-עמוד מנוע המארקדאון.

עיגון שורה בפתקי ריפו: למה 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 נגזר מהרוחב, וחישובו לפי רוחב שכבר לא יוצג מצמיד את הפתק למקום שנכון לגודל אחר. זה בדיוק המצב שבו פתק ”נדחף לקצה וממשיך לגלוש“.

שלושת מצבי המיקום נבדלים במסגרת הייחוס שלהם, ולכן גם במה שמצטמצם ובאיזה ציר מוצמד:

מצב

מה מצטמצם

איזה ציר מוצמד

למה

surface

רוחב, לרוחב המשטח

X בלבד

המשטח גליל וגדל כלפי מטה, ולכן פתק גבוה נשאר נגיש בעוד שפתק רחב גולש ואין דרך להגיע לידית שלו.

anchored

רוחב, לרוחב אזור התצוגה

X בלבד

הפתק הוא absolute במרחב המסמך. ה-top נגזר מהעוגן ב-_updateAnchoredNotePosition, והצמדתו הייתה מנתקת אותו מהשורה שהוא מצביע עליה.

screen

רוחב וגובה, לאזור התצוגה

שניהם

הפתק הוא fixed מול אזור התצוגה, ולכן חריגה בכל ציר מוציאה אותו מהמסך לצמיתות.

המרווחים בענפי ה-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 (עד אוקטובר 2026 NOTE_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.

תפריט ⋮: הפעולות הנדירות, ומה קובע לאן הוא נפתח

ההתנהגות הנראית מתוארת ב-תפריט ⋮; כאן הנימוק.

בסוף שורת הפעולות — הכרעה של המשתמש, ולכן הוא הראשון שנחתך. שורת הפעולות אינה נשברת (#3478), ובפתק צר הקצה שלה נחתך. ⋮ יושב שם בכוונה: לתוכו נכנסות הפעולות שהכי פחות שכיחות, ומה שבתוכו הוא בדיוק מה שאפשר לוותר עליו. 🔔 עבר מהשורה לתפריט, ולכן מספר הכפתורים בשורה לא השתנה, והשורה אף התקצרה — המספרים ב-גודל של פתק חדש, והגיבוי של פתק בלי גודל. test_in_a_narrow_note_the_more_button_is_cut_first ב-tests/test_sticky_note_menu_browser.py דורש שמה שנחתך יהיה תמיד סוף השורה, ו-⋮ בתוכו.

רשימה אחת, והתפריט נבנה ממנה — NOTE_MENU_ITEMS ב-sticky-notes.js. לכל פריט אייקון, שם נגיש (label, שהוא גם ה-title), ופעולה. הפעולה היא פונקציה שמקבלת את המנהל ואת הפתק, ולא שם של מתודה במחרוזת: כך הקריאה למתודה כתובה ברשימה כמו כל קריאה אחרת בקוד, וחיפוש של שם המתודה, או שינוי שם שלה, מוצא גם אותה. פריט חדש נכנס כשורה ברשימה, בלי מנגנון נוסף. בחירה בפריט סוגרת את התפריט לפני שהפעולה רצה, ובלי להחזיר את הפוקוס ל-⋮ — הפעולה עשויה לפתוח משטח משלה, כמו חלון התזכורת, והפוקוס שייך לשם. חלון התזכורת אכן לוקח אותו: _openReminderModal מעביר את המיקוד לכפתור הקיצור הראשון, גם כשהחלון כבר היה פתוח, ובסגירה מחזיר אותו ל-⋮ של הפתק — הפריט שנלחץ ירד מה-DOM יחד עם התפריט, ובלי זה המיקוד היה נופל ל-body.

ילד של הפתק, כמו רשימת ”+N“ של השרשורים. התפריט זז עם הפתק ונמחק איתו, וממוקם absolute — ולכן אינו משנה את המלבן ש-_notePayloadFromEl קורא ממנו את המיקום (אותו תנאי כמו בתיבת החיפוש, חיפוש בתוך פתק: מודול הדגשה אחד, ומה שהפתק מוסיף עליו). הוא נושא sticky-note-outside ומדליק את .is-unclipped דרך _syncNoteClipping, כי בפתק ממוזער הוא יוצא מתחת לכותרת; ופתיחתו מעלה את הפתק לקדמת הערימה (_raiseToFront), כי ה-z-index שלו כלוא בתוך הפתק, ופתק שכן שמצויר אחריו היה מכסה אותו. תפריט אחד לכל המנהל: פתיחה סוגרת תפריט פתוח בפתק אחר.

חשוב

צירוף חדש: פתק ממוזער שאינו חותך. החיפוש והשרשורים מושבתים בפתק ממוזער, ולכן עד התפריט פתק ממוזער לא הפסיק לחתוך אף פעם — והכלל שמעגל את הפינות של הכותרת (.is-unclipped:not(.is-searching) .sticky-note-header) מעגל רק את העליונות, כי מתחת לכותרת יש תמיד גוף. בפתק ממוזער הכותרת היא כל הפתק, ובהשוואת פיקסלים בכרומיום רקע הכותרת יצא מהקשת בפינות התחתונות ברגע שהתפריט נפתח. הכלל .is-minimized.is-unclipped מעגל את ארבע הפינות, ו-test_a_minimized_note_reveals_nothing_while_its_menu_is_open נופל בלעדיו. מי שיאפשר בעתיד מצב אחר של .is-unclipped בפתק ממוזער מקבל את הכלל הזה בירושה.

המיקום — כלפי מטה, ולמעלה רק כשאין מקום (הכרעה של המשתמש), ומצמצמים לפני שמזיזים (H7). _layoutNoteMenu מגביל קודם את הרוחב למלבן שמותר פחות השוליים (NOTE_EDGE_GAP); אחר כך בוחר כיוון — מטה, ומעלה רק כשהתפריט אינו נכנס מתחת ל-⋮ ומעליו יש יותר מקום; אחר כך מגביל את הגובה למקום בכיוון שנבחר; ורק אז ממרכז מתחת ל-⋮ ומצמיד אל תוך המלבן. החשבון הוא הפרש בין מלבנים שנמדדו, ולכן נכון ב-RTL וב-LTR כאחד, ו-left נכתב פיזי בכוונה — ההערה ב-CSS מסבירה למה לא inset-inline-start. נמדד בשתי הפריסות: test_the_menu_is_centered_under_the_button_in_both_layouts ו-test_near_the_edge_the_menu_is_pulled_back_onto_the_screen. התפריט ממוקם מחדש בכל גלילה ובשינוי גודל החלון, באותו פריים שבו ממוקמות חלונית השרשור ורשימת ”+N“.

מה חותך את הפתק — לפי הכלל של CSS, ולא לפי הנחה על הקונטיינר (_noteMenuBounds). overflow שאינו visible חותך את הצאצאים, חוץ מאלה שה-containing block שלהם הוא אב שלו או אזור התצוגה (CSS 2.1, סעיף 11.1.1). לכן פתק fixed — מצב screen — נחתך רק במסך, ופתק absolute נחתך בידי ה-containing block שלו, שאותו מחזיר offsetParent (CSSOM View), ובידי כל אב שלו שחותך. הגרסה הראשונה חתכה תמיד עם הקונטיינר, וזה נמדד כשגוי: על משטח שאינו חותך, תפריט של פתק ממוזער התהפך כלפי מעלה למרות שמתחתיו היה מקום במסך. בעמודים האמיתיים הקונטיינר כן חותך — .board-surface הוא overflow: auto, ו-#code-viewer-container הוא overflow: hidden — וזה נגזר כאן מה-CSS ולא נקבע מראש. בדפדפן הריפו המסגרת נמשכת מתחת לתחתית המסך (נמדד בכרומיום 153 ב-24.10.2026: bottom ‏844 בטאבלט של 1280×800), ולכן שם המסך הוא שקובע בתחתית.

הערה

חישוב, ולא חיישן — בשונה מתיבת החיפוש ומהבועות (חיפוש בתוך פתק: מודול הדגשה אחד, ומה שהפתק מוסיף עליו). שם IntersectionObserver מכריע, כי הוא סופר חיתוך של כל אב. אבל ההודעה שלו מגיעה כמשימה שנכנסת לתור אחרי עדכון הרינדור (W3C Intersection Observer, 3.2.4, ”Queue an Intersection Observer Task“), כלומר אחרי שהתפריט כבר צויר במקום הראשון — והוא היה נפתח כלפי מטה ואז קופץ למעלה. התפריט נולד בלחיצה, וההכרעה צריכה להיות מוכנה לפני הציור הראשון שלו. החישוב כאן הוא אותו כלל שהדפדפן מחיל, ו-test_inside_a_clipping_ancestor_the_menu_flips_before_the_screen_edge בודק אותו מול אב שחותך מעל ה-containing block.

מקלדת ואירועים. Enter על ⋮ פותח ומפקס את הפריט הראשון; החיצים, Home ו-End עוברים בין הפריטים; Escape סוגר ומחזיר את הפוקוס ל-⋮; Tab סוגר ומחזיר את הפוקוס ל-⋮ בלי לבטל את ברירת המחדל, כך שהמעבר ממשיך מ-⋮ ולא מאלמנט שכבר ירד מה-DOM. מקשים בתפריט אינם מבעבעים, מאותה סיבה כמו בתיבת החיפוש: בדפדפן הריפו Escape ברמת העמוד סוגר חלוניות. לחיצה מחוץ לתפריט ומחוץ ל-⋮ סוגרת אותו (pointerdown על document, בשלב הלכידה, כמו רשימת ”+N“).

גודל הטקסט: טוקן אחד, ולמה דווקא בפיקסלים

הצד של הנושא הזה בשרת: העדפה שיש לה גם מצב ”רק במכשיר זה“ ו-גודל הטקסט בעמוד ההגדרות: מה שמסוכן יושב סביב הבורר.

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 — הרג’קס שקרא פעם את המארקדאון בשורה של פתק (קוד בשורה: יחידה אחת, שקודמת לכל מפריד) — שם הוספת חלופה באמצע הייתה מזיזה בשקט את כל אינדקסי הקבוצות שאחריה.

שרשורי תגובות: השרת והממשק בוובאפ

ההתנהגות הנראית — בועות, חלונית, כפתורים — מתוארת ב-שרשורי תגובות על פתקים, והמודל, כללי הכתיבה וצד ה-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.