עקרונות להוספת פיצ’ר לסטיקי-נוטס

summary:

המלכודות החוזרות של הפתקים הדביקים: יעד יחיד ב-build_note_target, מכסה fail-closed והפטור-לאדמין, flush לפני פעולה הרסנית, אינדקס ממוספר שנבנה לפני שמפילים, נורמליזציה בשני הקצוות, יתומים בקריאה, ועדכון עמוד המשתמש.

הפיצ’ר של הפתקים הדביקים צובר משטחים לאורך זמן — קובץ ב-CodeKeeper ← לוח ← רינדור מארקדאון ← קובץ בריפו ממורר. כל תוספת כזו נוטה לגלות מחדש את אותן מלכודות, כי הן אינן בקוד של פיצ’ר בודד אלא בחוזה המשותף. העמוד הזה מרכז אותן, כדי שהפיצ’ר הבא (או סוכן AI שממש אותו) יקרא אותן פעם אחת במקום לגלות אותן שוב בפרודקשן.

זהו עמוד כוונה — הוא מסביר למה הדברים בנויים כפי שהם. הסמכות היא תמיד הקוד; אם עמוד זה סותר את הקוד, הקוד צודק והסתירה היא ממצא לדיווח.

היעד: בדיוק אחד, ובשכבה טהורה אחת

פתק שייך ליעד אחד בדיוק — קובץ, לוח, או קובץ בריפו. הכלל הזה חי במקום יחיד, sticky_notes_target.build_note_target, וכל מסלולי הכתיבה עוברים דרכו. הוא אינו בדיקה בראוט שאפשר לשכוח: הוא בנאי שמקבל כוונה, בונה את שדות היעד, ומריץ ולידציה לפני ההחזרה — כך שקורא אינו יכול לייצר מסמך לא חוקי.

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

חשוב

השכבה הזו טהורה בכוונה — בלי Flask ובלי pymongo — כדי שגם ה-webapp, גם mcp_server וגם services יוכלו לייבא ממנה. אל תוסיפו לה תלות כבדה; ולידציה שחיה רק בצד אחד היא הבטחה שהצד השני מפר בשקט.

המכסה: fail-closed, והפטור-לאדמין שהורג מכסות בשקט

check_note_quota דוחה כשספירת הפתקים נכשלה (existing=None), ולא מניחה אפס. תקרה שנפתחת לרווחה בדיוק כשהמסד מתקשה אינה תקרה.

הפטור לאדמין מסוכן יותר ממה שהוא נראה. אם המשטח החדש נגיש לאדמינים בלבד — כפי שדפדפן הריפו נגיש — אז כל מי שמגיע אליו הוא אדמין, ומכסה שפטורה-לאדמין אינה נאכפת על אף אחד לעולם: קבוע מת שנראה חי. לפני שמעבירים is_admin למכסה, שאלו מה מטרתה. מכסה של צורת-תוכן (למשל ”עשרים פתקים על קובץ = זה כבר עמוד תיעוד“) חלה על כולם, ולכן נקראת עם is_admin=False תמיד. רק מכסת הגנת-משאבים נשארת פטורה.

חשוב

טסט שבודק מכסה חייב לרוץ עם is_admin=False. עם is_admin=True הפטור מקדים כל בדיקה אחרת, והטסט עובר בלי לבדוק כלום — בדיוק המלכודת ”טסט עובר, מכסה שלא מכסה“.

הערה

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

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

אינדקס ייחודי: ממוספר, ונבנה לפני שמפילים

כל אינדקס ייחודי חדש עובר דרך המנוע המשותף _ensure_versioned_unique_index: בונים את החדש, מאמתים אותו ב-index_information חוזר, ורק אז מפילים גרסה ישנה. מונגו אינו יודע לשנות שם של אינדקס, ולכן עדכון ”במקום“ מחייב את הסדר ההפוך — ובין ההפלה לבנייה אין ייחודיות כלל, ושתי בקשות מקבילות יכולות להכניס שני ערכים זהים. אז הבנייה החדשה נכשלת, והאכיפה נשארת מושבתת לצמיתות.

לכן השם נושא גרסה (_v1, _v2), ושינוי מפרט מעלה את המספר במקום לערוך את הקיים. הפונקציה מחזירה True רק אחרי הקריאה החוזרת — ערך החזרה של כתיבה אינו אימות.

חשוב

partialFilterExpression הוא מה שמדיר את שאר הסוגים מהאינדקס. אינדקס הלוח מסנן על board_id קיים, אינדקס הריפו על repo_name וגם repo_path קיימים — שני חצאי היעד, כי מסמך שנושא רק אחד מהם אינו יעד ריפו חוקי, ובלי הדרישה הכפולה הוא בכל זאת נכנס לאינדקס תחת שדה חסר. בלי הסינון, פתקים מסוגים אחרים נכנסים עם שדה חסר, חולקים ערך מפתח, ושני פתקים שונים עם אותו שם נדחים ב-E11000. את זה סטאב לא יכול לבדוק — create_index שלו מחזיר None — ולכן בדיקת הייחודיות חייבת לרוץ מול מונגו אמיתי.

אכיפה שנבנית רק בצד אחד היא הבטחה שהצד השני מפר בשקט

הסעיף שמעל עוסק ב**איך** בונים אינדקס אכיפה. זה עוסק ב**מי** בונה אותו, וזה כשל נפרד לגמרי: אינדקס תקין לחלוטין שנבנה רק באחד השירותים.

לפתקים יש היום שני כותבים — הוובאפ ו-mcp_server — ושניהם מחזירים duplicate_title על סמך דחייה של המסד. שירות שאינו בונה את האינדקס עדיין מבטיח את הייחודיות, ופשוט לעולם לא אוכף אותה: אין שגיאה, אין לוג, ושני פתקים עם אותו שם נכתבים. פריסה שבה רק ה-MCP רץ היא בדיוק המקרה הזה.

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

שני כללים נוספים שנגזרים מזה:

  • זוג דגלים עצמאי לכל אינדקס. דגל משותף נותן לכשל של האחד לחסום את הניסיון החוזר של השני, ולהצלחה של האחד להדליק אכיפה שלא אומתה עבור השני.

  • אינדקס אכיפה חדש נבנה במסלול הכתיבה, לא בכל קריאה. ההבטחה נאמרת רק שם, ולכן רק היא משלמת על אימותה. זה הכלל לאינדקס שמוסיפים מהיום; אינדקס הלוח הקיים אינו עומד בו — _ensure_title_index עדיין נקרא מ-_notes_coll ב-mcp_server/backend, כלומר גם ממסלולי קריאה. זה חוב קיים שקדם לכלל ולא יושר יחד איתו, כדי לא לערבב שינוי התנהגות בשירות חי עם הוספת יעד. אל תעתיקו ממנו.

הבדיקה שהעניין הזה קיים אינה יכולה להיות סטאב. create_index של סטאב מחזיר None ומרוצה מכל מפרט. הריצו את שני הבוטסטראפים על אותו אוסף במונגו אמיתי, בשני הסדרים, ובדקו ששניהם עוברים ושסט האינדקסים שנוצר זהה.

זהות לעומת נורמליזציה: אותה פונקציה בשני הקצוות

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

והיעד של הנורמליזציה אינו ”עקביות פנימית“ אלא התכנסות לצורה שכבר קיימת. נתיב פתק ריפו חייב להתכנס לצורה שבה repo_files שומר נתיבים, אחרת גילוי היתומים — שמשווה מול המניפסט הזה — יסמן קובץ קיים כמיותם.

יתומים: זיהוי בקריאה, לא בכתיבה

כשמשטח יכול ”לזוז מתחת לפתק“ (לוח שנמחק, קובץ שנעלם מהעץ), אל תזהו יתומים בסריקה תקופתית ואל תריצו update_many על מסלול קריאה. הפתק ממשיך להתקיים עם הזהות האחרונה הידועה, ומסומן כמיותם בעת הטעינה בלבד. וכשיש שתי רמות של ”נעלם“ (הקובץ נעלם / הריפו כולו נעלם), שתיהן צריכות תצוגה — אחרת הרמה החיצונית ”נעלמת בשקט“, כי אין לה מקום להופיע בו.

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

חשוב

כשל שאילתה נבדל תמיד מ“אין“. מניפסט שלא נקרא אינו ”אין יתומים“, ורשימת מראות שלא נקראה אינה ”הריפו נעלם“ — שניהם מדווחים unknown, כי קריאה שנכשלה אינה ראיה על מצב העולם.

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

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

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

לכן ההגדרה חיה היום במקום אחד, note_reminder_state, והוא טהור מאותה סיבה ש-sticky_notes_target טהור: שלושה מודולים שואלים ”האם התזכורת פעילה“ — הראוטים של הפתקים, הדשבורד, ושולח הפוש — והתשובה חייבת להיות אחת. acknowledge_fields מחזירה את שני השדות, ולכן מי שסוגר תזכורת אינו יכול לכתוב אחד ולשכוח את השני; אין לו את המילון החלקי בכלל.

שתי מלכודות שנגזרות מזה, ושתיהן כבר נשכו:

  • ``status`` לבדו לא סינן כלום. בלי מצב סופי, status in (pending, snoozed) היה נכון על כל מסמך באוסף — מי שסינן לפיו קיבל את הכול וחשב שסינן. עמודת lifecycle בלי מצב יציאה היא עמודה קבועה.

  • שתי פעולות סותרות על אותה תזכורת. ה-Service Worker הריץ snooze ומיד אחריו ack על אותה לחיצה, כי מסלול הפתיחה כולל בכוונה גם את snooze_10 כפולבק למיפוי כפתורים שגוי. הדחייה נקבעה ובוטלה באותה נשימה, והתזכורת לא חזרה. כשיוצאות שתי קריאות מצב מאותו אירוע, הכריעו ביניהן בקוד — לא בסדר ההגעה.

  • אישור בלי מועד סגר את התזכורת הלא נכונה. ack התאים על (user_id, note_id, ack_at) בלי לומר איזו תזכורת, ו-set_note_reminder עושה upsert על אותו מסמך — כך שהתראה שישבה במגש מאתמול סגרה את התזכורת שנקבעה מחדש למחר. עכשיו ההתראה והחלונית נושאות את remind_at של המועד שנורו עליו, הלקוח שולח אותו בגוף בקשת ה-ack, והפילטר תופס רק מסמך שעדיין נושא אותו (note_reminder_state.parse_remind_at הופך אותו למפתח ברזולוציית מילישניות, כמו שה-BSON שומר). השדה אופציונלי: לקוח ישן ששולח note_id בלבד מאשר בלי קשירה, כמו קודם; מחרוזת שאינה ISO, או מועד שמחוץ לטווח, היא 400 invalid_remind_at. תשובת השרת לא השתנתה — {"ok": true} בלבד, בלי remind_at. פעולה על ישות שיש לה ”מופעים“ חייבת לנקוב במופע. ומאותו מודול מגיעים גם שדות הכתיבה של הדריכה והדחייה (armed_fields, snoozed_fields): קבוע מצב שמשתנה משנה כך גם מה נקרא וגם מה נכתב.

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

וכלל המניפסט שבסעיף היתומים חל גם על מסלולי הבועה: שאילתה שנכשלה עונה 500, לא רשימה ריקה. ok:true, count:0 על מסד שלא נקרא הוא בדיוק ה“אין“ שהכלל אוסר — הבועה אמרה שיש, והחלונית אמרה שאין.

הדגימה: כשל אינו ”אין“, ו-401 אינו כשל

בלוק הדגימה ב-base.html (initStickyRemindersIndicator) שואל את השרת מתי התזכורת הבאה מבשילה ונרדם עד אז, בין דקה לחצי שעה. וכשיש בועה על המסך השרת עונה לכל היותר חמש דקות — ומוקדם יותר כשתזכורת נוספת מבשילה — כדי שניקוי ממכשיר אחר ומונה שמשתנה ייראו תוך כדי ולא אחרי חצי שעה. הכלל של היתומים — קריאה שנכשלה אינה ראיה על מצב העולם — חל גם עליו.

כל כשל הוא ”לא ידוע“, ולכולם מונה אחד ו-backoff אחד. תשובת שגיאה, שגיאת רשת, JSON פגום ו-timeout נכנסים כולם לאותה פונקציה: המונה עולה, ההמתנה מכפילה את עצמה מדקה עד התקרה, והיא נכתבת לאותו __stickyRemindersBackoffUntil שמשרת את 429 — כך ש-visibilitychange מכבד אותה בלי לדעת עליה. הצלחה מאפסת את המונה; בלי האיפוס, מצב הדיכוי מהתקלה הקודמת היה מרעיל את החלון הבא. הגרסה הקודמת חזרה כל דקה לנצח על כל כשל, ונרדמה חצי שעה על שגיאת רשת — שני הקצוות הלא נכונים, ודווקא כשהשרת נופל. והבועה שכבר הוצגה נשארת: היא המצב האחרון הידוע, וכשל אינו ראיה שהתזכורות נעלמו; רק תשובה שאומרת ”אין“ ו-401 מסירים אותה. ‏429 אינו כשל ואינו ”אין“: השרת נוקב בעצמו בהמתנה (Retry-After, ולפחות רבע שעה), היא נכתבת לאותו חלון, המונה אינו זז, והבועה נשארת. וכל כשל נרשם בקונסול הדפדפן — מספר הכשל, ההמתנה הבאה והסיבה — כי כשל שקט נראה בדיוק כמו ”אין תזכורות“. ובצד השרת כל 500 במסלולי התזכורות משאיר עקבה בלוג עם ה-traceback (_failed), כי הלקוח הופך אותו ל-backoff שקט וזה האות היחיד, בעוד קלט פסול — דקות שאינן מספר שלם, גוף שאינו אובייקט — הוא 400 בלי לוג, כי טעות של הלקוח אינה כשל של השרת; וכרטיס הדשבורד מציג ”לא ידוע“ ולא 0 כשספירת התזכורות נכשלת, כי מאז שהמונה נכון אפס הוא גם הערך הבריא.

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

ה-timeout יושב בלקוח, כי בשרת אין אחד. ‏worker של gevent מודד את השתיקה של ה-worker ולא את אורך הבקשה (Sticky Notes Warmup – פתרון ביצועים משולב), וחיבור half-open — מעבר רשת בנייד, NAT שנפל — אינו מחזיר שום חבילה. fetch בלי סיגנל היה תלוי לנצח, ואיתו השרשרת כולה, כי היא דורכת את הטיימר הבא רק כשהבקשה הנוכחית נגמרת. AbortSignal.timeout נדחה עם TimeoutError ונופל לאותו מסלול כשל; בדפדפן שאינו מכיר אותו התקרה נבנית מ-AbortController — נפילה-לאחור על היעדר יכולת, לא על כשל, ובלי מסלול גרוע יותר: בלי סיגנל בכלל, בקשה תקועה הייתה משאירה את הגארד inFlight דלוק לנצח, והפוקוס לא היה מציל.

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

רינדור בתצוגה: innerHTML אף פעם לא על טקסט לא-מהימן

טקסט של משתמש או של סוכן לעולם אינו נכתב דרך HTML גולמי. כל צומת שנושא תוכן כזה נבנה עם createElement ו-textContent, ותכונות של קישור נכתבות עם setAttribute אחרי אימות סכימה. לכן זריקת <script> מוצגת כטקסט, ואל תכניסו ספריית מארקדאון שפולטת מחרוזת HTML. תבנית סטטית לגמרי, בלי שום קלט משתמש (למשל שלד מודאל התזכורת), מותרת דרך innerHTML — הגבול הוא הקלט, לא ה-API. הכלל המוחלט הוא על טקסט לא-מהימן, לא על השיטה.

עיגון שורה בפתקי ריפו: למה anchored אינו זמין

anchored מחייב עוגן שורה יציב. דפדפן הריפו משתמש ב-CodeMirror, שמרנדר רק את השורות שבתחום הנראה; לכן אין אלמנט DOM יציב שאפשר להיקשר אליו עבור שורה מחוץ למסך. פתקי ריפו תומכים רק ב-surface וב-screen. תיאור ההתנהגות למשתמש נמצא ב-פתקים דביקים (Sticky Notes).

סדר: flush לפני כל פעולה הרסנית

תור השמירה עובד עם debounce, ולכן עריכה שנעשתה זה עתה עדיין ממתינה בו. כל פעולה שעלולה למחוק את התור — פירוק המנהל בהחלפת יעד, סימון צ’קבוקס שמפעיל כתיבה משלו — חייבת לרוקן את התור תחילה. אחרת העריכה האחרונה נעלמת בלי שום סימן. זו תקלת סדר שכבר נתפסה כאן פעמיים (_flushFor לפני ה-toggle; destroy שמרוקן לפני שהוא מפרק).

אינדקס המשימות: התאמה מלאה בין שרת ללקוח

ספירת המשימות בצד השרת (sticky_notes_tasks) והאינדקס בצד הלקוח חייבים להתקדם זהה — כולל בתוך גדרות קוד. אם השרת סופר שורת - [ ] בתוך גדר והלקוח מדלג עליה, לחיצה על צ’קבוקס אמיתי מסמנת את המשימה הלא-נכונה. זו החלטה מתועדת ומכוסה בטסט, לא צירוף מקרים; שמרו עליה בכל שינוי שנוגע ברינדור המשימות.

מה לבדוק, ובמה

  • מודל טהור (יעד, נורמליזציה, מכסה): טסטי יחידה, בלי מסד. כל טסט חדש נבדק במוטציה — ביטול התיקון חייב להפילו.

  • אינדקס ייחודי ופילטר חלקי: מול מונגו אמיתי בלבד. סטאב מחזיר None מ-create_index וכל בדיקת כפילות עוברת בו, גם אם האינדקס מעולם לא נוצר.

  • חיווט הלקוח (הרכבה, שמירה, החלפת יעד): מול דפדפן אמיתי. השוואת מחרוזות אינה מוכיחה שהפתק באמת נשמר ונטען.

טבלאות: בלוק רב-שורתי בתוך מנוע של שורה-לשורה

מנוע הרינדור בנוי על חוזה אחד: כל שורת מקור מייצרת בדיוק אלמנט תצוגה אחד עם data-charOffset משלו. _enterEditFromView מוצא את השורה שנלחצה דרך closest('.sticky-task-line') וקורא ממנה את ההיסט, וכך לחיצה מחזירה לעריכה בשורה הנכונה.

טבלה היא הבלוק הראשון שמקפל כמה שורות מקור לאלמנט אחד, ולכן היא הראשונה שיכולה לשבור את החוזה. הפתרון: ``<tr>`` הוא שורת המקור — הוא נושא את sticky-task-line ואת ההיסט שלו, בדיוק כמו div רגיל. שורת המפריד נצרכת ואינה מייצרת <tr> (אין לה מה להציג), אבל ההיסט מקודם גם עליה — אחרת כל מה שאחרי הטבלה מוסט באורך שורה שלמה.

מכאן גם הכלל לבלוק רב-שורתי הבא: מותר לקפל, בתנאי שכל שורת מקור נשארת אלמנט אחד שנושא את ההיסט שלה. הבדיקה שמגינה על זה נמצאת ב-tests/sticky-notes-target.test.js בשני חלקים בכוונה — היסט של שורה בתוך הבלוק, והיסט של השורה אחריו. הראשון תופס מיפוי שגוי, השני תופס חשבון מצטבר שגוי, ובאג בכל אחד אינו מפיל את השני.

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

שתי עמודות לפחות — סטייה מכוונת מ-GFM. לפי התקן grep foo | ואחריו --- היא טבלה חוקית של עמודה אחת. בפתק זו כמעט תמיד פקודה שאחריה קו מפריד, ולכן הזיהוי דורש מקף אנכי פנימי. המחיר הוא שטבלת עמודה אחת אמיתית תרונדר כטקסט, וזה קטן בהרבה מהחלופה.

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

רוחב: הטבלה יושבת בעטיפה עם overflow-x ובלי max-width. עם max-width: 100% היא נדחסת לרוחב הפתק והעמודות מתמעכות במקום להיגלל — נמדד בדפדפן, לא הונח.

קינון רשימות: שאלה אחת, מקום אחד, ומקור מדיד

הסעיף על טבלאות מנסח את הכלל ”מהו מפריד — תשובה אחת בלבד“. הקינון הוא המופע השני של אותו כלל, ושם הוא נשך בצורה שונה: - [ ] א היא גם שורת משימה תקפה וגם פריט רשימה תקף. הסיווג ישב פעם בתוך ענף ה-wantMd בלבד, כלומר אחרי שהשורה כבר התפצלה לענף המשימה — ולכן שורות משימה לא הזינו את מחסנית העומק כלל, ופריט שאחרי - [ ] א יצא ברמה 0 במקום 1. לכן הקריאה ל-_classifyLine עלתה לראש הלולאה: שורת מקור נשאלת פעם אחת מהי, לפני שהיא נכנסת לענף כלשהו. מי שיוסיף כאן ענף נוסף — צריך להזין את המחסנית, לא לעקוף אותה.

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

המקור נמדד, לא נזכר. markdown-it@14.1.0 כבר רץ בריפו ומרנדר את תצוגת ה-Markdown, ולכן הוא הצרכן שהפתק צריך להסכים איתו — אותו טקסט מקונן חייב להיראות אותו הדבר בשני המקומות. שלושת הכללים נלקחו מהקוד שלו: עמודת התוכן והכלל שרווחים מעבר לארבעה נספרים כאחד (lib/rules_block/list.mjs), הסגירה לפי הזחה קטנה מעמודת התוכן (אותו קובץ), וטאב שמתקדם לתחנה שמתחלקת בארבע (lib/rules_block/state_block.mjs). הקצוות — שורה ריקה, שתי שורות ריקות, כותרת, קו מפריד, וכל צירוף של גדר ובלוק מוזחים ולא מוזחים — הורצו דרכו בפועל, ולכן הם עובדה ולא הנחה. הגלגול של התבליט לפי עומק נמדד באותה צורה: getComputedStyle(ul).listStyleType בכרומיום.

גבול בלוק סוגר לפי ההזחה של עצמו — כלל אחד, בלי ענף לכל סוג. _closeListsAbove היא כל התשובה: היא מקבלת הזחה ומוציאה מהמחסנית כל פריט שההזחה כבר אינה בתוכו. שורת רשימה, כותרת, ציטוט, קו מפריד, טבלה, שורת טקסט ושורת גדר פותחת — כולן עוברות דרכה, ולכן בלוק בעמודה 0 סוגר את הרשימה ובלוק שמוזח לתוך הפריט נשאר בתוכו.

הגרסה הראשונה הפרידה ביניהם, ושתי ההתנהגויות יצאו שגויות בכיוונים הפוכים: שורת גדר לא נגעה במחסנית כלל, ולכן גדר בעמודה 0 השאירה את הרשימה פתוחה ורשימה חדשה שמוזחת אחריה הוצגה כתת-רשימה; וכל בלוק אחר הריץ listStack.length = 0, איפוס גורף שהתעלם מההזחה של עצמו, ולכן כותרת מוזחת בתוך פריט סגרה אותו. ריוויוור תפס את החצי הראשון; המדידה מול markdown-it מצאה גם את השני. הכלל האחיד קצר משתי ההתנהגויות שהוא החליף.

הערה

הגדר נכנסת לכלל דרך שורת הפתיחה בלבד, ומבנית ולא בדגל. _closeListsAbove מקבלת רק שורות שמחוץ לגדר, כי הבלוק כולו נצרך כיחידה ב-_appendCodeBlock — שורות התוכן ושורת הסגירה כלל אינן מגיעות ללולאה. שורת הפתיחה קוראת לכלל במפורש מהענף שלה, בדיוק כמו הטבלה.

קודם זה היה מושג בדגלים inFence/isFenceLine ובהחרגות מפורשות. הם נמחקו, וההסבר למה — ומה הם עלו — נמצא בסעיף על בלוק הקוד. ההתנהגות עצמה לא השתנתה: רק הזחת שורת הפתיחה קובעת, וזה מכוסה באותה בדיקה בדיוק.

חשוב

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

שתי סטיות מכוונות, ולא אחת.

הראשונה — המשך פסקה עצל. ב-markdown-it שורת טקסט או טבלה בעמודה 0 מיד אחרי פריט היא המשך הפסקה שלו, והרשימה ממשיכה. כאן היא סוגרת. הנימוק הוא החוזה של המנוע: כל שורת מקור היא שורת תצוגה נפרדת עם ההיסט שלה, ואין בו מושג של המשך פסקה — ולכן ”המשך“ היה מצב שאי אפשר לייצג. כמו דרישת שתי העמודות בטבלה, זו סטייה שנבחרה ותועדה, לא פער שהתגלה. היא גם אינה עולה ענף: הזחה 0 סוגרת ממילא לפי הכלל האחיד, ולכן הסטייה נופלת מאליה.

השנייה — גדר שנסגרת מוזחת החוצה. אצלנו קובעת שורת הפתיחה בלבד, וב-markdown-it הרשימה כבר הסתיימה בשורת הסגירה. ההתנהגות הנראית מתוארת ב-פתקים דביקים (Sticky Notes); כאן רק הנימוק. הפער אינו במחסנית, ולכן גם לא ייסגר שם. בתקן גדר סוגרת שמוזחת מתחת לעמודת התוכן של המכולה אינה סוגרת את הגדר כלל — היא מסיימת את פריט הרשימה, והתו נקרא כפתיחת גדר חדשה ברמה העליונה שבולעת את כל שאר הפתק לבלוק קוד. אצלנו התאמת הגדרות נאיבית בכוונה: כל שורת גדר מתהפכת, בלי מודעות למכולה.

הערה

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

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

ההזחה היא CSS, והחוזה לא נגע. אין קיפול של שורות לתוך <ul>: כל שורת מקור נשארת div.sticky-task-line אחד עם ה-charOffset שלה, ומה שנוסף הוא מחלקת is-depth-N בלבד. המחלקה יושבת על .sticky-task-line ולא על .sticky-md-li, כי זה האלמנט המשותף לשורת רשימה ולשורת צ’קבוקס — כלל אחד מזיח את שתיהן, ואין שני מקומות שיכולים להיסחף. ההזחה נכתבת ב-padding-inline-start: כלל פיזי היה מזיח מהצד הלא נכון בפתק בעברית.

ומה שאסור שיזוז מזה: הסידור. sticky_notes_tasks בשרת סופר כל שורת - [ ], כולל מוזחת, ולכן ההזחה חייבת להישאר תצוגה בלבד. שינוי שיגרום לצד אחד לדלג על שורה מוזחת מחזיר בדיוק את הבאג שהסעיף ”אינדקס המשימות“ מתעד — לחיצה שמסמנת משימה אחרת.

חשוב

מדידה בדפדפן, לא הסקה מה-CSS. ההזחה נמדדה בכרומיום ב-RTL על שלוש רמות, וריצת בקרה על הקוד שלפני התיקון הראתה את כל השורות על אותו קו בדיוק. Playwright אינו בתלויות הפרויקט ולכן ההארנס אינו נשאר בריפו; מה שנשאר הוא שומר טקסטואלי ב-tests/sticky-notes-target.test.js שקורא את קובץ ה-CSS ומוודא שההחלטה לא בוטלה — הטוקן קיים, חמשת הכללים קיימים, כולם מפנים לטוקן, כולם לוגיים, והמחלקה על שורת המקור. השומר שומר על המסקנה של המדידה; הוא אינו מודד מחדש, וכל טענה בו מכוסה במוטציה.

בלוק קוד: כשצריכה של בלוק שלם מוחקת שלושה סייגים

הגדר רונדרה פעם שורה-שורה בתוך הלולאה, מאחורי שני דגלים — inFence ו-isFenceLine. הם עבדו, אבל הם חייבו שלושה סייגים נפרדים במקומות מרוחקים: חישוב מוקדם של isFenceLine לפני בדיקת הטבלה, ההבחנה החדה בין !inFence ל-!inCode בכלל המחסנית, והחרגת שורת הסגירה. כל אחד מהם היה נכון, לכל אחד מהם הייתה הערה משלו, וכל אחד מהם היה דבר שאפשר לשכוח.

_appendCodeBlock צורכת את הבלוק כיחידה, ומכאן ש**הלולאה לעולם אינה נמצאת בתוך גדר** — שלושת הסייגים נמחקו יחד עם הדגלים. החתימה זהה ל-_appendTable בכוונה: שתי תשובות שונות לאותה שאלה (”כמה שורות בלעתי ומה ההיסט אחריהן“) היו מזמינות אתר קריאה שמטפל באחת ושוכח את השנייה.

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

חשוב

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

שם השפה מוצג, כי הסתרת הגדר היא הסתרה ולא איבוד. הוא נגזר מה-info שאחרי הגדר, במילה הראשונה בלבד — כמו ש-markdown-it גוזר אותו. הוא טקסט שהמשתמש הקליד, ולכן textContent.

מה שהסדר בלולאה אוכף, ומה שהוא לא

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

  • מול הטבלה הסדר הוא ההגנה. ```sh | x היא גם פותחת גדר וגם כותרת טבלה סבירה. הזזת בדיקת הטבלה לפני בדיקת הגדר מפילה בדיקה.

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

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

ההעתקה: מהמקור, לא מה-DOM

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

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

הערה

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

האייקון נבנה ב-createElementNS, והצורות זהות לאלה שבתצוגת המסמך. אותו אייקון בשני המסכים, כי זו אותה פעולה על אותו בלוק; אבל שם הוא מחרוזת שנכנסת ל-innerHTML, וכאן הכל צמתים. זה בדיוק מה ש-docs/webapp/language-icons.rst מנסח: מי שממילא בונה DOM אינו צריך innerHTML בכלל.

יישור לימין: אותה שאלה, ולכן אותה פונקציה

בלוק בלי שם שפה שיותר מ-30% מאותיותיו עבריות מוצג מיושר לימין. ההתנהגות עצמה מתועדת ב-פתקים דביקים (Sticky Notes); כאן מה שמסביבה.

אזהרה

‏``isHebrewMajority`` אינה שואלת על רוב, והשם הזה כבר הטעה פעם אחת. הסף הוא > 0.3, כלומר בלוק שבו 31% מהאותיות עבריות ו-69% לטיניות מתהפך. הניסוח הראשון בשני עמודי התיעוד אמר ”רוב“ — תיאור של השם ולא של ההתנהגות — ונתפס בריוויו. מי שמתעד כאן מתאר את מה שהקוד עושה, לא את מה שהפונקציה נקראת.

הפונקציה המשותפת היא אחת, והיא הקטנה מבין השלוש. utils/rtl-code.js חושף שלוש פונקציות, ורק isHebrewMajority שמישה כאן. applyRtlIfHebrew פותחת ב-block.closest('pre') והפתק אינו מייצר pre בכלל, ולכן היא הייתה מחזירה false תמיד — בשקט. hasExplicitLanguage קוראת class="language-…" שהפתק לעולם אינו כותב; שם השפה בפתק כבר נגזר משורת הגדר, ולכן נוספה לצידה צורה שמקבלת שם גולמי. זה אותו כלל שכבר מנוסח כאן על מפריד הטבלה ועל יצרן ההזחה: לפני שמוסיפים תחביר, שואלים מי כבר עונה על אותה שאלה.

חשוב

הקריאה מ-``window`` היא בדיוק הדרך שהסעיף על הפלטה דחה, וההכרעה כאן הפוכה במודע. ההתנגדות שם אינה ”המקור בפייתון“ אלא שקריאה כזו מחליפה תלות בבדיקה בתלות בסדר טעינת תבניות. ההתנגדות תקפה כאן מילה במילה, והיא מנוהלת ולא מוכחשת: שלוש עריכות תבנית, בדיקת חיווט, ותיקון מטמון. ומה שמצדיק את ההפוך הוא טיב הדבר המשותף — הפלטה היא טבלה, שבדיקה מצליבה מכסה במלואה; זהו אלגוריתם, ובדיקה מצליבה עליו היא דגימה ולא הוכחה. עותק שני היה הימור על כך שהדגימה מכסה בדיוק את מה שיסטה.

שתי מלכודות בבניית הרג’קס מחדש מתוך הרשימה, ושתיהן מכוסות במוטציה. ה-\b נשמר לכל איבר — בלעדיו texture נבלע ב-text ומתהפך. וההשוואה נשארת רגישה לרישיות, כמו הרג’קס: toLowerCase בצורה שמקבלת מחרוזת נראה כמו שיפור, והוא בדיוק הפער שהשיתוף בא למנוע — בלוק שהוצהר עליו Text היה מתהפך בפתק ולא במסמך. הרשימה המשותפת מבטיחה שהרשימה זהה; רק בדיקה מבטיחה שההשוואה זהה. הבדיקה מריצה את שתי הצורות על אותם שמות ודורשת תשובה זהה.

סכנה

תיקון ה-``?v=`` אינו ניקיון אלא תנאי. שתיים מהתבניות טענו את utils/rtl-code.js בלי מחרוזת המטמון, ושתיהן מארחות פתקים. מרגע שהקובץ משתנה, דפדפן עם עותק שמור היה מקבל גרסה בלי הפונקציה החדשה. היעדר ה-defer באותן שתיים כן מתועד ככוונה ב-base.html ונשאר; היעדר ה-?v= היה אי-עקביות, כי שתי התבניות האחרות כבר טענו איתה.

אזהרה

‏``dir=“auto“`` נשקל כאן ואינו יכול לעבוד, והסיבה הראשונה היא CSS ולא התנהגות. התכונה dir פועלת דרך גיליון הסגנון של הדפדפן, וכל כלל של המחבר גובר עליה. .sticky-md-cell אינו מצהיר direction כלל, ולכן תאי הטבלה יכולים להישען עליה; .sticky-md-pre כן מצהיר direction: ltr, ולכן dir על גוף הבלוק פשוט לא היה משפיע. מי שירצה בכל זאת dir נוגע באותו כלל CSS — כלומר אין כאן ”פתרון בלי CSS“.

והסיבה השנייה, על ההתנהגות: dir="auto" מכריע פר אלמנט לפי התו החזק הראשון, ובלוק קוד הוא יחידה אחת שפרושה על אלמנט לכל שורת מקור. בלוק עברי עם שורה אחת באנגלית היה מתיישר לשמאל באמצע. היחס הוא הכלל היחיד שנותן החלטה אחת לבלוק שלם.

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

הכלל ב-CSS הוא הצהרה אחת, ולא שתיים כמו במסמכים. שם text-align: right נחוץ כי כלל הבסיס מצהיר text-align: left; בפתק אין הצהרת text-align על השורה ולא על אף אב, ולכן הערך נשאר start ועוקב אחרי direction מאליו. נמדד בכרומיום: עם השורה ובלעדיה הטקסט נוחת באותו פיקסל בדיוק. שורה שאף מוטציה אינה יכולה להפיל היא שורה שאף בדיקה אינה מכסה — אותה מסקנה שכבר תועדה כאן על overflow-wrap ב-summary, והשומר ב-tests/sticky-notes-target.test.js אוכף גם את ההיעדר הזה. unicode-bidi: embed נשאר ואינו עובר ל-plaintext, שהוא בדיוק ההכרעה הפר-שורתית שהיחס בא להחליף.

חשוב

היעדר ``window.RtlCode`` הוא היעדר יכולת סטטי, והבדיקה עליו אינה הגנתיות. _syncTaskView כולו עטוף ב-try/catch אחד שמוחק את התצוגה בתחילתו וחושף אותה בסופו. חריגה באמצע נבלעת, ולכן TypeError כאן היה משאיר כל פתק שיש בו גדר בלי מארקדאון כלל — בלי שגיאה ובלי לוג. זו אותה החלטה בדיוק שכבר מתועדת ב-_containerSpec מול ADMONITION_TITLES, ובדיקת החיווט היא אותה בדיקה.

הערה

בדיקת החיווט טוענת גם שאין ``defer``, וזה מה שהופך את טענת הסדר לטענה. היא משווה מיקום בקובץ, ומיקום שווה לסדר הרצה רק כששני התגים אינם דחויים. שתי תבניות אחרות בריפו כן טוענות את הקובץ עם defer — וזו הצורה שתועתק בטעות. בלי הסעיף הזה תג דחוי היה עובר בזמן שהוא רץ אחרי המודול.

סכנה

שני תנאים מקדימים בבדיקות, ובלעדיהם הן עוברות מהסיבה הלא נכונה. ראשית, שני הסנדבוקסים של vm חייבים לטעון גם את utils/rtl-code.js; בלי זה window.RtlCode הוא undefined בבדיקה, כל טענה רצה דרך מסלול היעדר-היכולת, וכל בדיקה שמצפה ל-LTR מאשרת את עצמה. נמדד: הסרת הטעינה מפילה חמש בדיקות ומשאירה את השאר ירוקות — כלומר בדיוק מחצית התמונה. שנית, הדמה מממשת .class כחיפוש מחרוזת אחת, ולכן querySelector('.a.b') מחפש מחלקה בשם "a.b" ותמיד מחזיר null; טענה שלילית שכתובה כך עוברת גם כשהקוד מחיל את המחלקה תמיד. כל הטענות שואלות את העוטפת ישירות.

הערה

תיבת העריכה כבר יורשת ``rtl``, וזה לא מצב חדש לתקן. .sticky-note-content אינו מצהיר direction, ולכן הוא מקבל את זה של <html>. כלומר הפער בין התצוגה לעריכה היה קיים קודם ובכיוון ההפוך — תצוגה LTR מול עריכה RTL — והפיצ’ר הזה מצמצם אותו.

בלוקי אלרט: הבלוק הראשון שמכיל שורות אחרות

הסעיף על טבלאות ניסח את הכלל ”מותר לקפל, בתנאי שכל שורת מקור נשארת אלמנט אחד שנושא את ההיסט שלה“. האלרט הוא המופע השני שלו, ובכיוון הפוך: טבלה מקפלת כמה שורות מקור לאלמנט אחד, ואילו אלרט מכיל שורות שממשיכות להיות שורות תצוגה רגילות לכל דבר.

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

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

אזהרה

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

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

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

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

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

חשוב

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

התחביר אינו שלנו, ולכן הוא נמדד

markdown-it-container@4.0.0 מרנדר את אותם בלוקים בתצוגת המסמך ובתצוגה החיה, והוא הצרכן שהפתק צריך להסכים איתו — אותו יחס בדיוק שכבר קיים מול markdown-it בקינון הרשימות. הכללים נגזרו מ-node_modules/markdown-it-container/index.mjs והורצו מולו, לא נזכרו: הושוו רצפי פתיחה-וסגירה על עשרות קלטים, כולל שלוש רמות קינון בשני סדרי סגירה.

הכלל שהכי קל לטעות בו הוא סדר הסגירה בקינון: האינטואיציה אומרת ”סוגר את הפנימי“, ובפועל שורת סגירה סוגרת את ה**חיצוני ביותר** שאורך המרקר שלו קטן או שווה לה, ואיתו כל מה שבתוכו. הסיבה נראית רק בקוד המקור של התוסף: כל מכולה סורקת קדימה אחר שורת סגירה משלה, והמכולות שבתוכה נסגרות אוטומטית בסוף ההורה. _closeContainersAt היא כל התשובה, והמוטציה שמחליפה אותה ב“סגור את הפנימי“ מפילה בדיקה. היא אינה יודעת דבר על הסוג, ולכן היא נשארה ללא שינוי גם כשנוסף ::: details — ראו את הסעיף עליו למטה.

וכלל נוסף שנראה שולי ואינו: שורה שאין מעליה אף מכולה שאורכה מתאים אינה סגירה כלל ונשארת תוכן. _closeContainersAt מחזירה בוליאני בדיוק בשביל זה, ולא סוגרת ”מה שאפשר“.

הערה

[A-Za-z]+\b ולא \S+ בזיהוי שם הסוג. הוולידציה של שני הצרכנים היא ^<type>\b\s*(.*)$, ושתי ההתנהגויות שנגזרות ממנה נמדדו: ::: noteX נדחה כי הכמת החמדן בולע את כל האותיות ומקבל סוג שאינו קיים, ו-::: note2 נדחה כי אין גבול מילה בין אות לספרה. בלי ה-\b המקרה השני היה נחתך ל-note והופך לאלרט שהתוסף דוחה — כלומר הפתק היה מציג משהו שהמסמך אינו מציג.

שתי סטיות מכוונות, ושתיהן מאותו שורש

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

השנייה — הזחה אינה פוסלת מרקר. ב-markdown-it שורה שמוזחת ארבעה רווחים היא בלוק קוד מוזח, ולכן ␣␣␣␣::: note אינו פותח מכולה. לפתק אין מושג של בלוק קוד מוזח בכלל, ו-MD_FENCE_RE כבר מזהה גדר בכל הזחה — כלומר הכלל היה חריג יחיד בקובץ שכולו אינו מכיר אותו. הוא חל גם על שורת הסגירה, וגם שם נמדד: markdown-it אינו מקבל סגירה מוזחת בארבעה רווחים ומשאיר את המכולה פתוחה עד סוף המסמך, בעוד הפתק סוגר בה.

שתיהן נובעות מאותו שורש: הפתק אינו מיישם את מודל הבלוקים של markdown-it, אלא מסכים איתו על התוצאה. במקום שבו המודל עצמו הוא ההבדל, ההסכמה נעצרת — כמו שכבר קרה עם ”המשך פסקה עצל“ בקינון הרשימות.

מקור אחד לסוג, וכל הצרכנים קוראים ממנו

admonition-icons.js מחזיק את האייקון ואת התווית בעברית לכל סוג, והצרכנים קוראים משם: תצוגת המסמך, התצוגה החיה, והפתקים. לפני השינוי הזה האייקונים כבר היו משותפים אבל התוויות הוחזקו פעמיים, וכל אחת מהן יכלה לקבל סוג חדש בלי שהשנייה תדע.

המפה היא גם ההגדרה של ”מהו סוג מוכר“ בפתקים: מפתח שאינו בה אינו אלרט, והשורה נשארת טקסט. זה אינו קיצור אלא בדיוק מה שהתוסף עושה על סוג שלא נרשם — נמדד, ::: foo מרונדר כפסקה.

אזהרה

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

הצבעים: אין כאן פלטה

.admonition, .admonition-title, .admonition-content ו-.admonition-<type> מוגדרות ב-markdown-enhanced.css, שנטען ב-base.html ולכן זמין בכל שלושת המשטחים שמציגים פתקים. לכן אלרט בפתק נראה כמו אלרט בתצוגת המסמך לא במקרה ולא בהעתקה — זה אותו קוד. sticky-notes.css מחזיק רק כיול לפתק הצר: ריפוד, מרווחים וגודל אייקון.

אזהרה

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

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

חשוב

הכיול חייב לבטל את כלל המובייל, ובשתי תכונות לוגיות ולא בקיצור. markdown-enhanced.css מותח את האלרט אל מחוץ לשוליים במסכים צרים (margin: 0.75rem -0.5rem); בעמוד זה נכון, בפתק זה מוציא אותו אל מחוץ לגבול.

הגרסה הראשונה כתבה margin: .35em 0 ואחריו margin-inline: 0 כ“ביטול מפורש“, ומדידה בכרומיום הראתה שהשורה השנייה חסרת השפעה לחלוטין: הקיצור כבר איפס את הצדדים, והסרתה לא שינתה פיקסל. שורה שאי אפשר להפיל היא שורה שאף בדיקה אינה מכסה — והשומר הטקסטואלי שנכתב עליה היה שומר על כלום.

הפיצול ל-margin-block ול-margin-inline הופך אותה לנושאת משקל, כי המפל פועל לכל תכונה בנפרד גם כשהמקור הוא קיצור: בלי margin-inline הצדדים נופלים חזרה לערך שבכלל המובייל. נמדד בבקרה, פתק ברוחב 260 באזור תצוגה של 390 — 0px עם השורה, -8px בלעדיה, והאלרט חוצה את גבול הפתק. באזור תצוגה של 1024 אין הבדל כלל, ולכן המדידה במסך צר היא היחידה שמסוגלת ליפול.

הערה

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

::: details: הסוג היחיד שדורש אלמנט אחר, ולמה זה נעצר בו

כל סוגי המכולה שקדמו לו הם אותו div עם מחלקה אחרת, ולכן כל הקובץ נבנה סביב משתנה אחד בלולאה — לאן שורה נכתבת — ובלי שום ענף רינדור. details הוא הראשון ששובר את זה: הוא דורש <details> ו-<summary> נייטיביים.

נייטיבי, ולא ``div`` עם JS שמראה ומסתיר. הפתק עצמו כבר מתקפל ונפתח, ומנגנון קיפול שני משלנו היה מכונת מצב שנייה שנלחמת בראשונה. הנייטיבי מביא מקלדת, נגישות והדפסה בלי שורה אחת נוספת.

והענף מסתיים שם. _openContainer מחזירה את אותה רשומה בדיוק לשתי הצורות — { markers, content } — ולכן _closeContainersAt, מחסנית הרשימות, חשבון ההיסטים וסידור הצ’קבוקסים אינם יודעים שהסוג קיים. מי שיוסיף סוג נוסף אינו מקבל בזה היתר לענף משלו: ההיתר צר ומוגדר — אלמנט HTML אחר, ולא התנהגות אחרת.

הערה

details אינו ב-ADMONITION_TITLES, ובכוונה: המפה היא ההגדרה של ”מהו סוג אלרט“, והיא מוצלבת בבדיקה מול ADMONITION_ICONS ומול רשימת הסוגים בעמוד המשתמש. תווית ברירת המחדל שלו יושבת לצידה באותו קובץ, DETAILS_DEFAULT_TITLE, וממלאת בדיוק את אותו תפקיד: מקור אחד לשלושת הצרכנים. לפני השינוי הזה המחרוזת הופיעה שלוש פעמים בקוד.

הכרעת הקליק, וההיסט שאינו נוגע בו

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

סכנה

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

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

ובמקלדת הפטור רחב יותר מאשר בקישור, ולא באותה נקודה. אצל הקישור רק Enter יוצא לפני ה-preventDefault, ו-Space ממשיך אליו כדי לבלום גלילה. ב-<summary> שני המקשים מקפלים, והקיפול הוא פעולת ברירת המחדל — כלומר preventDefault היה מבטל אותו ומשאיר משתמש מקלדת בלי שום דרך לפתוח את הבלוק, וגם Space אינו גולל שם ממילא. לכן שניהם יוצאים.

ומצב הפתיחה חייב לשרוד רינדור מחדש. <details> נייטיבי מחזיק את מצבו ב-DOM בלבד, והתצוגה נבנית מאפס בכל _syncTaskView — כניסה ויציאה מעריכה, ו-_applyServerContent שרץ אחרי סימון צ’קבוקס. בלי שחזור, בלוק שנפתח נסגר תחת האצבע ברגע שמסמנים משימה שבתוכו, וגם אחרי כל עריכה.

סכנה

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

הראשונה — מקום הלכידה. היא ישבה מתחת ליציאה המוקדמת של _syncTaskView, ואותה יציאה מוחקת את התצוגה בעצמה. כלומר כניסה לעריכה מחקה את המצב לפני שמישהו קרא אותו. הלכידה חייבת לשבת מעל שתי המחיקות.

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

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

והלקח שרחב מהמקרה: ההיסט הוא זהות בתוך רינדור אחד. כל מה שצריך לשרוד שינוי טקסט צריך זהות אחרת.

הערה

היציאה המוקדמת משרתת שני מצבים שנראים זהים ואינם. editing אומר ”המשתמש עורך עכשיו“, ושם הזיכרון הוא כל מטרת המנגנון. !hasTask && !wantMd אומר ”לפתק אין מבנה בכלל“, כלומר אין בו בלוקים — וזיכרון ששורד אותו הוא מצב רפאים. נמדד: פתק עם בלוק פתוח, שתוכנו הוחלף בטקסט רגיל ואז בבלוק חדש לגמרי, פתח את החדש. הזיכרון נמחק בענף השני בלבד.

הזיכרון עצמו חי על רשומת הפתק (_getEntry), לצד מצב הביטול, ולא כתכונה על האלמנט — כדי שיהיה מקום אחד לכל מה שהפתק זוכר מעבר לרינדור.

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

בשאר הסוגים הכותרת היא תווית על תוכן שגלוי ממילא; אם תיפול, נשארת כרטיסייה עם תוכן. בבלוק מתקפל היא כל מה שקיים על המסך כשהוא סגור, ולכן תווית ריקה היא בלוק שאי אפשר לזהות ואי אפשר לדעת מה בתוכו. זו הסיבה שהיא נקראת מהמקור המשותף ולא נכתבת כמחרוזת רביעית, ושהיעדר הקבוע משאיר את השורה כטקסט במקום לבנות summary ריק.

אזהרה

שני הצרכנים לא הסכימו על חילוץ הכותרת, וזה נמדד לפני הכתיבה. תצוגת המסמך חילצה אותה ב-^details\s+(.*)$ בעוד שכל שאר הסוגים משתמשים ב-^<type>\b\s*(.*)$. ההבדל נראה רק כשאין רווח אחרי שם הסוג: ::: details.x קיבל שם את תווית ברירת המחדל, בעוד ש-::: note.x קיבל את הכותרת .x. הפתק, שקורא את שם הסוג עם גבול מילה, היה מציג .x — כלומר שני הצרכנים היו מציגים דברים שונים לאותו קלט.

התיקון הוא יישור ל-\b\s* בשני הצרכנים, ולא ענף בפתק. הושוו צורות הפרמטרים מול markdown-it-container@4.0.0: עם \s+ שתיים סטו, ואחרי היישור אף אחת לא.

ובאותו עמוד היה גם מסלול שני שאינו מסכים עם עצמו: enhanceMarkdownContainers ב-md_preview.html, שממיר פסקאות שנשארו אחרי הרינדור, זיהה סוג בלי \b — כלומר ::: detailsX היה נפתח שם כבלוק מתקפל בזמן שהתוסף דוחה אותו לגמרי. גם הוא יושר.

הערה

השומר על ”מקור אחד“ חיפש שם משתנה, ולכן פספס עותק שלם. באותו מסלול post-render שרדה מפת תוויות שלישית, מוטמעת בשורה אחת בתוך function defaultTitle. הבדיקה חיפשה DEFAULT_TITLES = { note:, והעותק נקרא m. מה שקבוע בכל עותק אינו שם המשתנה אלא הזוג note ← 'הערה', וזה מה שהשומר בודק היום.

הצבעים והכיול: אותה מלכודת של האלרט, ואותו כלל מובייל

.markdown-details, .markdown-summary ו-.details-content מוגדרות ב-markdown-enhanced.css שנטען ב-base.html, ולכן המבנה והמידות בפתק הם אותו קוד בדיוק כמו בתצוגת המסמך. שתי מלכודות נובעות מזה, ואחת מהן פספסתי.

המלכודת הראשונה היא המידות, והיא באמת אותה שורה של האלרט: .markdown-details, .admonition { margin: 0.75rem -0.5rem; } בכלל המובייל תופסת את שתי המחלקות יחד.

הערה

וההיקש מהאלרט הפיל כאן שורה מתה, בכיוון השני. הגרסה הראשונה הצהירה על ה-summary גם overflow-wrap: anywhere וגם min-width: 0, בהיקש מ-.sticky-md-alert-label. מדידה בכרומיום הראתה ששתיהן חסרות השפעה מוחלטת: overflow-wrap כבר מוצהר על .sticky-note-tasks ויורש, וה-summary אינו פריט flex אלא בלוק בתוך <details>. כותרת של 140 תווים רצופים בפתק ברוחב 260 יצאה באותו גובה בדיוק עם השורות ובלעדיהן. שתיהן ירדו, כי שומר על שורה מתה שומר על כלום — אותו לקח שכבר תועד למעלה על margin-inline, רק שכאן התשובה הייתה למחוק ולא לפצל.

לכן הכיול חוזר על אותה החלטה ומאותו נימוק: margin-block ו-margin-inline בנפרד ולא קיצור margin, כי הקיצור מאפס את הצדדים בעצמו והופך את margin-inline לשורה שאף מוטציה אינה יכולה להפיל. נמדד בכרומיום, פתק ברוחב 260 באזור תצוגה של 390: עם השורה margin-left/margin-right הם 0px והבלוק נצמד לתיבת התוכן של התצוגה; בלעדיה הם -8px והוא חוצה אותה. המדידה נעשית מול תיבת התוכן של .sticky-note-tasks ולא מול הפתק — היחס בין הפתק לתצוגה קבוע ואינו מה שהכלל הזה משנה.

סכנה

והמלכודת השנייה — הצבעים. הגרסה הראשונה של הסעיף הזה כתבה ”ולכן גם כאן אין פלטה חדשה“, וזה היה שגוי.

הכלל הנכון: קובץ CSS משותף אינו מספיק כשהמשטח קשיח ועוקף ערכה. משטח כזה חייב לספק ערך לכל טוקן שכל רכיב שמוצג בתוכו קורא. הפתק הוא נייר בהיר קבוע, ולכן רכיב שקורא טוקן ערכה מקבל ערך שנבחר לרקע אחר לגמרי. זה בדיוק מה שההערה בראש sticky-notes.css כבר קבעה על טקסט, ומה ש-מערכת ערכות הנושא והטוקנים החדשה קובע בסעיף החריגים — והסעיף הזה סתר את שניהם.

מה שנמדד בבקרה, על הקוד לפני התיקון: הטקסט בגוף הבלוק יורש #222 מ-.sticky-note-tasks ונכתב על --details-bg של הערכה. בערכה מיובאת כהה יצא יחס ניגודיות 1.09:1 — טקסט כהה על רקע כהה, בלתי קריא. ‏1.19 ב-dark, 1.32 ב-high-contrast, 2.03 ב-ocean. אחרי התיקון: 14.85 עד 15.48 בכל שמונה הערכות.

וגם הערכה הבהירה לא היתה תקינה — היא רק נראתה כך. החץ של ה-summary יצא שם 2.09:1, ובערכה מיובאת כהה אחרת 1.69:1 — מתחת לסף 3:1 של WCAG 1.4.11 לרכיבי ממשק. אחרי התיקון 3.83–4.00.

האלרט שרד, אבל לא כי העיקרון עבד: כלליו ב-markdown-enhanced.css אינם משתמשים ב-var() כלל, אלא בצבע ליטרלי לכל סוג. הבלוק המתקפל הוא הרכיב הראשון שמוצג בפתק ו**כן** קורא טוקני ערכה, ולכן הראשון שנשבר. העיקרון לא נבחן שם.

התיקון: sticky-note מצהיר על ששת הטוקנים שהרכיב קורא — --details-bg, --details-border, --details-hover, --summary-bg, --summary-color, --summary-arrow-color. אין !important ואין קרב ספציפיות: הצהרת הערכה יושבת על <html> ושלנו על .sticky-note, כלומר שני אלמנטים שונים, וההכרעה היא ירושה.

חשוב

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

ומדידה שנראית נכונה יכולה עדיין לא להיות מסוגלת ליפול. --summary-bg אינו צבע אלא גרדיאנט בכל שמונת בלוקי הערכה, והכלל הוא background: var(--summary-bg). קיצור background עם ערך תמונה מכניס אותו ל-background-image ו**מאפס את** background-color ל-transparent. כלומר בדיקה שמודדת background-color של ה-summary קוראת transparent גם על הקוד השבור — נמדד בכל שמונה הערכות — וזה נראה בדיוק כמו ”אין קופסה כהה“. הפיקסל באותו רגע היה (27, 43, 52).

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

הערה

הבדיקה שהיתה כאן אכפה את המסקנה השגויה. היא דרשה ש---details-bg/--details-border לא יופיעו ב-sticky-notes.css, בנימוק ”הצבעים חיים במקום אחד“. כלומר הריפו אכף שאסור לתקן את הבאג. היא הוחלפה בשומר הפוך שנגזר מהרכיב עצמו: הוא סורק את הכללים ב-markdown-enhanced.css, מחלץ כל var(--x) שהם קוראים, ודורש שלכל טוקן צבע יהיה ערך על .sticky-note. טוקן שביעי שיתווסף שם — ע“י מי שאינו חושב על הפתקים בכלל — מפיל אותו ומכריח החלטה.

אזהרה

``–summary-bg`` בפתק שטוח ולא גרדיאנט, וזו החלטה עיצובית ולא המרת ערך. בתצוגת המסמך שורת הכותרת היא רצועה שדוהה לרוחב; בפתק ברוחב ~260 פיקסלים דהייה כזו נקראת כמריחה לא אחידה ולא כרצועה. השומר אוכף את הבחירה, כך שחזרה לגרדיאנט עוברת דרכו.

ומחיר אחד שנמדד ונאמר במפורש: ב-high-contrast החץ ירד מ-19.44:1 ל-4.00:1. הוא עדיין מעל הסף, והפתק ממילא אינו מיישם את הערכה הזו — high-contrast.css אינו מכיל אף כלל ל-.sticky-* — בעוד שהטקסט בגוף הבלוק עלה שם מ-1.32 ל-15.48.

innerHTML: מקום אחד, ובגלל הקלט ולא בגלל ה-API

הסעיף על רינדור בתצוגה קובע שהגבול הוא הקלט ולא ה-API, ושתבנית סטטית לחלוטין מותרת. _appendAlertIcon הוא המימוש היחיד של הפטור הזה בקובץ: מחרוזת SVG קבועה מהמפה המשותפת, שנכנסת ל-span ייעודי.

מה שהופך את זה לבטוח אינו הכוונה אלא המבנה: האייקון והתווית הם שני צמתים נפרדים. התווית — הטקסט היחיד שיכול להגיע מהמשתמש — נכתבת תמיד ב-textContent, ואין מסלול קוד שמחבר בין השניים למחרוזת אחת. בדיקת typeof על ערך המפה אינה קישוט: היא הגלובל היחיד שנכתב כ-HTML, וערך שאינו מחרוזת היה נכתב כ-[object Object].

העדפה שיש לה גם מצב ”רק במכשיר זה“

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

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

שלוש נקודות שנראות קטנות ואינן:

א. ”לא נאמר כלום“ אינו ”נאמר: ברירת מחדל“. _decode_note_fonts מחזירה None על cookie חסר או פגום, ולא dict של ברירת מחדל. ההבחנה היא כל ההכרעה: הראשון נופל ל-DB, השני גובר עליו. פענוח שמחזיר ברירת מחדל בשני המקרים גורם למכשיר ב-device בלי ערך שמור להציג ”הכל רגיל“ במקום את ההעדפה המשותפת.

ב. הערך חייב להיפתר בשרת. ה-cookie הוא httponly, ולכן JS אינו יכול לקרוא אותו כלל. ההעדפה מגיעה לתבניות דרך inject_globals ומשם ל-_note_fonts_head.html, שמחיל אותה בזמן פרסור ה-head. זו גם הסיבה שהמחלקה יושבת על <html> ולא על <body>: בשלב הזה document.body הוא null.

ג. כל ערך cookie חדש חייב להיכנס ל-needs_cookie_update. הרשימה הזו מחליטה אם בכלל נבנית תגובה עם קוקיז. ערך שנשכח בה נבנה, עובר ולידציה, ואז נזרק בחזרה המוקדמת — התגובה 200 והקוקי לא נשלח. זה קרה בפועל ל-ui_note_fonts במצב device, שאינו כותב ל-DB ולכן needs_db_update לא כיסה אותו. הבדיקה שתפסה זאת אסרה על ה-cookie בתגובה עצמה; בדיקה שהסתפקה בסטטוס 200 הייתה עוברת.

גודל: מה שנקבע לעומת מה שנכנס

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

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

הערה

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

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

font-size: 14px היה מוצהר פעמיים — על תיבת העריכה ועל התצוגה שמחליפה אותה — ולידו הערה שהן ”אותו ארגז בדיוק“. שני מספרים שחייבים להישאר שווים ואיש לא אכף. הבורר החדש לא הוסיף שלישי אלא איחד את השניים ל---sticky-note-font, ושתי המחלקות על <html> הן כל מה שקובע אותו.

חשוב

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

הערה

הזחת הקינון הייתה הרכיב שהכי קל היה לטעות לגביו: --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 מחזיקה את מה שכל תפריט מציע.

כל עוד היה תפריט אחד, ”אילו גדלים קיימים“ ו“מה מוצע“ היו אותה שאלה, והבדיקה השוותה את הבורר ישירות למפה. משהצטרף הבורר שבעמוד ההגדרות הן נפרדו: הלוח מציע 14/16/18, ועמוד ההגדרות 14/15/16, והמפה גדולה משניהם. מקור אחד לשתי השאלות היה מכריח כל תפריט להציע את מה ששייך לשני.

אזהרה

תפריט שגדל מרחיב גם את מה שהמשטח השני צריך לדחות. הלוח מציב את הערך השמור על ה-<select>; ערך שאין לו <option> מרוקן אותו (selectedIndex נעשה -1) במקום ליפול ל“רגיל“. לכן הלוח מאמת מול התפריט שלו ולא מול המפה — בשני המקומות: בהצבת הבורר, וגם בסקריפט הראש שמחיל את הגודל, אחרת התצוגה והבורר מציגים שני דברים שונים.

שתי השאלות אינן זהות ולכן יש שני מסננים: applyStickyNoteFontSize שואלת ”האם הגודל קיים“, והמשטח שואל ”האם אני מציע אותו“.

שלוש הצלבות שומרות על השרשרת, וכולן בבדיקות ולא בהבטחות: אפשרויות ה-HTML של כל בורר זהות לרשימה שלו (האפשרויות חייבות להישאר ב-HTML כי הן בעברית ומשתמש רואה אותן), כל טוקן ברשימות קיים במפה, ולכל מחלקה שבמפה יש כלל ב-sticky-notes.css. בלי החוליה האחרונה אפשר להוסיף גודל, לראות אותו בבורר, ולגלות שבחירה בו פשוט אינה עושה דבר.

אזהרה

הערך השמור אינו מורכב לשם מחלקה. localStorage הוא קלט חיצוני לכל דבר — ערך שנערך ביד, נשאר מגרסה קודמת, או הגיע ממקום אחר. 'sticky-font-' + size היה מכניס כל מחרוזת ל-classList, ולכן החיפוש הוא hasOwnProperty על המפה. הבדיקה מריצה שבעה ערכים פגומים, ובהם __proto__ ו-constructor.

ההחלה מנקה לפני שהיא מוסיפה. מעבר מ-xl ל-lg בלי הניקוי היה משאיר את שתי המחלקות על <html>, ואז מנצח הכלל שמופיע אחרון בקובץ ולא זה שנבחר — כשל שנראה כמו ”הבחירה לא נשמרה“.

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

גודל הטקסט בעמוד ההגדרות: מה שמסוכן יושב סביב הבורר

הבורר שבמודאל הלוח נשמר ב-localStorage לכל לוח בנפרד. הבורר שבעמוד ההגדרות אינו יכול לעבוד כך, משתי סיבות שכבר מתועדות למעלה: ה-cookie הוא httponly ולכן JS אינו יכול לקרוא אותו, ו-localStorage אינו עובר מכשיר ולכן ”לכל המכשירים“ לא היה עובד. הערך נפתר בשרת ומגיע מרונדר, בדיוק כמו הבחירה של כתב היד שלצידו.

ההגדרה חלה על שני משטחים יחד, ולכן היא ערך יחיד ולא תת-מסמך; אין כאן את סיבוך המיזוג של note_fonts, שבו כתיבה מלאה הייתה מוחקת שדות שכנים.

בורר התחולה נפרד. note_fonts והגודל מחזיקים כל אחד cookie תחולה משלו, כדי שאפשר יהיה להחזיק גודל למכשיר אחד וכתב יד לכולם. ערבוב ביניהם היה מעביר בשקט גם את כתב היד למכשיר כשמשנים רק את הגודל — כשל שנראה תקין לחלוטין עד שמישהו בודק במכשיר שני.

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

סכנה

א. הוולידטור של ה-ETag. _note_fonts_head.html מרנדר את ההעדפה לתוך ה-HTML. ערך שאינו נכנס ל-_note_fonts_etag_key אינו משנה את ה-ETag, השרת מחזיר 304, והדפדפן מציג את העמוד הישן עם הגודל הישן. המסלול הזה אינו תלוי בקאש של Redis — הוא רץ לפני בלוק הקאש — ולכן הוא חי בכל תצורה. המפתח נושא היום את שתי ההעדפות, כתב היד והגודל.

ב. ``ETAG_USER_PROJECTION``. מסמך המשתמש נשלף לוולידטורים עם פרויקציה מפורטת. שדה שאינו בה אינו קורס — הוא פשוט חוזר לברירת המחדל, וזה נראה בדיוק כמו ”ההגדרה לא נשמרה“.

ג. ``_etag_needs_user_doc``. יש קיצור דרך שמדלג על שליפת מסמך המשתמש כשכל ההעדפות הוכרעו מה-cookie. העדפה שאינה נספרת שם מחושבת לפי ברירת המחדל דווקא כשהדילוג פועל — כלומר המפתח אינו זז אחרי שינוי ההגדרה, שזה הכשל שסעיף א« נועד למנוע.

ומעבר לשלושתם נשאר הכלל שכבר מתועד למעלה על needs_cookie_update: ערך cookie שנשכח ברשימה נבנה, עובר ולידציה, ואז נזרק בחזרה המוקדמת — התגובה 200 והקוקי לא נשלח. במצב device שום דבר אינו נכתב ל-DB, ולכן needs_db_update אינו מכסה אותו. הבדיקה שתופסת זאת חייבת לטעון על ה-cookie בתגובה עצמה; בדיקה שהסתפקה בסטטוס 200 עוברת על הבאג במלואו.

הערה

הערך מגיע מחוץ לתהליך, ולכן בדיקת הטיפוס קודמת לכל נגיעה בו. גוף ה-JSON יכול להכיל מספר, רשימה, מילון או null, ו-.strip() על כל אחד מהם זורק — כלומר 500 במקום 400, לפני שהוולידציה הספיקה לומר מה לא תקין. הדחייה מוחלטת ולא המרה: טוקן לא מוכר שהיה נשמר כמות שהוא אינו מוחל בדפדפן, והמשתמש היה מקבל ”נשמר“ על גודל שלא ישתנה לעולם.

הרשימה שהשרת מקבל היא מקור שני לצד STICKY_NOTE_FONT_MENUS.settings, ואי אפשר לגזור אחד מהשני בזמן ריצה. בדיקה משווה ביניהם: טוקן שיתווסף לתפריט בלי להתווסף לשרת מפיל שמירה, וטוקן שיוסר מהתפריט ויישאר בשרת נשמר בלי דרך לבחור אותו.

והלוחות נשארים מחוץ לזה. התבנית המשותפת מרנדרת ערך ריק כשהמשטח הוא board, כי שתי החלטות על אותה מחלקה ב-<html> היו נאבקות זו בזו — והמנצחת הייתה זו שרצה אחרונה, כלומר תלויה בסדר ההכללה ולא בכוונה.

מקור אחד למחלקה, ומקור אחד לסדר

מחלקה אחת ולא שתיים. sticky-handwriting מוחלת משני מקורות — ההגדרה הגלובלית והמתג שלכל לוח — כי המראה זהה. שתי מחלקות עם אותם כללים היו נסחפות: אחת מתעדכנת והשנייה לא. מסיבה דומה ההחלה עצמה חיה בפונקציה אחת (applyStickyHandwriting) ב-_note_fonts_head.html, ולא משוכפלת בכל משטח.

שם המחלקה השתנה; מפתח האחסון לא. localStorage עדיין מכיל board-handwriting:<id> אצל משתמשים קיימים, ויישור השם למחלקה היה מוחק בשקט את ההעדפה של כל לוח קיים. מפתח אחסון הוא נתוני משתמש, לא סגנון.

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

וה-guard בחיווט הוא נושא משקל. בלעדיו הענף הלא-נעול היה מריץ applyHandwriting(readHandwriting()) ומסיר את המחלקה שההכללה כבר החילה, כשההעדפה המקומית של הלוח כבויה. אומת במוטציה: המחלקה ירדה מ-<html> בדפדפן. הבדיקה שמכסה זאת חייבת להריץ את הצירוף גלובלי דלוק + מקומי כבוי; כל צירוף אחר עובר גם בלי ה-guard.

סדר בקידוד. NOTE_FONT_SURFACES הוא המקום היחיד בקוד שיודע את סדר הביטים ב-cookie, והקידוד והפענוח שניהם נגזרים ממנו. משטח רביעי נספח בסוף. צימוד משמעות למיקום כבר נשך פעם בפרויקט הזה, ב-MD_INLINE_RE, שם הוספת חלופה באמצע הייתה מזיזה בשקט את כל אינדקסי הקבוצות שאחריה.

צבע: מזהה במסד, hex נגזר, וכינוי במקום מיגרציה

הצבע נשמר כ**מזהה** מהפלטה (yellow, pink_light) ולא כ-hex. השיקול הוא עלות השינוי הבא ולא אסתטיקה: החלפת גוון, או הוספת משמעות לצבע (”דחוף“), נוגעת בשורה אחת ב-NOTE_COLORS ואף פתק אינו צריך מיגרציה. עם hex שמור, כל שינוי כזה הוא כתיבה על כל הפתקים של כל המשתמשים.

וה-``hex`` נגזר, לעולם לא נשמר לצידו. שני שדות שחייבים להישאר מסונכרנים הם שני שדות שיסטו; לכן במסמך יש ערך אחד, ו-note_color_hex גוזרת ממנו את מה שהדפדפן צובע.

חשוב

‏``note_color_hex`` עוברת דרך ``note_color_id``, ולא במקביל לה. הגרסה הראשונה שאלה ”האם זה מזהה?“ ואם לא — החזירה את ה-hex כמות שהוא, בלי להתייעץ עם טבלת הגוונים. שתי הפונקציות ענו אז תשובות שונות על אותו ערך: אחת אמרה ”זה הצהוב“, השנייה החזירה גוון אחר — כלומר הבורר מסמן עיגול אחד והפתק צבוע בשני.

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

חשוב

הצהוב הקיים הוא צבע בפלטה, ולא גוון היסטורי של אחר — וזו ההחלטה שהכי קל לטעות בה. #FFFFCC היה ברירת המחדל של הפתקים מאז ומתמיד, ולכן כמעט כל פתק שקיים היום נושא אותו. הפיתוי הוא למפות אותו אל yellow_light שדומה לו, ”כדי שייכנס לפלטה“ — וזה בדיוק מה שנבנה כאן בסבב הראשון. המחיר היה שינוי גוון לכל פתק שקיים בעולם, בפעולה שאיש לא ביקש.

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

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

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

הקלט סובלני, האחסון קנוני

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

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

הערה

הרג’קס ``3,8`` לא צומצם, אף שחמש ושבע ספרות אינן hex חוקי ב-CSS. mcp_server/handlers קיבל אותן, ולכן ערכים כאלה כבר יושבים במסד ובגיבויים; דחייה שלהם עכשיו הייתה מוחקת צבע קיים במקום להשאירו legacy.

חשוב

‏``resolve_note_color`` מדווחת שלא הצליחה לפענח; מה עושים עם זה הוא החלטה של הקורא. default=None מחזיר "", ושני הקוראים מחליטים אחרת בכוונה:

  • ה-MCP דוחה — invalid_color עם רשימת המזהים התקינים, ביצירה ובעדכון כאחד. סוכן שמקבל ok על צבע שלא הוחל מדווח למשתמש דבר לא נכון; ערוץ הכשל היה קיים ואיש לא קרא בו, וזה בדיוק האישור השקרי של CRITICAL-PATTERNS K11. והוא גם היחיד שיכול לתקן בניסיון הבא — אם אומרים לו מה חוקי.

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

ההבדל מתועד כדי שלא ייקרא כסתירה, ומי שמיישר אותם צריך להחליט לאיזה כיוון ולמה — לא ”כי הם לא זהים“.

סכנה

”לא ביקשתי צבע“ אינו ”ביקשתי צבע שאינו קיים“, וההבחנה היא כל מה שמפריד בין ולידציה מועילה לבין ולידציה שמעצבנת. None ומחרוזת ריקה (או רווחים בלבד) הם הראשון, ולכן נופלים לברירת המחדל בשקט — הם אינם בקשה שנכשלה. כל ערך אחר שאינו נפתר הוא השני.

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

color בתשובת ה-API הוא hex, ולא מה שמאוחסן

התשובה נושאת שני שדות: color תמיד hex חוקי, ו-color_id שהוא המזהה או "" ל-legacy. אותו חוזה בדיוק בשני הסריאלייזרים — הוובאפ וה-MCP — כי אין שום סיבה שסוכן ודפדפן יקבלו צורות שונות לאותה שאלה. color נשאר hex כי הצרכן היחיד שלו הוא style.backgroundColor, ולקוח שנטען מקאש ישן היה מכניס לשם pink_light — ערך CSS לא חוקי, כלומר פתק שקוף על גבי הקוד שמתחתיו. אותה סיבה בדיוק היא שהנפילה ב-note_color_hex היא לגוון ברירת המחדל ולא ל-"".

color_id ריק אינו כשל אלא התשובה הנכונה ל“הצבע הזה אינו בפלטה“, והבורר נפתח בלי סימון. עיגול שהיה מסומן שם היה אומר למשתמש שהפתק בצבע שהוא אינו בו.

ויש שני סריאלייזרים, לא אחד. webapp/sticky_notes_api._as_note_response מגיש לדפדפן, ו-mcp_server/backend._as_note מגיש לסוכן — ושניהם חייבים לגזור את הצבע, לא להעתיק אותו מהמסמך.

סכנה

הסבב הראשון תיקן רק את הראשון, וזה נתפס בריוויו ולא בבדיקה. _as_note העתיק את color גולמי, ולכן מרגע שהמסד החזיק את שתי הצורות זו לצד זו — פתק שהמיגרציה יישרה ופתק שלא — סוכן שקרא שני פתקים באותו צבע בדיוק קיבל שני ערכים שונים: "yellow" מהאחד ו-"#FFFFCC" מהשני. אין לו שום דרך לדעת איזו צורה תגיע.

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

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

הפלטה חיה פעמיים, ובדיקה מצליבה אותן

המקור הוא sticky_notes_target.NOTE_COLORS, אבל sticky-notes.js מחזיק עותק — אי אפשר לגזור אחד מהשני בזמן ריצה בלי לטעון פייתון בדפדפן. זו בדיוק המצוקה שכבר תועדה על הרשימה שהשרת מקבל לצד STICKY_NOTE_FONT_MENUS, והתשובה שם היא התשובה כאן: מקור שני מותר, בתנאי שבדיקה מצליבה ביניהם.

הדרך האחרת — לקרוא את המפה מ-window, כמו ADMONITION_TITLES — נשקלה ונדחתה. היא מחליפה תלות בבדיקה בתלות ב**סדר טעינת תבניות**, וזה בדיוק הכשל שהאזהרה על note_board.html מתעדת: משטח שלא טען את המפה הציג הכל שגוי, בלי שגיאה ובלי לוג.

אזהרה

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

הצבעים והמדידה

כללי הבורר ב-sticky-notes.css אינם משתמשים ב-var() כלל, ובדיקה אוכפת את זה. זה אינו ”סתם עוד משטח קשיח“: הבורר מציג את הצבעים עצמם, ולכן ערכה שהייתה צובעת את הכרטיס מחדש הייתה משנה בדיוק את הדבר שהמשתמש בא להשוות אליו.

שלושת המספרים שנמדדו, ושכל צבע שיתווסף כאן צריך לעמוד בהם:

  • טקסט על ה-swatch — הדיו #2b3f6a מול גווני הפלטה יוצא 8.14:1 (ורוד, הגרוע) עד 10.10:1 (הצהוב הקיים), כלומר מעל הסף המחמיר של WCAG AAA. לכן התווית יושבת על הצבע ואינה צריכה רצועה משלה.

  • הגבול של ה-swatch — הוא שמחזיק את הכל. גווני הפלטה בהירים מאוד, ו-#f0f8ff הוא 1.02:1 מול כרטיס הבורר: בלי גבול הכחול והסגול פשוט נעלמים בו. הגבול הוא פס שיושב חציו על ה-swatch וחציו על הכרטיס, ולכן נמדד לשני הכיוונים. .35, שנראה סביר לחלוטין, נכשל ב-1.84 / 1.81; .6 הוא הסף המדויק; הערך שנבחר הוא .7.

  • טבעת הבחירה בדיו מלא: 8.14:1 במקרה הגרוע.

אזהרה

הטוקן היחיד בפתק שתלוי ברקע הוא המרקר, וכעת יש לו שישה רקעים במקום אחד. כל שאר הצבעים בקובץ שקופים או נגזרים מהדיו (transparent, rgba(0,0,0,…), color-mix(… , transparent)) ולכן מסתגלים לכל גוון מאליהם; --sticky-mark-bg/--sticky-mark-line הם צבעים אטומים שההערה בקוד מתעדת שכוילו מול הנייר הצהוב.

נמדד: מרחק המרקר מהרקע יורד מ-102 בצהוב ל-85 בכתום ול-69 בוורוד — כלומר מתחת ל-76 שאותה הערה כבר פסלה כ“ורוד שנבלע בנייר“. הוא נשאר כמות שהוא בכל הצבעים, בהחלטה מפורשת אחרי בדיקה חזותית של האלמנטים על הפלטה: המדד הוא מרחק RGB אוקלידי, והוא גס במיוחד כשהוא משווה ורוד לוורוד ולא ורוד לצהוב.

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

הערה

המודאל נבנה כולו בצמתים, בלי ``innerHTML`` — בשונה ממודאל התזכורת שלצידו. הפטור שהכלל בפתקים נותן לתבנית סטטית לחלוטין תקף גם כאן, אבל docs/webapp/language-icons.rst מנסח את מה שמעליו. הרווח אינו סגנוני: שלד שנבנה בצמתים אפשר לבדוק בלי פרסר HTML, כלומר בלי להוסיף תלות לפרויקט רק כדי שהבדיקה תוכל לרוץ.

ומאותה משפחה: הצבע יושב ב-style של ה-swatch ולא במחלקה. כלל CSS לכל גוון היה מקור שני לאותה שאלה, וצבע שביעי היה נוסף לפלטה ויוצא עיגול לבן.

הערה

ומה שהסוכן קורא על הצבע נגזר אף הוא מהפלטה. _build_note_color_doc ב-mcp_server/server בונה את תיאור הפרמטר מ-NOTE_COLORS עצמה, ולא מונה את הצבעים ביד: טקסט מוקלד היה מתיישן בשקט בצבע הבא, והסוכן היה ממשיך לראות רשימה חלקית בלי שאף בדיקה תשים לב.

חשוב

הצבע הנוכחי נקרא מהרשומה (``_getEntry``), לא מהאלמנט. style.backgroundColor מוחזר מדפדפן אמיתי כ-rgb(...) ולא כ-hex, ולכן השוואה מולו הייתה נכשלת תמיד ואף עיגול לא היה מסומן — מצב שנראה תקין לחלוטין. הרשומה היא כבר המקום שבו חיים מצב הביטול וזיכרון הבלוקים הפתוחים, ולכן אין כאן dataset שני שיכול להיסחף.

שני חצאים למיגרציה, ואף אחד מהם אינו מיותר

הקריאה מנורמלת בעצמה — note_color_id מזהה #FFFFCC כ-yellow בכל שליפה — ולכן התצוגה והבורר נכונים גם בלי להריץ דבר, ואף פתק אינו משנה מראה בהרצה. מה שהסקריפט קונה הוא צורת אחסון אחת: מסד שמחזיק חלק מהפתקים כ-#FFFFCC, חלק כ-#ffffcc וחלק כ-yellow מכריח כל שאילתת סינון לכסות את שלוש הצורות, ושכחה של אחת מהן מחזירה חלק מהתשובה בלי שגיאה.

לכן scripts/migrate_note_colors.py מיישר את מה שקיים, והנורמליזציה בקריאה מכסה את מה שנכתב בין ההרצה לפריסה ואת גיבויים שמשוחזרים אחריה. הסקריפט הוא דוח בלבד כברירת מחדל, בתבנית migrate_note_boards.py, והוא אינו תנאי לפריסה.

חשוב

‏``–normalize-legacy`` הוא דגל נפרד. צבע שאינו בפלטה אינו מוחלף בכוח — זו הדרישה. אבל #AABBCC ו-#aabbcc הם אותו פיקסל ושני ערכים במסד, כלומר אותה בעיית סינון בקטן. הדגל מיישר כתיב בלבד ולעולם לא צבע, וההחלטה להריץ אותו נשארת של מי שמריץ.

וערך שאי אפשר לפענח כלל (None, מספר, זבל) נספר ומדווח ו**אינו נכתב**. שכבת התצוגה כבר מציגה אותו כצהוב; כתיבת הניחוש הזה למסד הייתה הופכת אותו לעובדה ומוחקת את העדות שהיה שם משהו אחר.

סכנה

‏``distinct`` מפרק מערכים, וזו השרשרת שהופכת את הסקריפט הזה למסוכן. מסמך שבו color הוא מערך אינו פתק חוקי ואף כותב אינו מייצר אחד — אבל הוא יכול לשבת במסד מכתיבה ישירה או מגיבוי פגום, והוא המקרה היחיד שבו הסקריפט עלול להרוס נתונים במקום ליישר אותם. שלוש חוליות, שלושתן מתועדות ב-MongoDB:

  1. distinct מגיש את איברי המערך כערכים נפרדים, ולכן ["#FFFFCC", "x"] תורם את המחרוזת "#FFFFCC" לסריקה ונראה בדיוק כמו ערך רגיל.

  2. שאילתת שוויון תואמת איבר במערך, ולכן המסמך נספר ונכנס לעדכון.

  3. $set דורס את השדה כולו, ולכן המערך היה מוחלף במחרוזת אחת — כלומר האיברים האחרים נמחקים, בפעולה שכל מטרתה לא לשנות שום צבע בכוח.

ההגנה היא ``$type: ”array“``, והיא היחידה שעובדת. הוא הטיפוס היחיד שמתייחס לשדה עצמו ולא לאיבריו; $type: "string" דווקא אינו מגן, כי על מערך הוא תואם אם איבר אחד מתאים. מסמכים כאלה נספרים ומדווחים בדוח, ולעולם אינם נכתבים.

חשוב

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

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

סכנה

‏``update_many`` מדווח על עבודתו ב-``modified_count`` ואינו זורק כשלא נגע בכלום. סקריפט שסופר את מה שתכנן היה מדפיס ”עודכנו 3“ גם כשאפס נכתבו — האישור השקרי של CRITICAL-PATTERNS K11, שכבר נשך בריפו הזה יותר מפעם אחת. המונה נגזר מתשובת המסד, ופער בין הספירה לכתיבה מדווח ואינו נבלע.

חיפוש בפתקים: למה רג’קס ולא $text

החיפוש בוובאפ וב-MCP הוא אותה פונקציית התאמה — sticky_notes_target.note_search_filter. שני הצרכנים נבדלים רק במה שהם מציגים: ה-MCP מחזיר הפניות בלבד, והעמוד מוסיף תצוגה מקדימה של 200 תווים. זו ההפרדה שמונעת את המצב שבו אותה מילה מחזירה קבוצות שונות בשני המקומות.

חשוב

‏``$text`` נבחן ונפסל, על נתונים אמיתיים. אינדקס טקסט נראה כמו התשובה הנכונה — הוא מדורג, ממוין במסד, ויש עליו אינדקס אמיתי. המדידה על פתקים אמיתיים בעברית הראתה שהוא מפספס כ-18% מההתאמות שהרג’קס מוצא, ובמונחים נפוצים הרבה יותר: חיפוש ”שימוש“ החזיר 2 פתקים מתוך 27 שהרג’קס מצא.

הסיבה אינה באג אלא התנהגות מתועדת: $text מתאים טוקנים שלמים, ו-default_language: "none" מתעד במפורש שהוא מתעלם מגזירת סיומות. בעברית, שבה תחיליות וסופיות נדבקות למילה, ”פתק“ אינו מוצא ”בפתק“. רג’קס תת-מחרוזת מוצא את שניהם.

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

הערה

מה שלא נעשה, ובכוונה: נורמליזציה של ניקוד. amir-bug-patterns H4 מתאר שחיפוש שאינו מנרמל ניקוד לא ימצא מסמך שנכתב עם ניקוד. מדידה על הפתקים שבמסד מצאה פתק אחד עם ניקוד אמיתי; שאר ההתאמות בטווח היוניקוד הזה היו מקף עברי ופיסוק, שאינם משפיעים על התאמת מילים.

אבל הסיבה המכריעה אינה העלות. H4 דורש לנרמל את השאילתה ואת התוכן יחד, אחרת הנרמול חד-כיווני — כלומר שדה מאוחסן נוסף ומיגרציה. ו-note_search_filter משותף: נרמול רק בוובאפ היה יוצר בדיוק את הפער שהפונקציה המשותפת קיימת כדי למנוע. אם ניקוד יהפוך לבעיה אמיתית, הוא נפתר שם, לשני הצרכנים יחד.

סכנה

חיתוך התצוגה המקדימה הוא ``$substrCP``, ולעולם לא ``$substrBytes``. אות עברית היא שני בייטים, ואינדקס שנספר בתווים שנכנס לאופרטור שמודד בבייטים נוחת באמצע תו ומפיל את השאילתה (Location28657). זה בדיוק האירוע שקרה בחיפוש הקוד בריפו הזה: השגיאה נבלעה ב-try, מסלול חלופי יקר רץ במקומה, והמשתמש קיבל ”לא נמצאו תוצאות“ על חיפוש שמעולם לא רץ.

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

חשוב

סדר שלבי הצינור אינו קוסמטי. $project שמסיר את גוף הפתק חייב לשבת לפני ה-$sort. בסדר ההפוך מונגו החזירה בפרודקשן שגיאה 292 (QueryExceededMemoryLimitNoDiskUseAllowed), כי $sort מאגר בזיכרון את מה שהוא ממיין; allowDiskUse אינו מציל, כי Atlas מתעלם ממנו בקלאסטרים חינמיים ו-Flex.

והדגשה חשובה על איך זה נבדק: לא על ערך ההחזרה של פונקציית הבנייה, אלא על הצינור שאתר הקריאה באמת שולח ל-aggregate. אפשר לכתוב בונה נכון לחלוטין ולשכוח לחבר אליו את הראוט — והבדיקה נשארת ירוקה. התקדים הוא tests/test_files_pipelines_drop_heavy_fields_early.py, שנכתב אחרי שבדיוק זה קרה.

חשוב

האינדקס שהחיפוש צריך הוא ``(user_id, updated_at)``, וגילינו אותו במדידה ולא בהיגיון. הדוקסטרינג של note_search_filter טען שנים ש-user_id כפרדיקט ראשון חוסם את הסריקה למכסת המשתמש ”ואינו COLLSCAN“. explain על השאילתה האמיתית הראה שהתוכנית הזוכה בוחרת דווקא ב-updated_desc — כלומר סורקת את הפתקים של כל המשתמשים לפי סדר עדכון. התוכניות שנשענות על תחילית user_id נדחו כולן, כי כל אחת דורשת מיון חוסם. התיאור הישן תיאר את מה שנדחה.

וכל הוספת אינדקס לפתקים נוגעת בשלושה מקומות: _QUERY_INDEX_SPECS בוובאפ, הרשימה המקבילה ב-mcp_server.backend._notes_coll, ו**קידום** _INDEX_READY_CACHE_KEY. בלי הקידום, כל תהליך שקורא דגל מוכנוּת חי מדלג על הבנייה ליממה שלמה, והאינדקס החדש פשוט לא נבנה — בשקט. tests/test_note_boards_api.py נועל את שני הצדדים יחד, כך שאי אפשר לשנות אחד בלי לגעת בשני.

הערה

סינון הצבע: מיגרציה קודם, ופונקציה אחת שיודעת את התשובה. note_color_query מחזירה {"color": {"$in": [<מזהה>, <רג'קס מעוגן חסר-רישיות>]}}. המזהה תופס את מה שנכתב אחרי המעבר לפלטה, והרג’קס את ה-hex ההיסטורי בכל צורת כתיבה — #FFFFCC, #ffffcc, #FfFfCc, #ffc.

רג’קס ולא רשימת כתיבים, כי רשימה הייתה צריכה למנות את כל צירופי הרישיות של שש ספרות; דגל i אחד עושה את אותה עבודה. $in ולא $or, כי מונגו מתירה רג’קס כערך בתוך $in ואינה מתירה שם ביטוי $regex.

והחצי השני הוא ``scripts/migrate_note_colors.py``, שמיישר את מה שכבר קיים. כשכל הסביבות מיושרות ומאומתות, מוחקים את הרג’קס מ-note_color_query — נקודת הסרה אחת, ואין מקום שני לתקן.

התיעוד למשתמש הוא חלק מהפיצ’ר

חשוב

כל פיצ’ר חדש בפתקים, וכל שינוי בהתנהגות קיימת, מעדכן את פתקים דביקים (Sticky Notes) באותו PR.

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

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

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

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