Back to Projects

Building "LookStuffUp": A Deep Dive

June 10, 2023 Browser Extension
LookStuffUp Extension Screenshot

The First Hurdle: The Tooltip vs. The Wild West of CSS

The very first feature was the most obvious one: show a tooltip with information. This sounds simple, but it’s an absolute minefield in extension development. How do you inject a UI element onto any webpage—from a minimalist blog to a complex, style-heavy web app—without it looking like a broken mess?

My first attempt was naive. I just injected my HTML and CSS directly into the page. It was a disaster. On some sites, my tooltip's CSS would be overridden by the page's global styles (div { box-sizing: content-box; } will ruin your day). On others, my styles would leak out and accidentally restyle parts of the host page.

The solution, and my first major "aha!" moment, was the Shadow DOM. It’s an incredible browser feature that lets you create a completely encapsulated, isolated DOM tree. My content.js script creates a single, empty <div> on the page to act as a host. Then, it attaches a shadow root to it: tooltipHost.attachShadow({ mode: 'open' });. Everything—my tooltip's HTML, its structure, and its styles—lives inside this shadow root. The page's CSS can't get in, and mine can't get out.

To load the styles, I used chrome.runtime.getURL('styles.css') to get the path to my stylesheet, fetched its content, and injected it into a <style> tag inside the shadow root. This pattern is a lifesaver for any extension that needs to render its own UI on a page.

The Brains of the Operation: A Waterfall of Data Sources

Okay, so I can show an empty box. What do I put in it? I knew from the start I didn't want to rely on a single source of information. I wanted the extension to feel "intelligent." So I built a waterfall system in background.js's searchSources function. It’s not just one API call; it’s a cascade of different sources, ordered by what I figured would be most relevant for a given query.

  1. Is it a single English word? The logic lang === 'en' && words.length === 1 checks for this. If it's a match, the first stop is the DictionaryAPI. It's free, fast, and gives a clean, structured definition with parts of speech and phonetics, which is perfect for simple word lookups.
  2. For everything else, let's try Google first. I wired it up to the Google Custom Search API. This presented a classic developer trade-off. To keep the extension free and avoid hitting a global rate limit, I couldn't hardcode my own API key. The best solution was to have users provide their own free keys via the options page. It's a bit of a setup hassle for the user, but it gives them the best possible results. I provided a DEFAULT_CSE_ID that searches the whole web, so it still works out-of-the-box, but a user-configured engine ID can provide much more specific results.
  3. Next up: Wikipedia. This is the king for proper nouns, historical figures, and general concepts. The Wikipedia API requires a neat two-step dance. First, I hit the opensearch endpoint to find the most likely page title for the user's query. This handles redirects and misspellings. Then, with the correct title, I make a second call to the query endpoint with prop=extracts to get a clean, introductory summary of the page. I also added a little enhancement: if a search in a specific language (e.g., German) fails, the searchWikipedia function calls itself again to retry in English, which covers a huge number of cases for technical terms or names.
  4. The Fallbacks: DuckDuckGo and Wiktionary. If all else fails, I have two more great sources. The DuckDuckGo Instant Answers API is fantastic and often has concise summaries. Wiktionary is a great source for definitions, especially for non-English words. Each of these required its own custom formatter (formatDuckDuckGoResult, formatWiktionaryResult) to parse their unique JSON structures.

This layered approach is the core of the extension's logic. It provides resilience — if one service is down or doesn't have a result, another one probably will.

Before the Search: Language Detection

How do I know which Wikipedia or Wiktionary to search? I needed simple language detection. I didn't want to bundle a heavy library for this, so I wrote a lightweight detectLanguage function. It uses simple regex tests for different Unicode character sets: /[\u0590-\u05FF]/.test(text) for Hebrew, /[\u4E00-\u9FFF]/.test(text) for Chinese, and so on. It's not foolproof, but for the purpose of picking the right API endpoint, it's fast, efficient, and has zero dependencies.

Performance, Polish, and Not Annoying the User

A tool you use constantly has to feel fast and seamless. Performance and good UX were top priorities.

  • Caching: The first search for a term might take a moment to hit all the APIs. But if you look it up again, it should be instant. I built a simple in-memory cache using a JavaScript Map. The getCacheKey function creates a unique key from the query and language (`${lang}:${query.toLowerCase()}`). To prevent it from growing forever, I capped it at MAX_CACHE_SIZE and implemented a simple eviction policy: if the cache is full, the oldest entry is deleted before adding a new one. All cached data also has a CACHE_DURATION of one hour.
  • Rate Limiting: It's surprisingly easy to select text many times in a row. To avoid spamming the APIs and getting a user's IP temporarily blocked, I implemented a simple rate limiter. It keeps an array of recent request timestamps and won't fire a new request if the limit (REQUEST_LIMIT) within a given period (REQUEST_PERIOD) is exceeded.
  • Debouncing: In content.js, the mouseup event can be very noisy. A user might select, adjust, re-select. Firing a network request on every single mouseup event would be incredibly wasteful. I wrapped the fetchContext call in a 300ms setTimeout. This debounce ensures that we only send a request when the user has paused for a moment, implying they've settled on their selection.
  • UI Polish & Options: The little things make a difference. I built out an options.html page to give users control. Using chrome.storage.sync, I save their preferences for dark mode, font size, and—a personal favorite—a "Draggable Tooltip" setting. The logic in content.js for the draggable tooltip was fun to write: on mousedown on the tooltip's header, it sets a flag and records the mouse offset. On mousemove, it updates the tooltip's left and top style properties. The setting is synced across all tabs and sessions.

The Cross-Browser Conundrum: Chrome vs. Firefox

I wanted this to work for as many people as possible. Thankfully, building a cross-browser extension is easier than ever.

All i did was a tiny compatibility check at the top of any script that called an extension API:
const runtimeAPI = typeof browser !== 'undefined' ? browser.runtime : chrome.runtime;

The other major difference is the manifest.json file.

  • Manifest Version: Chrome is on V3, while Firefox is still on V2.
  • Background Scripts: V3 requires a background.service_worker. V2 uses a background.scripts array.
  • Permissions: In V2, you can list host permissions directly in the permissions array. V3 splits them out into a separate host_permissions key.
  • Firefox Specifics: Firefox requires a browser_specific_settings key with a Gecko ID for publishing.

With a couple of small scripts to generate the correct manifest for each target, I was in business on both platforms.

What I Learned

Building LookStuffUp was a huge learning experience that I've taken into my professional work. It really hammered home a few key lessons:

  • Embrace the Platform: The Shadow DOM isn't just a curiosity; it's the right way to build isolated components for extensions.
  • Layer Your Logic: A multi-layered data fetching strategy is far more resilient and provides a better user experience than relying on a single source.
  • The Frontend IS a Distributed System: Client-side caching, rate-limiting, and debouncing aren't just for big server-side apps. They are critical for creating a responsive and responsible client-side tool.
  • Plan for Differences: Cross-browser support isn't the monster it used to be, but you have to plan for the small differences in APIs and manifest files from the start.

It's been incredibly rewarding to build a tool that solves a problem I had. It's not perfect, but it's something I use dozens of times a day. It's a testament to the power of a small idea and the fun of seeing a side project all the way through. I hope it helps a few other people break that 'open-a-new-tab' habit, too.