A selection-based dictionary tooltip browser extension (Manifest V3). Select any word on the web to see definitions, phonetics, and part-of-speech — without leaving the page.
- Selection tooltip — hover over any word on a page; after a configurable delay the tooltip appears with definitions from the dictionary.
- Inline bookmarking — save words for later; export to JSON or CSV and re-import.
- Pronunciation — speaker button in both the tooltip and popup; uses the Web Speech API.
- Lookup history — recent lookups shown in the popup search tab.
- Site exclusions — disable Etyr on any site directly from the tooltip or settings page.
- Dark-first theme — tooltip is dark translucent glassmorphic by default; light variant toggled via settings or
prefers-color-scheme. - Offline-first — ships with a curated offline dictionary; falls back to Wiktionary (
en.wiktionary.org) and then the Free Dictionary API (api.dictionaryapi.dev) when the offline dictionary has no match.
src/
content/
index.ts # Content script entry — wires selection detection, state machine, tooltip, messages.
selection/
detector.ts # Debounced selectionchange + mousedown listener.
state-machine.ts # Explicit lifecycle: IDLE → SELECTION_DETECTED → WAITING → RESOLVING → SHOWING → IDLE.
validate.ts # Low-confidence keyword filter + bounding-rect sanity.
tooltip/
TooltipRoot.tsx # React shell; owns #etyr-tooltip-root.
TooltipController.tsx # Translates machine state into React component props.
components/ # LoadingState, DefinitionList, SpeakerButton, BookmarkButton, icons.
styles/tooltip.css # Dark-first glassmorphic tooltip styles with animations.
styles/tooltip.css # Imported via the content build pipeline (Vite library mode).
background/
service-worker.ts # Message router for all extension actions (DICTIONARY_RESOLVE, BOOKMARK_*, etc.).
dictionary/
resolver.ts # Singleton resolution pipeline: offline → cache → online. Records history on success.
offline.ts # Curated ~250-entry in-memory dictionary.
online.ts # Fetch-based Wiktionary + Free Dictionary API wrapper with timeout and errors.
cache.ts # chrome.storage.local cache with TTL.
pronunciation.ts # Extension-side pronunciation via SpeechSynthesis (background fallback).
popup/
App.tsx # Three-tab shell: Home / Saved / Settings.
pages/ # HomePage (search + recent), SavedPage (filter / export / import), SettingsPage.
components/SearchResult.tsx # Pronunciation + bookmark controls for a resolved word.
styles/popup.css
options/
App.tsx # Full settings page: behavior toggles, exclusions list, data actions.
styles/options.css
shared/
types/index.ts # Public TypeScript types: DictionaryResult, MessageRequest/Response, Settings, etc.
constants.ts # MESSAGE_ACTIONS enum, DEFAULT_SETTINGS, STORAGE_KEYS.
utils.ts # formatRelativeTime, normalizeQuery, truncate, debounce, createId, slugify.
messaging.ts # Typed sendMessage helper wrapping chrome.runtime.sendMessage.
browser/
types.ts # getBrowserAPI() / isExtensionContext() — globalThis-based chrome/firefox detection.
api.ts # Safe wrappers for Chrome APIs (tabs.query, etc.).
storage/
database.ts # Low-level chrome.storage.local get/set wrappers.
bookmarks.ts # Saved words CRUD, dedup-by-query, JSON/CSV export helpers, import parser.
settings.ts # Merges persisted settings over defaults.
history.ts # Lookup history ring buffer.
exclusions.ts # Per-site exclusion list.
offline.lookup(query)
↓ (no match)
cache.get(query)
↓ (no hit)
online.lookup(query, abortSignal)
↓ (network error or not found)
return null → UI shows "not found" / "error" badge
Every successful resolution is written to history via addHistoryEntry.
The content script is a self-contained IIFE bundle built by vite.content.config.ts. It does not import shared code at runtime; instead all dependencies (React via Preact, utilities, state machine) are bundled in. The host page's DOM is never modified except for appending #etyr-tooltip-root (the tooltip mount point) inside a Shadow DOM-like container; no styles leak into the host page.
The content script begins a 1.5-second debounce after a word is selected (configurable via settings.lookupDelayMs). If the user changes the selection while waiting, the timer restarts with a fresh requestId, ensuring only the most recent selection ever resolves — no stale results.
npm install
npm run build # builds dist/- Open
chrome://extensions. - Enable Developer mode.
- Click Load unpacked and select the
dist/folder inside this repository. - The Etyr icon appears in your extensions toolbar; pin it for easy access.
npm run dev # parallel: builds popup+options on change + watches content libRe-load the unpacked extension in chrome://extensions to pick up changes (or enable chrome://extensions → Developer mode → Update).
npm run build:firefox # builds dist/, then runs scripts/prepare-firefox.mjs- Open
about:debugging#/runtime/this-firefox. - Click Load Temporary Add-on… and select
dist/manifest.json.
prepare-firefox.mjs converts service_worker → background.scripts and sets browser_specific_settings.gecko.id automatically.
| Permission | Why |
|---|---|
storage |
Persist settings, bookmarks, history, cache, and exclusions via chrome.storage.local. |
https://en.wiktionary.org/* |
Primary online dictionary provider (host_permissions). |
https://api.dictionaryapi.dev/* |
Free Dictionary API fallback (host_permissions). Required only when the offline dictionary and Wiktionary have no match. |
No other permissions are requested. The content script runs on <all_urls> purely to enable word selection on any page — it does not read or modify page content beyond appending the tooltip container.
npm test # vitest runFour test suites:
normalization.test.ts— query normalization (case, accents, whitespace, punctuation stripping).resolver.test.ts— offline → cache → online resolution chain and ordering.storage.test.ts— bookmarks CRUD, dedup, ring buffer history.exclusion-and-races.test.ts— site exclusion logic, requestId race prevention, re-selection restarts the delay timer.
| Script | Description |
|---|---|
npm run build |
Production build (popup + options + content lib + background). Output: dist/. |
npm run build:chrome |
Alias for build. |
npm run build:firefox |
Build + Firefox manifest transform. |
npm run dev |
Watch mode: parallel builds for popup/options and content. |
npm run generate-icons |
Regenerate public/icons/icon-{16,32,48,128}.png from public/icon.svg. |
npm run typecheck |
tsc --noEmit. |
npm test |
vitest run — unit + integration tests. |
- Create a new file in
src/dictionary/implementing alookup(query: string, signal?: AbortSignal): Promise<DictionaryResult | null>method. - Register it in
src/dictionary/resolver.ts— insert it into the resolution chain (offline → cache → your provider → online), or replaceOnlineDictionaryProviderentirely. - Adjust
src/shared/types/index.tsif your provider returns fields not currently inDictionaryResult.
The resolver handles caching automatically: any non-null result is written to cache before being returned, so you do not need to implement caching inside your provider.
public/icon.svg is the source. The generate-icons script uses sharp to rasterise it to 16, 32, 48, and 128 px PNGs. No design tools required.
We welcome contributions! Please see our Contributing Guide for details on how to set up the project locally, run tests, and submit pull requests. By participating in this project, you agree to abide by our Code of Conduct.
This project is licensed under the MIT License.