Skip to content
dsh.fish
Bundle

dsh-hebrew-rtl

Correct Hebrew RTL rendering for the DeepSeek Harness web GUI: per-block direction by dominant script, bidi-safe input fields, and RTL-aware Cmd+Left/Right line navigation.

Source
kfirsch
stars
1 stars
License
MIT
Updated
Updated yesterday

Readme

# dsh-hebrew-rtl

English | [עברית](./README.he.md)

Correct Hebrew RTL rendering for the [DeepSeek Harness](https://github.com/deepseek-harness/harness) web GUI: per-block direction by dominant script, bidi-safe input fields, and RTL-aware `Cmd`+`←`/`→` line navigation.

## The problem

The GUI renders every block left-to-right. For Hebrew that produces three distinct failures:

1. **Whole paragraphs read backwards.** A Hebrew sentence is laid out as an LTR paragraph, so its lines start on the wrong side and punctuation lands at the wrong end.
2. **Typing into a field runs the wrong way.** The `ask_user_question` custom-answer field inherits the page direction, so Hebrew typed into it flows left-to-right.
3. **`Cmd`+`←`/`→` feel inverted.** These shortcuts are *logical* in every browser: `←` goes to the line's logical start, which on an RTL line sits at the visual **right** edge. Pressing `←` jumps the caret to the far right.

The obvious CSS answer — `unicode-bidi: plaintext` — fixes only the easy half. It picks a block's direction from its **first strong directional character**, so a Hebrew sentence that opens with a Latin product name, a list number, or an emoji is still rendered LTR and still reads garbled.

## What this plugin does

### Per-block direction by dominant script

For each block element (`p`, `ul`/`ol`, `h1`–`h6`, `blockquote`, `table`) it drops code-like tokens from the block's text, then counts Hebrew-majority **words** against Latin-majority words in what remains:

| Content | Result |
| --- | --- |
| No Hebrew prose | No override — `unicode-bidi: plaintext` stays in charge |
| Hebrew words > Latin words | `direction: rtl` + `text-align: right` |
| Latin words > Hebrew words | `direction: ltr` + `text-align: left` |
| Equal (both present) | `direction: rtl` — a block with as much Hebrew as Latin is Hebrew text quoting English |

Forced blocks get `unicode-bidi: isolate`, so the Unicode Bidi algorithm still lays out the minority-script runs correctly **inside** the chosen paragraph direction:

```
היום בדקנו את הפלאגין החדש עם DSH והכול עבד מצוין.
→ RTL, and "DSH" stays in place, unreversed

This is a test sentence with שלום in the middle.
→ LTR, and "שלום" stays in place, mid-sentence
```

Dominance is the rule because both simpler alternatives fail in an obvious way. First-strong alone mis-renders any Hebrew paragraph that does not *begin* in Hebrew. "Any Hebrew character forces RTL" over-corrects in the other direction: an English sentence carrying one Hebrew word flips to RTL and its English runs come out reversed.

**Why words, and why code tokens are excluded.** Counting raw letters breaks on technical Hebrew prose. A single identifier can outweigh a whole paragraph — `git+https://git@github.com:kfirsch/...#<sha>` contributes 44 Latin letters by itself, and a commit sha another 40 — so a Hebrew sentence that merely *cites* a URL was rendered LTR. Identifiers, URLs, paths, shas and `15/15`-style ratios are therefore stripped before counting; they are still laid out normally, they just no longer vote on the paragraph's direction. Counting whole words rather than letters follows from the same reasoning: a three-letter Hebrew word says as much about the sentence's language as `credential` does. The stripping is deliberately conservative — an ordinary Latin word standing alone is never treated as code, so genuine English prose still counts in full.

The heuristic is not infallible on a block that is genuinely half-and-half after stripping (a short line of mostly commit hashes with two Hebrew words, say). Those fall back to first-strong rather than guessing.

**Composite elements are judged whole, never by their parts.** This one rule was learned three times over:

- **Tables.** Judging each `td` separately gave a six-row status table three different verdicts — Hebrew label cells went RTL while the `HEAD` and `Commits` cells beside them stayed LTR — so the label column changed edges from row to row. Column *order* is a property of the table rather than the cell, so per-cell directions also fight the column order itself.
- **Lists.** The same failure one level down. In a Hebrew numbered list, a short all-Latin item such as `push` has no Hebrew to weigh, so it got no verdict at all, fell back to the page's LTR, and jumped to the opposite margin from its RTL siblings — breaking the numbering column.
- **Formulas.** KaTeX renders a formula as hundreds of nested `<span>`s whose horizontal positions it computes itself. Letting each one pick its own direction tore formulas apart: `(idle + rightsizing)` and `$870/month` came out reordered into nonsense. `.katex` and its whole subtree are excluded — KaTeX handles bidi itself.

So the direction is decided once per composite, from its whole text, and every part inherits it. `unicode-bidi: isolate` still lays out each part's own content correctly within that direction.

Blocks are re-evaluated through a `MutationObserver` as text streams in, coalesced with `requestAnimationFrame` so streaming does not trigger a scan per character.

### Bidi-safe input fields

The composer and the `ask_user_question` custom-answer field are each a **two-layer stack**: a visible or measuring mirror layer plus a real `<textarea>` sharing one grid cell. Both layers must carry *identical* bidi metrics, or their computed heights and wrapping diverge and the field mis-sizes or the caret drifts away from the glyphs.

Both layers therefore receive `unicode-bidi: plaintext` together — never the textarea alone — so each line's direction follows what is actually typed into it.

### RTL-aware line navigation

A capture-phase `keydown` listener intercepts `Cmd`+`←`/`→` **only when the caret's current line is RTL**, and swaps them so each arrow points where the caret visually goes. Lines that are not RTL are left completely untouched. Shift-extension and selection direction are preserved.

## Install

```sh
dsh plugin --profile web add github:kfirsch/dsh-hebrew-rtl
```

Then restart `dsh web` and hard-refresh the browser tab.

To pin an exact commit (recommended — a later push then cannot silently change what runs):

```sh
dsh plugin --profile web add github:kfirsch/dsh-hebrew-rtl#<commit-sha>
```

The package ships plain hand-written JavaScript in `lib/` with **no build step**, so installing from GitHub needs no `allowBuilds` approval.

### Uninstall

```sh
dsh plugin --profile web remove dsh-hebrew-rtl
```

## Scope: Hebrew only, deliberately

The direction rule generalises cleanly to every RTL script — Arabic and its languages, Syriac, Thaana, N'Ko, Adlam — and that version was written and passed a 16-case script matrix. It was then **reverted on purpose**: neither the author nor a reviewer can proof-read those scripts, and shipping text-direction handling that nobody involved can verify is worse than not shipping it. The package name says what it actually supports.

A pull request adding another script is welcome **from someone who reads it** and can confirm the rendering by eye.

## Compatibility

- **Surface:** the `web` profile (`dsh web`). No host service, no configuration, no network access.
- **What it never touches:** `contenteditable` regions and code blocks — code stays LTR regardless of its content.
- Tested against dsh `0.1.1-rc.2`.

## License

[MIT](./LICENSE) © Kfir Schneider

## For reviewers: why there is no build step

`lib/` is not build output — it is the hand-written source, in the exact wire
format the DSH client module system loads. There is deliberately no `src/`, no
`scripts.build` and no `scripts.prepack`, so a clean checkout is already the
artifact and a GitHub install needs no `allowBuilds` approval.

`plugin_check` flags all three of those as warnings on the assumption that a
plugin is compiled; they are inapplicable here rather than unaddressed. The
package passes with **no errors**.

Run the checks yourself:

```sh
npm test          # smoke-tests the direction rule and the caret swap
```

Install

dsh plugin --profile web add github:kfirsch/dsh-hebrew-rtl

Profile: web

  • This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.
Source