This is the first devlog for kiho.
kiho is a browser extension I build in my spare time, meant to help me read and learn English at the same time. It has actually already shipped its first version to the Chrome Web Store, so this first entry is more of a look back at how it got here.
I dogfood kiho every day, shipping updates straight from my own friction as a user. But because of a few things still pending on the release side — and some other historical reasons — I wanted to start this devlog series to record some of the details and the progress: how it came to be, how it got to today, and maybe where it's headed, so that looking back later comes with a clear thread.
I really enjoy writing these notes... about life, about work, about all kinds of odds and ends — including my own ignorance. I hope I can keep the habit of writing alive as AI reshapes everything, and enjoy the process along the way. And if it happens to help you, all the better.
Or if you spot something I got wrong, feel free to reach out and teach me a better way.
Origin
kiho comes from my mother tongue, Taiwanese Hokkien: kì-hō (記號) — a mark you leave to clearly label something so you can recognize it at a glance later. Every word you look up leaves a mark on the page, and the next time you come back to it, the mark is right there waiting.
Before kiho, there was another project with the same goal: LinguaLens:
- Lingua: Latin, meaning "language"
- Lens: a lens, an optical lens
Put together — looking at a web page through a layer of language.
That was the first extension I built by following the official tutorial, and my earliest prototype. But for a few reasons (mostly my own ignorance), it didn't last.
The Problem
My mother tongue isn't English, and I'm not particularly fluent or native with it either. But between work and personal interest, I browse English sites, docs, files, and videos every day. When I hit a word or phrase I don't know, my flow used to be roughly:
- Select the word or phrase
- Run it through Chrome's Google Translate, or paste it into Claude Chat and ask the AI to break it down and explain
- Copy it into my notes and tidy it up
- Switch back to the original and try to find my place again
Whatever tool I used, it never felt smooth to me, because:
- The context switch broke the reading.
- Words lived in my notes, so whenever I forgot one I had to dig back through the notes to find it.
- I kept re-noting the same words.
- Looked-up words scattered across a pile of AI chats — nothing ever accumulated.
- Rereading the same article weeks later, I couldn't remember which words I'd already looked up.
- More often than not, the word I need to look up only makes sense together with its context — that's what lets the AI explain it accurately.
- Google Translate's quality usually doesn't match what the sentence actually means in context.
- What I want isn't yet another whole-page translation.
- I had to manually add each word into a tool like Anki to schedule reviews.
And the next time I open the same page, there's a good chance I go through the whole flow again (because I've forgotten the words, again).
So I needed a way that fit me, to tighten up this flow.
Choices
Let me list the tech choices so far:
- WXT — the extension framework. What it compiles down to is just a plain Manifest V3 extension.
- React 19 + TypeScript — for all the UI.
- Tailwind CSS v4 + shadcn/ui — for the components.
- Playwright — for end-to-end tests.
I'm a backend engineer, and I don't go especially deep on front-end tech choices, so if I have to explain why I picked these, roughly:
- I didn't want to build the extension separately for each browser — though Safari is probably out of reach anyway.
- I'm building alongside Claude, so I want a stack Claude is most familiar with.
- I know some Tailwind CSS, and I wanted shadcn/ui's design, because it genuinely looks great.
Shadow DOM
Looking back, the first feature was also where I was most ignorant.
kiho draws UI on top of the page you're reading — when you select a word or phrase, a small "look up" button pops out, along with a hover card that shows the explanation.
Naively, I figured I'd just capture that event inside the extension and then draw the extension's UI onto the page. What actually happened was this: no matter how much I adjusted the UI, I couldn't get the result I wanted. This was also the main reason the first extension, LinguaLens, failed.
Later I understood: the most obvious risk with injecting UI is CSS. Every page has its own styles, and some pages are aggressive enough to turn a carefully-made little tooltip into a mess. The other direction matters just as much — I don't want kiho's CSS leaking out and wrecking someone else's article layout.
The standard fix is Shadow DOM: render the UI inside a shadow root, which encapsulates the styles so the page's own styles can't get in and mine can't leak out. WXT happens to give you a helper for exactly this — createShadowRootUi — and that's what kiho uses.
And just like that, the styles were isolated.
Font
To make my eyes a little more comfortable while reading these explanations, I picked two web fonts:
- Inter — for the UI components
- Source Serif 4 — for the reading text
But for some reason they never loaded correctly; the UI kept falling back to the system font, with no error or warning at all.
Later I understood: an @font-face rule declared inside a shadow root is ignored by Chrome. Font loading resolves against the document, not the shadow tree, so an @font-face that only lives in a shadow root's stylesheet is never registered.
The standard fix: declare the fonts on document.head, then reference them by font-family name from inside the shadow root.
On top of that, there's an MV3-specific problem.
The src in an @font-face rule can't point straight at the font file the way a normal web page would — page context can't reach the extension's bundled URL:
- The vendored font files live at the extension's own
origin: chrome-extension://<extension-id>/fonts/.... - But the @font-face CSS is attached to the page's document, e.g. https://example.com. When the browser loads src: url(...), it resolves and fetches from the page's context, not the extension's.
So how do you get at it? You resolve it through browser.runtime.getURL(...), and for that URL to be loadable from the page, the font files have to be declared as web_accessible_resources in the manifest:
// wxt.config.ts
web_accessible_resources: [
{ resources: ['fonts/*.woff2'], matches: ['*://*/*'] },
],Then, on each page, register the font faces at the document level, pointing at those web-accessible copies:
const interUrl = browser.runtime.getURL('/fonts/inter-latin.woff2');
const style = document.createElement('style');
style.id = 'kiho-overlay-fonts';
style.textContent = `
@font-face {
font-family: 'Inter Variable';
font-weight: 100 900;
font-display: swap;
src: url('${interUrl}') format('woff2-variations');
}`;
(document.head ?? document.documentElement).appendChild(style);Persistence
Even though I spent a lot of time — and kept hitting walls — figuring out Shadow DOM, the overlay, fonts, and the MV3-specific stuff, the truly hard part isn't any of those. Those are just things you have to understand to build an extension.
kiho's main feature — and the thing I think is actually worth doing — is persistence.
A mark that survives across visits: one that doesn't disappear just because I closed the browser or the page this time, only to lose it when I open the same page again next time. And because those marks don't vanish, you can layer a review loop on top, which makes the whole learning flow complete.
Of course, ignorant me once again figured I'd just save it to storage and be done.
But once you've marked a word, kiho has to be able to find that exact stretch of text again the next time you open the page — except:
- the page may have changed,
- the DOM shifted,
- the site is a single-page app that re-renders as you navigate,
- the content loaded in late,
- the text is hiding inside another shadow root.
Being able to reliably re-find that stretch under all of these is kiho's real core — and what actually solves my own pain point. So my basic principle is:
Better to show nothing than to place it wrong.
A mark that quietly fails to appear is only a small disappointment. But a mark placed in the wrong spot — an underline on the wrong phrase, last week's note attached to a sentence it has nothing to do with — ends up the same as re-noting the same word over and over in my notebook: chaos. So every layer is deliberately built to return "not found" rather than guess.
Claude Code
As someone who doesn't know front-end engineering, my main way of building this is collaborating with Claude Code. Through it, I can quickly get suggestions for solving a problem, and adjust as I go.
I set up a small team: Orchestrator, Engineer, QA, and Reviewer. Each has its own role, and they follow a shared set of rules — Karpathy-style quality and security review, a deliberate harness design, a bounded loop limit, proactively escalating problems, and preserving every round of experience and process into docs.
As a backend engineer, my role in this process is no longer to be the one hand-writing the code. It moved to writing specs, judging, and deciding — and to owning the parts I don't fully understand. Like the ones above: Shadow DOM, fonts, MV3, and so on. I don't need to know them ahead of time, but I do need to recognize when a fix is correct and worth keeping.
Closing
kiho's goal is to become a learning tool whose marks persist across visits, making the whole learning process smoother and more complete.
This is the first devlog, so I skipped some topics — the review flow, the Traditional-Chinese (Taiwan-usage) rule, security, and so on. I'm holding myself to recording them one by one across this series.