-
Notifications
You must be signed in to change notification settings - Fork 0
feat: per-block direction detection in Auto mode #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
edb0c90
ee95d9b
cd8e8cc
b406945
e361099
88be133
7941df4
916de9c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| # Auto Mode: Per-Block Direction Detection | ||
|
|
||
| **Date:** 2026-08-04 | ||
| **Status:** Approved | ||
| **Scope:** `src/content.ts` only (Auto mode CSS + JS) | ||
|
|
||
| ## Problem | ||
|
|
||
| In Auto mode, once a bubble is tagged `.YBYrtl`, response paragraphs get | ||
| `unicode-bidi: plaintext`, which sets each paragraph's base direction from its | ||
| **first strong character**. In a Hebrew conversation this causes: | ||
|
|
||
| 1. Paragraphs that start with an English word flip to LTR and align left. | ||
| 2. List markers (bullets/dots) follow that direction and land on the wrong side. | ||
| 3. Mixed Hebrew/English lines scramble punctuation and word order, hard to read. | ||
|
|
||
| ## Decision | ||
|
|
||
| Direction is decided **per block element** by *presence* of RTL characters, not | ||
| by first character: | ||
|
|
||
| - Block **contains** Hebrew/Arabic/Persian → `dir="rtl"` → right-aligned, | ||
| bullet on right, embedded English isolated inline. | ||
| - Block has **no** RTL characters (pure English heading, free-standing code-ish | ||
| line) → `dir="ltr"` → left-aligned, natural. | ||
|
|
||
| User-approved trade-off: a lone English word on its own line goes left; a Hebrew | ||
| paragraph containing lots of English still goes right. | ||
|
|
||
| ## Mechanism | ||
|
|
||
| ### JS (extends the existing Auto-mode observer in `RTL_AUTO_JS_CODE`) | ||
|
|
||
| - For each `.YBYrtl` bubble, walk block elements — `p`, `li`, `h1`–`h6`, | ||
| `blockquote` — **only inside markdown containers** (`[class*="root_"]`); | ||
| tool/thinking/todo UI reuses the same tags and must not be tagged. | ||
| - Additionally skip anything inside every container the LTR overrides protect: | ||
| `pre`, `code`, `codeBlockWrapper_`, `thinking_`/`thinkingContent_`, | ||
| `toolUse_`/`toolSummary_`/`toolBody_`/`toolResult_`/`toolReference_`, | ||
| `todoList_`/`todoListContainer_` — a native `dir` attribute on a child is not | ||
| neutralized by direction rules on its container. | ||
| - Set `dir="rtl"` or `dir="ltr"` per the RTL-char test | ||
| (`/[--ۿݐ-ݿﭐ-﷿ﹰ-]/`). | ||
| - Re-scan on mutations, debounced — same pattern as the BiDi stripper — so a | ||
| streamed line that starts in English flips right once Hebrew arrives. | ||
|
|
||
| ### CSS (in `AUTO_RTL_RULES`) | ||
|
|
||
| - Remove `unicode-bidi: plaintext` and the blanket `text-align: right` from | ||
| response-paragraph rules. | ||
| - Add: | ||
| - `[dir="rtl"]` blocks → `direction: rtl; text-align: right; unicode-bidi: isolate`. | ||
| - `[dir="ltr"]` blocks → `direction: ltr; text-align: left`. | ||
| - Bubble-level `direction: rtl` layout rules stay (bubble alignment unchanged). | ||
| - Prompt input rules stay as-is (first-char live detection is correct while | ||
| typing). | ||
|
|
||
| ## Out of Scope | ||
|
|
||
| - Active / Always / LTR-Always modes (their semantics are explicit, not | ||
| detected). | ||
| - Plan Preview. | ||
| - Input field. | ||
|
|
||
| ## Verification | ||
|
|
||
| - `npm run build` clean; `npm test` (concurrency) still passes. | ||
| - Manual: in Auto mode with a mixed Hebrew/English response confirm — | ||
| English-first mixed line right-aligned and readable, bullets on the right, | ||
| pure-English heading stays left, code blocks untouched, streaming reply | ||
| settles correctly. |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -294,17 +294,27 @@ const AUTO_RTL_RULES = ` | |
| unicode-bidi: plaintext; | ||
| } | ||
|
|
||
| /* Claude's markdown responses (excluding thinking block) */ | ||
| /* Claude's markdown responses (excluding thinking block) — container stays RTL | ||
| for bubble layout; per-block direction is set by the Auto-mode block walker | ||
| via dir attributes (contains-RTL → rtl, pure LTR → ltr) */ | ||
| .YBYrtl [class*="root_"]:not([class*="thinkingContent_"] [class*="root_"]) { | ||
| direction: rtl; | ||
| unicode-bidi: plaintext; | ||
| } | ||
|
|
||
| .YBYrtl [class*="root_"]:not([class*="thinkingContent_"] [class*="root_"]) > :is(p, ul, ol, h1, h2, h3, h4, blockquote), | ||
| .YBYrtl [class*="root_"]:not([class*="thinkingContent_"] [class*="root_"]) > :is(ul, ol) li { | ||
| .YBYrtl [class*="root_"]:not([class*="thinkingContent_"] [class*="root_"]) :is(p, li, h1, h2, h3, h4, h5, h6, blockquote)[dir="rtl"] { | ||
| direction: rtl; | ||
| text-align: right; | ||
| unicode-bidi: isolate; | ||
|
Comment on lines
+304
to
+307
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an RTL-tagged paragraph contains an English link or URL surrounded by Hebrew/Arabic text, isolating only the entire block does not isolate the anchor because Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| .YBYrtl [class*="root_"]:not([class*="thinkingContent_"] [class*="root_"]) :is(p, li, h1, h2, h3, h4, h5, h6, blockquote)[dir="ltr"] { | ||
| direction: ltr; | ||
| text-align: left; | ||
| unicode-bidi: isolate; | ||
| } | ||
|
|
||
| /* Links keep their own bidi run — unicode-bidi is not inherited, so block-level | ||
| isolation alone leaves URL punctuation reorderable by the RTL context */ | ||
| .YBYrtl [class*="root_"]:not([class*="thinkingContent_"] [class*="root_"]) a { | ||
| unicode-bidi: plaintext; | ||
| } | ||
|
|
@@ -906,6 +916,131 @@ export const RTL_AUTO_JS_CODE = ` | |
| }, 50); | ||
| }).observe(scanRoot, { childList: true, subtree: true, characterData: true }); | ||
| })(); | ||
|
|
||
| /* Per-Block Direction — sets dir="rtl"/"ltr" on markdown blocks inside .YBYrtl | ||
| bubbles by presence of RTL characters, so mixed lines read right-aligned | ||
| while pure-English blocks stay natural LTR. CSS keys off the dir attribute. */ | ||
| (function() { | ||
| var RTL = /[\\u0590-\\u05FF\\u0600-\\u06FF\\u0750-\\u077F\\uFB50-\\uFDFF\\uFE70-\\uFEFE]/; | ||
| var BLOCK_SEL = 'p,li,h1,h2,h3,h4,h5,h6,blockquote'; | ||
| /* Every container the LTR overrides protect — a native dir attribute on a | ||
| child is NOT neutralized by direction rules on the container, so the | ||
| walker must never tag inside these */ | ||
| var SKIP_SEL = '[class*="codeBlockWrapper_"],pre,code,[class*="thinkingContent_"],[class*="thinking_"],[class*="toolUse_"],[class*="toolSummary_"],[class*="toolBody_"],[class*="toolResult_"],[class*="toolReference_"],[class*="todoList_"],[class*="todoListContainer_"]'; | ||
|
|
||
| /* Blocks that render independently (own marker / own alignment) — text | ||
| inside them must not influence an ancestor block's direction. p and | ||
| headings inside a loose <li><p>…</p></li> DO count toward the li, | ||
| because the list marker belongs to the li. */ | ||
| var INDEPENDENT_SEL = 'li,blockquote'; | ||
|
|
||
| function ownsText(el, parent) { | ||
| var node = parent; | ||
| while (node && node !== el) { | ||
| if (node.matches && node.matches(INDEPENDENT_SEL)) return false; | ||
| node = node.parentElement; | ||
| } | ||
| return true; | ||
| } | ||
|
|
||
| /* True when the block has RTL text of its OWN — outside skipped containers | ||
| (a code block quoting Hebrew inside an English list item must not flip | ||
| it) and not owned by an independently nested block (a Hebrew sub-item | ||
| must not flip its English parent; the sub-item gets its own dir) */ | ||
| function hasOwnRtl(el) { | ||
| if (!RTL.test(el.textContent || '')) return false; /* fast path */ | ||
| var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null, false); | ||
| var n; | ||
| while ((n = walker.nextNode())) { | ||
| if (!RTL.test(n.nodeValue)) continue; | ||
| var p = n.parentElement; | ||
| if (!p) continue; | ||
| if (p.closest && p.closest(SKIP_SEL)) continue; | ||
| if (!ownsText(el, p)) continue; | ||
| return true; | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| function tagBlocks(bubble) { | ||
| /* Only markdown containers hold prose; tool/thinking/todo UI reuses the | ||
| same tags and must keep its LTR layout untouched */ | ||
| var roots = bubble.querySelectorAll('[class*="root_"]'); | ||
| for (var r = 0; r < roots.length; r++) { | ||
| var rootEl = roots[r]; | ||
| if (rootEl.closest && rootEl.closest(SKIP_SEL)) continue; | ||
| var els = rootEl.querySelectorAll(BLOCK_SEL); | ||
| for (var i = 0; i < els.length; i++) { | ||
| var el = els[i]; | ||
| if (el.closest && el.closest(SKIP_SEL)) continue; | ||
| var want = hasOwnRtl(el) ? 'rtl' : 'ltr'; | ||
| if (el.getAttribute('dir') !== want) el.setAttribute('dir', want); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| var dirRoot = document.getElementById('root'); | ||
| if (!dirRoot) return; | ||
|
|
||
| function scanAll() { | ||
| var bubbles = dirRoot.querySelectorAll('.YBYrtl'); | ||
| for (var i = 0; i < bubbles.length; i++) tagBlocks(bubbles[i]); | ||
| } | ||
|
|
||
| scanAll(); | ||
|
|
||
| /* Debounced watcher — re-tags during streaming and when bubbles gain | ||
| .YBYrtl. Only bubbles touched by the mutation batch are re-tagged. | ||
| Mutations outside any bubble are ignored (never a full scan — unrelated | ||
| LTR streaming must not re-walk the whole chat history); new bubbles are | ||
| caught directly from added nodes and class changes. */ | ||
| var dirTimer = null; | ||
| var pendingBubbles = []; | ||
|
|
||
| function addBubble(bubble) { | ||
| if (pendingBubbles.indexOf(bubble) === -1) pendingBubbles.push(bubble); | ||
| } | ||
|
|
||
| function noteAncestorBubble(node) { | ||
| var el = node.nodeType === 1 ? node : node.parentElement; | ||
| var bubble = el && el.closest ? el.closest('.YBYrtl') : null; | ||
| if (bubble) addBubble(bubble); | ||
| } | ||
|
|
||
| function noteRecord(rec) { | ||
| if (rec.type === 'attributes') { | ||
| /* class change: the target itself may have just become a bubble */ | ||
| var t = rec.target; | ||
| if (t.nodeType === 1 && t.classList && t.classList.contains('YBYrtl')) { addBubble(t); return; } | ||
| noteAncestorBubble(t); | ||
| return; | ||
| } | ||
| if (rec.type === 'childList') { | ||
| /* added nodes may BE or CONTAIN bubbles not yet under one */ | ||
| for (var a = 0; a < rec.addedNodes.length; a++) { | ||
| var node = rec.addedNodes[a]; | ||
| if (node.nodeType !== 1) continue; | ||
| if (node.classList && node.classList.contains('YBYrtl')) addBubble(node); | ||
| else if (node.querySelectorAll) { | ||
| var inner = node.querySelectorAll('.YBYrtl'); | ||
| for (var b = 0; b < inner.length; b++) addBubble(inner[b]); | ||
| } | ||
| } | ||
| } | ||
| noteAncestorBubble(rec.target); | ||
| } | ||
|
|
||
| new MutationObserver(function(records) { | ||
| for (var i = 0; i < records.length; i++) noteRecord(records[i]); | ||
| if (dirTimer || !pendingBubbles.length) return; | ||
| dirTimer = setTimeout(function() { | ||
| dirTimer = null; | ||
| var bubbles = pendingBubbles; | ||
| pendingBubbles = []; | ||
| for (var i = 0; i < bubbles.length; i++) tagBlocks(bubbles[i]); | ||
| }, 100); | ||
| }).observe(dirRoot, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['class'] }); | ||
| })(); | ||
| ${PERMISSION_RTL_JS} | ||
| /* End RTL Toggle Button */ | ||
| `; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| /** | ||
| * Regression guard for the Auto-mode per-block direction walker | ||
| * (Codex round-1 P2, PR #2): the walker must only tag blocks inside | ||
| * markdown containers, and its skip selector must cover every container | ||
| * the LTR overrides protect — a native dir attribute on a child is not | ||
| * neutralized by direction rules on its container. | ||
| */ | ||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
|
|
||
| const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'content.ts'), 'utf8'); | ||
|
|
||
| const walker = src.match(/\/\* Per-Block Direction[\s\S]*?\n\}\)\(\);/); | ||
| if (!walker) { | ||
| console.error('FAIL — per-block direction walker not found in content.ts'); | ||
| process.exit(1); | ||
| } | ||
| const w = walker[0]; | ||
|
|
||
| const failures = []; | ||
|
|
||
| // 1. Walker must query blocks from markdown roots, never bubble-wide. | ||
| if (!/querySelectorAll\('\[class\*="root_"\]'\)/.test(w)) { | ||
| failures.push('walker no longer scopes its block query under [class*="root_"] markdown containers'); | ||
| } | ||
|
|
||
| // 2. Skip selector must include every protected container class. | ||
| const skipSel = (w.match(/var SKIP_SEL = '([^']+)'/) || [])[1] || ''; | ||
| for (const cls of [ | ||
| 'codeBlockWrapper_', 'thinkingContent_', 'thinking_', | ||
| 'toolUse_', 'toolSummary_', 'toolBody_', 'toolResult_', 'toolReference_', | ||
| 'todoList_', 'todoListContainer_', | ||
| ]) { | ||
| if (!skipSel.includes(cls)) failures.push(`SKIP_SEL is missing protected container "${cls}"`); | ||
| } | ||
| for (const tag of ['pre', 'code']) { | ||
| if (!new RegExp(`(^|,)${tag}(,|$)`).test(skipSel)) failures.push(`SKIP_SEL is missing "${tag}"`); | ||
| } | ||
|
|
||
| // 3. CSS dir rules must keep the thinking-block guard (Copilot round-2, PR #2): | ||
| // per-block [dir] styling must never apply under thinkingContent_. | ||
| for (const dir of ['rtl', 'ltr']) { | ||
| const re = new RegExp( | ||
| `\\[class\\*="root_"\\]:not\\(\\[class\\*="thinkingContent_"\\] \\[class\\*="root_"\\]\\) :is\\([^)]*\\)\\[dir="${dir}"\\]` | ||
| ); | ||
| if (!re.test(src)) { | ||
| failures.push(`CSS [dir="${dir}"] block rule lost its thinkingContent_ guard`); | ||
| } | ||
| } | ||
|
|
||
| // 4. Direction detection must ignore text inside skipped containers | ||
| // (Codex round-4 P2, PR #2): a code block quoting Hebrew inside an | ||
| // English list item must not flip the item to RTL. | ||
| if (!/function hasOwnRtl/.test(w) || !/createTreeWalker/.test(w)) { | ||
| failures.push('walker direction detection no longer excludes skipped-container text (hasOwnRtl/TreeWalker missing)'); | ||
| } | ||
| if (!/hasOwnRtl\(el\)/.test(w)) { | ||
| failures.push('tagBlocks no longer uses skip-aware hasOwnRtl for direction detection'); | ||
| } | ||
|
|
||
| // 5. Independently nested blocks' RTL text must not flip the parent, while | ||
| // loose-list paragraphs still count toward their li (Codex round-5/6 P2s): | ||
| // ownership stops at nested li/blockquote, not at p/headings. | ||
| if (!/INDEPENDENT_SEL = 'li,blockquote'/.test(w) || !/function ownsText/.test(w)) { | ||
| failures.push('hasOwnRtl lost the independent-nested-block ownership rule (ownsText/INDEPENDENT_SEL)'); | ||
| } | ||
| if (!/ownsText\(el, p\)/.test(w)) { | ||
| failures.push('hasOwnRtl no longer consults ownsText for direction detection'); | ||
| } | ||
|
|
||
| // 6. The observer must never full-scan on unrelated mutations (Codex round-5 | ||
| // P2): pure-English streaming outside bubbles must not re-walk history. | ||
| if (/pendingFull/.test(w) || /scanAll\(\)/.test(w.split('Debounced watcher')[1] || '')) { | ||
| failures.push('observer regained a full-scan fallback (pendingFull/scanAll in mutation path)'); | ||
| } | ||
| if (!/addedNodes/.test(w)) { | ||
| failures.push('observer no longer inspects addedNodes for new bubbles'); | ||
| } | ||
|
|
||
| // 7. Anchors need their own inline bidi run (Codex round-3 P2, PR #2): | ||
| // unicode-bidi is not inherited, so block-level isolation alone leaves | ||
| // URL punctuation reorderable by the surrounding RTL context. | ||
| if (!/\[class\*="root_"\]:not\(\[class\*="thinkingContent_"\] \[class\*="root_"\]\) a \{\s*\n\s*unicode-bidi: plaintext;/.test(src)) { | ||
| failures.push('anchor unicode-bidi rule missing from Auto-mode CSS'); | ||
| } | ||
|
|
||
| if (failures.length) { | ||
| console.error('FAIL — dir-walker scope regression:'); | ||
| for (const f of failures) console.error(' - ' + f); | ||
| process.exit(1); | ||
| } | ||
| console.log('PASS — dir walker scoped to markdown roots, skip list covers all protected containers'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bumping the extension to 0.5.1 here leaves both version fields in
package-lock.jsonat 0.5.0. This makes the committed package metadata inconsistent and causes tooling that reads the lockfile to report the previous release; regenerate or update the lockfile so its top-level andpackages[""]versions are also 0.5.1.Useful? React with 👍 / 👎.