Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
# Reads the pinned version from packageManager in package.json.
- uses: pnpm/action-setup@v4

- uses: actions/setup-node@v4
- uses: actions/setup-node@v7
with:
node-version: 22
cache: pnpm
Expand Down
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Changelog

Selector changes get their own line. They are the change class most likely to cause
a field regression, so they should never be buried inside a general entry.

## 1.0.0

Initial release.

- Syntax highlighting for Google Chat code blocks, rendered into a shadow root beside
the original so message data is never modified.
- Copy button per block, copying the clean source with any language tag line removed.
- 15 bundled languages: bash, cpp, css, diff, go, java, javascript, json, markdown,
python, rust, sql, typescript, xml, yaml.
- Auto-detection gated on a relevance score, so low-confidence blocks render as plain
monospace instead of being mislabeled.
- Six themes plus Auto mode, which follows Chat's own appearance via background
luminance rather than `prefers-color-scheme`.
- Optional Gmail support, off by default, registered at runtime on user consent.
- Selectors: `article[role="code"]` (primary), `div.kaPZDd > article` (fallback),
`div.kaPZDd` (container), `div[role="main"]` (observer root). Captured 2026-08-06.
121 changes: 121 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# GCCH — Google Chat Code Highlighter

Syntax highlighting and a copy button for code blocks in Google Chat.

Chat renders fenced code blocks as flat monospace text with no coloring and no way
to copy them cleanly. GCCH fixes both, without touching the composer and without
altering message data.

## Quick start

Requires Node 20.11+ and [pnpm](https://pnpm.io) 9+ (`corepack enable` is enough —
the version is pinned via `packageManager` in `package.json`).

```bash
pnpm install
pnpm build
```

Then load the unpacked extension: `chrome://extensions` → enable Developer mode →
**Load unpacked** → select `dist/`.

```bash
pnpm test # unit tests
pnpm check # typecheck + lint + format + tests
pnpm dev # rebuild the content script on change
pnpm package # zip dist/ into releases/ for the Web Store
```

After `pnpm dev` rebuilds, hit reload on the extension card and refresh Chat.

pnpm is used deliberately rather than npm: its non-flat `node_modules` makes phantom
dependencies a hard error, so a package we never declared cannot quietly get bundled
into the shipped extension.

## How it works

A content script watches the message list, finds `article[role="code"]` blocks, and
renders a highlighted copy into a shadow root **beside** the original — the original
`<article>` is never modified, only hidden by our own stylesheet.

That one decision buys three things: Chat's native "copy message" stays byte-exact,
Google's Wiz renderer never sees mutated nodes it might choke on, and disabling the
extension restores the page perfectly without a reload.

Highlighting uses highlight.js with 15 explicitly registered languages. Blocks with
an explicit language tag use it; blocks without one go through auto-detection that is
gated on a relevance score, so logs and stack traces render as clean monospace rather
than being confidently mislabeled.

## Layout

```
src/
content/
index.ts lifecycle: settings, enable/disable, teardown
observer.ts MutationObserver, rAF-batched and throttled
detect.ts finds unprocessed blocks, rejects the composer
extract.ts article -> source text [pure]
language.ts language-tag parsing and aliases [pure]
highlight.ts hljs subset + auto-detect confidence gate
render.ts shadow host, copy button, theme swapping
theme.ts luminance-based light/dark detection [pure]
shared/
constants.ts EVERY Chat selector lives here
settings.ts storage schema, validated on read
logger.ts the only sanctioned console user
themes/ hljs theme CSS bundled as strings
popup/ settings UI
tests/
fixtures/ verbatim DOM captures from real Chat
docs/DOM-NOTES.md ground truth for the selectors
```

The `[pure]` modules take plain values and return plain values — no `chrome.*`, no
globals, no DOM mutation. They hold nearly all the logic that can actually be wrong,
and they are fully covered by tests.

## When Google changes Chat's markup

This extension sits on top of a DOM that can change without notice, so that case is
designed for rather than hoped against:

- **Every selector is in `src/shared/constants.ts`**, each tagged `DURABLE` (a11y
role, semantic tag) or `VOLATILE` (obfuscated class). Nothing else in the codebase
contains a Chat selector.
- **Failure is silent, not destructive.** Detection returns empty rather than
throwing, mounting bails when the container is missing, and a highlighter error
falls back to plain monospace with a working copy button. The worst realistic
outcome is that GCCH does nothing — never that Chat breaks.
- **A health check names the problem.** If a page has messages but no block matches
after several scans, GCCH logs a one-time warning pointing at `constants.ts`.
- **Turn on Debug logging in the popup** to see blocks found per scan, scan duration,
language decisions with relevance scores, and any selector-tier fallback taken.

Fixing a break: capture the new markup per the recipe in
[`docs/DOM-NOTES.md`](docs/DOM-NOTES.md), add it as a _new_ fixture beside the old
one, update the selector, and get both fixtures passing.

## Privacy and permissions

- `storage` — preferences only.
- `scripting` — registers the Gmail content script at runtime, if you opt in.
- `chat.google.com` — where the extension does its work.
- `mail.google.com` — **optional**, off by default, requested only when you enable
"Chat in Gmail" in the popup. It is not in `host_permissions` because that install
warning reads as "this extension can read your email".

Everything runs locally. No network requests, no analytics, no message content leaves
the page. highlight.js and all theme CSS are bundled — nothing is fetched at runtime,
and there is no `eval` anywhere, so the MV3 CSP story is trivial.

## Adding a language

Two places, both required:

1. Import and register it in `src/content/highlight.ts` (`LANGUAGES`).
2. Add its aliases to `ALIASES` in `src/content/language.ts`.

Auto-detection can only ever return a registered language, so step 1 also widens what
untagged blocks can be detected as. Update the list assertion in
`tests/highlight.test.ts`.
79 changes: 79 additions & 0 deletions docs/DOM-NOTES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Google Chat DOM notes

Ground truth for every selector in `src/shared/constants.ts`. Captured **2026-08-06**
from a live `chat.google.com` message.

If Chat's markup changes, update this file in the same commit as the selector fix.
The next person should not have to re-derive any of this.

## The code block

```html
<div class="kaPZDd">
<article role="code" class="Byzfyc">
<div>&nbsp;Prerequisites<span style="display: none;"></span></div>
<div><br /></div>
<div>&nbsp; export GH_PAT='&lt;your-github-pat&gt;'<span style="display: none;"></span></div>
</article>
<div class="n5Mo1b" exclude-from-clipboard="true">
<!-- Google's own "Toggle wrap" button -->
</div>
</div>
```

## What this means in practice

**`article[role="code"]` is the durable selector.** It is an accessibility role, not
a build-generated class. Google changes these rarely because doing so breaks screen
readers. `div.kaPZDd` and `.Byzfyc` are obfuscated and should be assumed disposable.

**One `<div>` per source line.** This is why `extract.ts` exists.
`article.textContent` concatenates every line with no separator and gives you one
long unusable string. `tests/extract.test.ts` asserts that divergence directly so
nobody "simplifies" the extractor back into a bug.

**Blank lines are `<div><br></div>`** — no text content at all.

**Indentation is `&nbsp;` (U+00A0), not spaces.** Both leading indentation and
repeated interior spaces. Extraction converts them back to U+0020; if it did not,
copied code would break shell heredocs and YAML.

**Every line carries an empty `<span style="display:none">`.** Today they are empty,
so skipping them changes nothing. They are clearly a slot for something, and if Chat
ever puts text in them it would silently corrupt every copied snippet. `extract.ts`
excludes `display:none` subtrees for that reason.

**`exclude-from-clipboard="true"` is Chat's own convention** for keeping injected UI
out of copied message text. Their toolbar carries it, so our shadow host carries it
too. Without it, copying a message would yield the code twice.

**Chat does not parse ` ```lang ` fences.** It strips the backticks and keeps
everything else verbatim, so a language tag survives as the literal first line of the
block. `language.ts` strips it, but only when the line is a bare token — `python` is
a tag, `python -m venv .venv` is a shell command.

## Ancestors worth knowing

| Selector | Notes |
| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `div[role="main"]` | Scrolling message list. Observer root. Chat **replaces this node** on space navigation, which is why `observer.ts` watches for the swap. |
| `[data-message-id]` | Present on sent messages. Used by the health check to distinguish "no messages" from "selectors broke". |
| `div.DTp27d` (`jsname="bgckF"`) | Message body wrapper. Volatile, currently unused. |
| `c-wiz[data-topic-id]` | Thread root. Volatile, currently unused. |

## Accepted limitation

Google's "Toggle wrap" button restyles the `<article>`, which we hide. So it is a
no-op on our render. Mitigation if it ever matters: listen for clicks on
`div.n5Mo1b button[aria-label="Toggle wrap"]` and toggle a wrap class inside our
shadow root — no dependency on Google's internal classes. Deliberately not in v1.

## Capturing a new fixture

When Chat's markup changes:

1. In Chat DevTools, select the block wrapper and run `$0.outerHTML`.
2. Save it as a **new** file in `tests/fixtures/`, alongside the old one. Do not
replace the old fixture — keeping both is how compatibility across Chat rollouts
stays proven rather than assumed.
3. Add it to the test matrix and adjust selectors until both fixtures pass.
115 changes: 115 additions & 0 deletions docs/PROJECT-STATUS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# Project status

Snapshot as of **2026-08-07**, v1.0.0. Written so this project can be picked up cold
months later without re-deriving anything.

Read alongside [`DOM-NOTES.md`](DOM-NOTES.md) (the Chat markup ground truth) and
[`../CONTRIBUTING.md`](../CONTRIBUTING.md) (workflow).

## Where things stand

**Working and confirmed in real Google Chat.** Code blocks are detected, highlighted,
wrapped, and copyable. Verified by the author on `chat.google.com` in dark mode.

**Automated gate is green:** 59 tests, typecheck, lint, format. `pnpm check` runs all
of it. CI runs the same plus a build and a bundle-shape assertion.

## Verified by hand

- Highlighting appears on real sent messages (markdown and bash blocks)
- Copy button works
- Rounded corners, tight padding, line wrapping
- Dark mode detection

## NOT yet verified — do these before trusting it further

These are the real remaining risks. None are covered by tests because they need a
live browser and a Google account.

1. **Native message copy is byte-exact.** Copy a message with a code block using
Chat's own menu, paste into an editor, compare against the original. This is the
most important one - it protects the user's actual data. It should hold, because
the original `<article>` is never modified, but it has not been confirmed since
the toolbar-hiding change.
2. **Wrap toggle off.** Click "Wrap" on a wide block; it should revert to horizontal
scrolling. Collapsing `overflow-x` and `white-space` inside a shadow root is the
least-trusted CSS path here.
3. **Composer untouched.** Type a ``` block in the composer without sending. Nothing
should be highlighted or altered until sent.
4. **Scroll performance.** DevTools Performance recording while scrolling a long
space. No long tasks attributable to the observer.
5. **Light mode / theme switching.** Switch Chat's own theme while leaving the OS
theme fixed. Highlighting must follow Chat, which is the whole point of luminance
detection over `prefers-color-scheme`.
6. **Space navigation.** Switch between spaces; blocks must still highlight after
Chat replaces `div[role="main"]`.
7. **Disable toggle.** Turn highlighting off in the popup; the page must return to
normal without a reload.
8. **Gmail path.** Entirely unexercised. `chrome.scripting.registerContentScripts`
after granting the optional permission has never run.
9. **Untagged auto-detect quality.** Send a raw stack trace with no language tag; it
should stay plain monospace rather than being mislabeled.
10. **Explicit language tag.** Send a block whose first line is `python`. The tag line
should vanish from the render and the block should be Python-colored. **This path
is only tested synthetically** - the original DOM capture had no language tag, so
the assumption that Chat preserves the tag as a literal first line is unconfirmed
against real markup.

## Tuning knobs

Both live in `src/shared/constants.ts`:

| Constant | Now | Change it if |
| -------------------- | --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `AUTO_RELEVANCE_MIN` | 8 | Untagged blocks stay plain when they should be colored (lower it), or logs get mislabeled as a language (raise it). This is a starting guess, not a tuned value. |
| `SCAN_THROTTLE_MS` | 150 | Scrolling stutters (raise), or new messages take too long to highlight (lower). |

## Decisions worth not relitigating

- **Shadow DOM sibling, original never mutated.** This is load-bearing. It keeps
Chat's native copy byte-exact, avoids provoking Google's Wiz renderer, and makes
disable/unmount perfectly reversible. Do not "simplify" it into an innerHTML
rewrite of the article.
- **Google's native toolbar is hidden.** Its "Toggle wrap" restyles the article we
hide, so it was dead, and it occupied the same corner as our controls. We ship our
own wrap toggle. (The original plan kept it visible; that was reversed once the
button collision showed up in practice.)
- **Wrap is on by default.** Chat panes are narrow; horizontal scrolling to read the
end of a line is worse than soft wrapping.
- **Atom One is the default theme.** GitHub's themes washed out at chat font sizes.
- **15 languages, registered explicitly.** Auto-detection can only ever return a
registered language, so the list bounds both features.
- **Everything is local.** No network calls at all - verified by grepping the built
bundle for `fetch`/XHR/WebSocket/dynamic import (zero hits). highlight.js and every
theme are bundled as strings. Keep it that way; it is the extension's main privacy
claim and what makes the CSP story trivial.

## Known limitations

- Inline code (single backticks) is not highlighted, only fenced blocks. Deliberate.
- Wrap state is per-block and resets on re-render; it is not persisted.
- Only the languages in `highlight.ts` are recognized.

## Repo notes

- **Git identity is repo-local**: `Dinesh Sutihar <dineshsutihar9@gmail.com>`, set via
`git config --local` so the machine's global Motorq identity is untouched. **A fresh
clone will not carry this** - `.git/config` is not cloned. Re-run:
```bash
git config --local user.name "Dinesh Sutihar"
git config --local user.email "dineshsutihar9@gmail.com"
```
- **pnpm, not npm.** Pinned via `packageManager`. `corepack enable` then `pnpm install`.
- `icons/*.png` are gitignored and regenerated by the `prebuild` hook, so a fresh
clone still builds.
- `tests/fixtures/chat-code-block.html` contains a real internal Helm/kubectl snippet
(namespace, resource group, AKS cluster, Azure subscription id). Harmless in a
private repo; **scrub it before making this public.**

## If it suddenly stops working

Almost certainly Google changed Chat's markup. Turn on **Debug logging** in the popup,
reload Chat, and check the console for `[GCCH]` lines - a `selector miss` or
`selector-fallback` warning names the failure. Then follow the fixture-capture recipe
in [`DOM-NOTES.md`](DOM-NOTES.md). Every selector is in `src/shared/constants.ts` and
nowhere else, so the fix is usually one line.
Loading
Loading