<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: On Pounch Man</title>
    <description>The latest articles on DEV Community by On Pounch Man (@liuxinyea).</description>
    <link>https://dev.to/liuxinyea</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F906782%2F5673f832-5fd3-4873-a012-9238eec8a533.jpeg</url>
      <title>DEV Community: On Pounch Man</title>
      <link>https://dev.to/liuxinyea</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/liuxinyea"/>
    <language>en</language>
    <item>
      <title>I Built an AI Tarot Demo in Two Hours. Shipping the Product Was a Different Job.</title>
      <dc:creator>On Pounch Man</dc:creator>
      <pubDate>Fri, 18 Sep 2026 09:59:28 +0000</pubDate>
      <link>https://dev.to/liuxinyea/i-built-an-ai-tarot-demo-in-two-hours-shipping-the-product-was-a-different-job-23ck</link>
      <guid>https://dev.to/liuxinyea/i-built-an-ai-tarot-demo-in-two-hours-shipping-the-product-was-a-different-job-23ck</guid>
      <description>&lt;p&gt;My wife was interested in tarot cards, and I was a frontend developer looking for an excuse to build something. I put together a page with a deck, a card-flipping animation, and an AI-generated reading.&lt;/p&gt;

&lt;p&gt;With AI assistance, the basic flow worked in about two hours.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5v6x68r5tomwy08k3abg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5v6x68r5tomwy08k3abg.png" alt=" " width="800" height="889"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I thought authentication and payments would get it ready to launch. Then the questions started: What happens when someone switches devices? What if a paid reading stops halfway through? Can I change a guided exercise without changing the meaning of someone’s saved answers?&lt;/p&gt;

&lt;p&gt;Eventually, another question became harder than any of those: how do I get people to use it?&lt;/p&gt;

&lt;p&gt;The project is now &lt;a href="https://tarot.auraflame.tech/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=demo_to_product" rel="noopener noreferrer"&gt;Mystic Journey&lt;/a&gt;. It includes a daily card ritual, guided explorations, a personal journal, and an optional companion board.&lt;/p&gt;

&lt;p&gt;I’m still looking for early users. This is a development retrospective—and, openly, an invitation to try what I’ve built.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I changed the product
&lt;/h2&gt;

&lt;p&gt;The original experience was straightforward: ask a question, draw cards, read the interpretation, leave.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft52sjei92pek8k4p6rqy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft52sjei92pek8k4p6rqy.png" alt=" " width="800" height="808"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;But a general-purpose AI can already explain tarot cards. A nicer animation wasn’t enough to explain why someone should return to my website.&lt;/p&gt;

&lt;p&gt;I started thinking about the experience around the reading. Writing in an empty journal can be surprisingly difficult. A card, a specific question, or a few choices might make it easier to begin reflecting.&lt;/p&gt;

&lt;p&gt;That became the daily ritual: choose your mood, reveal your daily card, read a reflection prompt, and optionally write a few words. The aim is to make a small check-in easy, even when you don’t have a major question to ask.&lt;/p&gt;

&lt;p&gt;For people who want to go further, I added six guided themes covering space, boundaries, change, confidence, connection, and direction. Each has five short chapters: notice the situation, consider another perspective, choose a small action, identify obstacles, and find support.&lt;/p&gt;

&lt;p&gt;These explorations use curated branches rather than generating every step with AI. Earlier choices influence later options. Personal notes are optional and don’t automatically trigger a model call.&lt;/p&gt;

&lt;p&gt;This gives me content I can review and improve consistently. Open-ended AI readings still have a place, but they don’t need to power every interaction.&lt;/p&gt;

&lt;p&gt;Completed rituals, explorations, and explicitly saved readings go into a personal Journey timeline. The intention is to give people something worth revisiting: their own thoughts and choices, not just a list of cards.&lt;/p&gt;

&lt;p&gt;I also added an opt-in weekly companion board. It shows names, avatars, and activity points, but not private reflections. Points are separate from purchases and don’t depend on someone’s mood or how much they write.&lt;/p&gt;

&lt;p&gt;There’s a tension here: even a gentle leaderboard can create comparison. Whether it feels supportive is something I need users to tell me. These are product hypotheses, not proven retention improvements.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small stack, with more state than I expected
&lt;/h2&gt;

&lt;p&gt;The stack is Nuxt 3 for the statically generated frontend, a separate Fastify API, SQLite, and DeepSeek for generated readings. Nginx routes API requests to the backend.&lt;/p&gt;

&lt;p&gt;For the current scale, I want something I can deploy and troubleshoot on my own. The difficult work has mostly been in the behavior of the application, not the number of services.&lt;/p&gt;

&lt;p&gt;A daily card becomes account data once it connects to reflections, history, and rewards. Someone opening the site on their phone should see the same record they started on their laptop. Completion and rewards need consistent server-side state.&lt;/p&gt;

&lt;p&gt;Disabling a button helps the interface, but it doesn’t prevent retries or requests from another tab.&lt;/p&gt;

&lt;p&gt;Guided exploration saves have a similar problem. If a laptop has already saved the next chapter while a phone still has an older revision, accepting the phone’s write could overwrite newer progress.&lt;/p&gt;

&lt;p&gt;Relevant save requests carry a revision. A stale write gets a conflict response, and the frontend keeps the draft while offering to reload saved progress. Identical committed retries return the existing result.&lt;/p&gt;

&lt;p&gt;These are easy details to overlook when you only test one browser tab on a reliable connection.&lt;/p&gt;

&lt;h2&gt;
  
  
  The content needed versioning, too
&lt;/h2&gt;

&lt;p&gt;One of the more interesting problems came from changing the exploration itself.&lt;/p&gt;

&lt;p&gt;Suppose a saved answer says the user selected option &lt;code&gt;1&lt;/code&gt;. If I reorder the options next week, that stored value can appear to mean something different.&lt;/p&gt;

&lt;p&gt;This is a simplified example of the problem:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Version 1, option 1: Take time to reflect privately
Version 2, option 1: Talk to someone you trust
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The number didn’t change. The user’s apparent answer did.&lt;/p&gt;

&lt;p&gt;An exploration therefore retains the content version it started with. Old option meanings need to remain stable, and the live chapter and saved recap need to interpret answers through the same branch logic.&lt;/p&gt;

&lt;p&gt;Once copy gives meaning to stored answers, editing it is also a data-compatibility decision.&lt;/p&gt;

&lt;p&gt;Weekly reflections brought another state problem. A generation request can time out, a retry can start, and the original request can finish late. Without a guard, that older result could overwrite the newer one.&lt;/p&gt;

&lt;p&gt;The implementation uses a generation state, an expiring lease, and a version condition when writing the result. A short database transaction protects state changes; it doesn’t stay open while waiting for the model.&lt;/p&gt;

&lt;h2&gt;
  
  
  A paid AI request has more than two outcomes
&lt;/h2&gt;

&lt;p&gt;The demo treated generation as success or failure. Streaming makes that less tidy: a request can return some text, stall, lose its client connection, or finish without usable content.&lt;/p&gt;

&lt;p&gt;Deep readings use server-sent events. The server applies a timeout and attempts to cancel the upstream request if the client disconnects early. Only a complete successful result goes into the result cache.&lt;/p&gt;

&lt;p&gt;If generation fails after credits have been deducted, the error path restores those in-app credits and provides a local card interpretation. Restoring credits is separate from refunding a payment through the payment provider.&lt;/p&gt;

&lt;p&gt;Weekly reflections also have a model-call budget and a local fallback. A free feature still needs a cost boundary.&lt;/p&gt;

&lt;p&gt;This isn’t a complete recovery system. For example, a process crash after a deduction cannot be recovered by a &lt;code&gt;catch&lt;/code&gt; block in that same process. Durable job state and reconciliation remain areas to improve.&lt;/p&gt;

&lt;p&gt;That distinction matters: having an error handler is not the same as having verified recovery from every failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security extends beyond the prompt
&lt;/h2&gt;

&lt;p&gt;The model generates text. It doesn’t decide account permissions, payment status, or credit balances, and it has no database or payment tools.&lt;/p&gt;

&lt;p&gt;That limits the consequences of a manipulated response, but it doesn’t solve prompt injection. User input can still interfere with the intended task, and model output needs to be treated as untrusted content when it reaches the UI.&lt;/p&gt;

&lt;p&gt;The service currently has request rate limiting, body-size limits, and account and credit checks before paid generation. These are basic controls, not a claim of comprehensive protection. IP-based limits have limitations, and input handling and output rendering require their own review.&lt;/p&gt;

&lt;p&gt;For accounts, passwords use salted hashes, the database stores hashes of session tokens, and session cookies use HttpOnly and SameSite settings, with Secure enabled for production. Verification codes expire and have attempt limits.&lt;/p&gt;

&lt;p&gt;Payments have a separate trust boundary. The backend confirms payment status with the provider and verifies webhook signatures. Order-status requests check ownership. Since polling and a webhook can both confirm the same payment, crediting an order must be idempotent.&lt;/p&gt;

&lt;p&gt;Privacy also affects ordinary feature decisions. The feedback form doesn’t automatically attach someone’s journal entries. Analytics can record that a reflection was saved without collecting its text.&lt;/p&gt;

&lt;p&gt;I still have security work to do. Keeping track of what each component can access—and where personal content goes—has been more useful than treating safety as a few lines in a system prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance and localization added their own work
&lt;/h2&gt;

&lt;p&gt;Static generation lets me serve public pages separately from account APIs, but it doesn’t automatically make the experience fast. Images, animations, authentication checks, and API latency still matter on a phone.&lt;/p&gt;

&lt;p&gt;The Journey timeline is paginated, details load on demand, and uploaded avatars are cropped and compressed to WebP in the browser. Streaming also needs the proxy to cooperate: response buffering can hide the incremental output the server is sending.&lt;/p&gt;

&lt;p&gt;The interface supports eight languages, while the card data is primarily English and Chinese, with English fallback elsewhere. Those are different levels of localization, and I shouldn’t present them as equivalent.&lt;/p&gt;

&lt;p&gt;A recent bug was a good reminder of the extra surface area: an email placeholder contained a literal &lt;code&gt;@&lt;/code&gt;, which the i18n message compiler interpreted as special syntax. The JSON was valid, but the page failed. A JSON parse check alone couldn’t catch it.&lt;/p&gt;

&lt;p&gt;Even a small copy change needs the right kind of validation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shipping didn’t bring users automatically
&lt;/h2&gt;

&lt;p&gt;I’ve shared the product on Solo, 出海栈, 小众软件, and Indie Hackers. That has given it places to be discovered, but I don’t yet have enough evidence to call any channel a repeatable source of users.&lt;/p&gt;

&lt;p&gt;I’m also learning to separate people who enjoy a development story from people who want the product. Developers may find concurrency handling interesting. A potential user wants to know what they can do, how much effort it takes, and whether their writing stays private.&lt;/p&gt;

&lt;p&gt;There’s overlap, but a well-read technical post doesn’t prove demand.&lt;/p&gt;

&lt;p&gt;My next experiments are technical retrospectives like this one, short product demonstrations, and content built around concrete reflection scenarios. Search is another ongoing task: a readable sitemap and a renderable page are necessary pieces, but I still need public content that answers something people actually look for.&lt;/p&gt;

&lt;p&gt;What I want to measure is the path after a visit: does someone complete a first ritual, continue to another exploration chapter, or return a few days later? Knowing where that stops should help me choose between improving the entry flow, the content, or the acquisition channel.&lt;/p&gt;

&lt;p&gt;Writing more code is the comfortable option. It gives me a visible result. Asking someone to try the product can end with “I’ll take a look” and nothing else. I’m trying not to mistake the comfort of development for evidence that another feature is needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the project is now
&lt;/h2&gt;

&lt;p&gt;Mystic Journey is still early. I’m looking for a small group of people willing to try it and tell me what feels useful, confusing, or unnecessary.&lt;/p&gt;

&lt;p&gt;The daily ritual and guided explorations are free; deeper AI readings use credits. If tarot is unfamiliar, you can start with an exploration theme that fits something you’re thinking about.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://tarot.auraflame.tech/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=demo_to_product" rel="noopener noreferrer"&gt;Try Mystic Journey&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Specific feedback would help most: where you got stuck, what didn’t make sense, or where you stopped wanting to continue. There’s a feedback form in the app, and comments here are welcome too.&lt;/p&gt;

&lt;p&gt;If you’ve taken a side project beyond the demo stage, I’d also like to hear how you found your first users who came back.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>sideprojects</category>
      <category>nuxt</category>
    </item>
    <item>
      <title>I Built TGDown, a Chrome Extension for Downloading Telegram Web Media</title>
      <dc:creator>On Pounch Man</dc:creator>
      <pubDate>Thu, 17 Sep 2026 03:17:08 +0000</pubDate>
      <link>https://dev.to/liuxinyea/i-built-tgdown-a-chrome-extension-for-downloading-telegram-web-media-1j55</link>
      <guid>https://dev.to/liuxinyea/i-built-tgdown-a-chrome-extension-for-downloading-telegram-web-media-1j55</guid>
      <description>&lt;p&gt;I built &lt;strong&gt;TGDown&lt;/strong&gt;, a Chrome extension that adds download controls for images and videos in Telegram Web.&lt;/p&gt;

&lt;p&gt;The original product goal was simple: when users browse a Telegram chat, channel, or search result, they should be able to save media without repeatedly opening the viewer, fighting the context menu, or handling every item one by one.&lt;/p&gt;

&lt;p&gt;The implementation was less simple.&lt;/p&gt;

&lt;p&gt;Telegram Web is a large, fast-moving single-page application. An extension needs to add UI without breaking scrolling, media interactions, theme behavior, or Telegram's own event handling. This post is about the architecture behind TGDown — and a dark-mode bug that forced me to rethink how its in-page UI was mounted.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnu20dk92vc8q5a0mgl6d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnu20dk92vc8q5a0mgl6d.png" alt="TGDown" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What TGDown does
&lt;/h2&gt;

&lt;p&gt;TGDown supports both Telegram Web interfaces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;web.telegram.org/a/*&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;web.telegram.org/k/*&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It provides:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One-click image and video downloads inside chats&lt;/li&gt;
&lt;li&gt;Batch downloads from Telegram search results&lt;/li&gt;
&lt;li&gt;A popup panel that lists detected media&lt;/li&gt;
&lt;li&gt;Warnings for very large or long videos before a browser download starts&lt;/li&gt;
&lt;li&gt;No separate TGDown account or subscription&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fowumd522g2mg6fkoh4sv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fowumd522g2mg6fkoh4sv.png" alt="TGDown Batch downloads" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The extension architecture
&lt;/h2&gt;

&lt;p&gt;TGDown is a Manifest V3 extension built with TypeScript and Vue.&lt;/p&gt;

&lt;p&gt;There are three execution contexts involved:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Content script&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Runs alongside Telegram Web, watches the DOM for supported images and videos, and mounts download controls.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Page-context script&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Some downloads need Telegram Web's existing authenticated browser session. Since a content script is isolated from the page's JavaScript context, TGDown injects a small page-context script and communicates with it through DOM events.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Extension pages&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
The toolbar popup is a Vue application. It lists media detected while browsing and can trigger a download from the active Telegram tab.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This separation keeps Telegram Web responsible for its own session, while the extension stays isolated from the application's internal code as much as possible.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dark-mode white-screen bug
&lt;/h2&gt;

&lt;p&gt;One of the most interesting bugs appeared only in Telegram's dark theme.&lt;/p&gt;

&lt;p&gt;My first approach for modal-style UI used a full-screen &lt;code&gt;iframe&lt;/code&gt;. The iframe had a transparent background, which seemed like a safe way to isolate extension styles from Telegram's styles.&lt;/p&gt;

&lt;p&gt;In practice, it was not safe.&lt;/p&gt;

&lt;p&gt;On a dark Telegram page, users would occasionally see the entire page turn white. It looked as if Telegram had failed to load, but the problem was the extension UI.&lt;/p&gt;

&lt;p&gt;The sequence was roughly:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A full-screen iframe was inserted above Telegram.&lt;/li&gt;
&lt;li&gt;The browser created the iframe document.&lt;/li&gt;
&lt;li&gt;Before the iframe application had initialized — or when initialization failed — the iframe document rendered the browser's default page canvas.&lt;/li&gt;
&lt;li&gt;That canvas was white, even though the iframe element itself was configured as transparent.&lt;/li&gt;
&lt;li&gt;Because the iframe covered the viewport, it hid Telegram completely.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The bug was much easier to notice in dark mode because a white flash or white overlay has maximum contrast against Telegram's dark UI. In light mode, the same failure could be almost invisible.&lt;/p&gt;

&lt;p&gt;The key lesson was simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A transparent iframe element does not guarantee a transparent iframe document.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The fix: Shadow DOM instead of a full-screen iframe
&lt;/h2&gt;

&lt;p&gt;I removed the full-screen iframe from the Telegram page UI path.&lt;/p&gt;

&lt;p&gt;TGDown now mounts its in-page controls in a Shadow DOM host. This preserves CSS isolation while avoiding a separate document with its own default rendering surface.&lt;/p&gt;

&lt;p&gt;The host is also designed not to interfere with Telegram:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.tgdown-host&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;pointer-events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.tgdown-host&lt;/span&gt; &lt;span class="nc"&gt;.download-button&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
&lt;span class="nc"&gt;.tgdown-host&lt;/span&gt; &lt;span class="nc"&gt;.modal&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
&lt;span class="nc"&gt;.tgdown-host&lt;/span&gt; &lt;span class="nc"&gt;.toast&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;pointer-events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The host itself ignores pointer events. Only actual TGDown controls opt back in, so Telegram remains clickable and scrollable everywhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping the integration conservative
&lt;/h2&gt;

&lt;p&gt;Telegram Web has its own click handling, overlays, media viewer, and virtualized message list. A browser extension can easily become too aggressive: global CSS overrides, broad event listeners, or page-wide &lt;code&gt;z-index&lt;/code&gt; layers may fix one download issue while breaking unrelated Telegram behavior.&lt;/p&gt;

&lt;p&gt;TGDown takes a narrower approach:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It only targets supported media elements and its own controls.&lt;/li&gt;
&lt;li&gt;It avoids replacing Telegram's global objects or &lt;code&gt;fetch&lt;/code&gt; implementation.&lt;/li&gt;
&lt;li&gt;It waits until Telegram has completed its initial page load before injecting behavior that depends on the page context.&lt;/li&gt;
&lt;li&gt;It uses Shadow DOM rather than global CSS to isolate extension UI.&lt;/li&gt;
&lt;li&gt;It keeps the UI non-interactive by default, enabling pointer events only for visible TGDown controls.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Telegram Web can still change its DOM structure, but keeping the integration narrow makes failures more contained and easier to diagnose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Browser downloads still have limits
&lt;/h2&gt;

&lt;p&gt;For regular images and videos, browser downloads work well. Larger media is different: browser memory, network stability, and the way Telegram Web exposes media URLs can affect reliability.&lt;/p&gt;

&lt;p&gt;TGDown warns before especially large or long downloads, but leaves the final decision to the user.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try TGDown
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://tgdown.wadesk.io/" rel="noopener noreferrer"&gt;TGDown official website&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://chromewebstore.google.com/detail/pobheilhkabfglljggbjlcphpcmdmdbn" rel="noopener noreferrer"&gt;Install TGDown from the Chrome Web Store&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If Telegram Web changes and TGDown stops detecting a specific kind of media, feedback is welcome. The most helpful report includes the Telegram Web version (&lt;code&gt;A&lt;/code&gt; or &lt;code&gt;K&lt;/code&gt;), media type, and reproducible steps.&lt;/p&gt;

</description>
      <category>browserextensions</category>
      <category>typescript</category>
      <category>vue</category>
      <category>node</category>
    </item>
    <item>
      <title>I Built TabScribe: A Free Chrome Extension to Copy All Browser Tabs as Markdown (Under 100KB, 12 Languages)</title>
      <dc:creator>On Pounch Man</dc:creator>
      <pubDate>Sun, 07 Jun 2026 11:47:17 +0000</pubDate>
      <link>https://dev.to/liuxinyea/i-built-tabscribe-a-free-chrome-extension-to-copy-all-browser-tabs-as-markdown-under-100kb-12-1bok</link>
      <guid>https://dev.to/liuxinyea/i-built-tabscribe-a-free-chrome-extension-to-copy-all-browser-tabs-as-markdown-under-100kb-12-1bok</guid>
      <description>&lt;h1&gt;
  
  
  Why I Built TabScribe
&lt;/h1&gt;

&lt;p&gt;After yet another research session where I found myself manually copying 20+ browser tab URLs one by one into my Obsidian notes, I decided enough was enough. I needed a tool that could copy all my tabs at once — in Markdown format.&lt;/p&gt;

&lt;p&gt;I searched the Chrome Web Store. The existing options were either:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Too bloated (OneTab at 2M+ users, but does way more than I need)&lt;/li&gt;
&lt;li&gt;Only supported plain text URL lists (Copy All URLs)&lt;/li&gt;
&lt;li&gt;Abandoned for years (the old TabCopy extension)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I built &lt;strong&gt;TabScribe&lt;/strong&gt; over a couple of weekends.&lt;/p&gt;

&lt;h2&gt;
  
  
  What TabScribe Does
&lt;/h2&gt;

&lt;p&gt;One click → all your tabs land in your clipboard in your chosen format:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Markdown&lt;/strong&gt; &lt;code&gt;[title](url)&lt;/code&gt; — perfect for Obsidian, Notion, VS Code&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JSON&lt;/strong&gt; array — great for data processing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plain text&lt;/strong&gt; — with custom delimiters&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTML&lt;/strong&gt; unordered list — for web-based notes&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Key Features
&lt;/h3&gt;

&lt;p&gt;⚡ &lt;strong&gt;Keyboard Shortcut (Alt+Shift+C)&lt;/strong&gt; — Copy all tabs without opening the popup&lt;/p&gt;

&lt;p&gt;💾 &lt;strong&gt;Save &amp;amp; Restore Tab Groups&lt;/strong&gt; — Save your tabs as named groups locally. Restore them in a new window later. Great for context switching.&lt;/p&gt;

&lt;p&gt;📤 &lt;strong&gt;Export as Files&lt;/strong&gt; — .md, .txt, .json, or .html with auto timestamps&lt;/p&gt;

&lt;p&gt;🎯 &lt;strong&gt;Smart Filtering&lt;/strong&gt; — Auto-excludes chrome://, edge://, about: pages. Optionally exclude pinned tabs.&lt;/p&gt;

&lt;p&gt;🔧 &lt;strong&gt;Custom Delimiter&lt;/strong&gt; — Set your own separator for text output (e.g., &lt;code&gt;|&lt;/code&gt; for CSV-style)&lt;/p&gt;

&lt;p&gt;🌐 &lt;strong&gt;12 Languages&lt;/strong&gt; — English, 中文, 日本語, 한국어, Deutsch, Français, Español, Português, Русский, Bahasa Indonesia, and more&lt;/p&gt;

&lt;p&gt;🎨 &lt;strong&gt;Dark Mode&lt;/strong&gt; — Auto-follows system theme&lt;/p&gt;

&lt;h2&gt;
  
  
  Technical Deep Dive
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Tech Stack
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Preact + TypeScript + Tailwind CSS + Vite + crxjs
Bundle: ~63KB (gzipped: ~17KB)
Dependencies: 0 external runtime deps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I chose &lt;strong&gt;Preact over React&lt;/strong&gt; deliberately. For a Chrome extension popup, every kilobyte matters. Preact's 3KB footprint vs React's 40KB+ is a no-brainer. Plus, with &lt;code&gt;@preact/preset-vite&lt;/code&gt;, the DX is nearly identical.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Clipboard Challenge (Manifest V3)
&lt;/h3&gt;

&lt;p&gt;The hardest technical problem was clipboard access in Manifest V3's Service Worker context.&lt;/p&gt;

&lt;p&gt;In MV3, the background page is replaced by a Service Worker — which has no DOM. The &lt;code&gt;document.execCommand('copy')&lt;/code&gt; fallback doesn't work there. Here's my solution:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Double-fallback clipboard strategy&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;copyToClipboard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Strategy 1: Modern Clipboard API (works in popup + offscreen)&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ClipboardItem&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text/plain&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Blob&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text/plain&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
      &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text/html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Blob&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text/html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{}),&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clipboard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* fall through */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Strategy 2: execCommand fallback (popup context only)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;undefined&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;textarea&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;textarea&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;textarea&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;textarea&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;textarea&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execCommand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;copy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;textarea&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Strategy 3: Chrome Offscreen Document (Service Worker context)&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;offscreen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createDocument&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="c1"&gt;// Route clipboard write through the offscreen document&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  i18n Architecture
&lt;/h3&gt;

&lt;p&gt;I used Chrome's built-in &lt;code&gt;chrome.i18n&lt;/code&gt; API with 12 locale JSON files. The extension name and description in &lt;code&gt;manifest.json&lt;/code&gt; use &lt;code&gt;__MSG_key__&lt;/code&gt; placeholders, so the Chrome Web Store automatically displays the right language.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;_locales/ja/messages.json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"extensionName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"TabScribe"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"extensionDesc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"すべてのタブをMarkdown/JSON/HTMLで一括コピー"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Privacy by Design
&lt;/h2&gt;

&lt;p&gt;TabScribe collects &lt;strong&gt;zero data&lt;/strong&gt;. Period.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No analytics&lt;/li&gt;
&lt;li&gt;No tracking pixels&lt;/li&gt;
&lt;li&gt;No external servers&lt;/li&gt;
&lt;li&gt;No network requests (except extension updates from Chrome)&lt;/li&gt;
&lt;li&gt;Everything stored in &lt;code&gt;chrome.storage.local&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Only 6 permissions requested, each with a clear purpose:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Permission&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tabs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Read tab titles &amp;amp; URLs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;clipboardWrite&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Copy formatted text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;storage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Save preferences &amp;amp; tab groups locally&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;commands&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Register keyboard shortcut&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;downloads&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Save exported files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;offscreen&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Clipboard access in Service Worker&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;🔗 &lt;a href="https://chromewebstore.google.com/detail/tabscribe-copy-all-tabs-a/jlochdcehejdkemnmlkphhkjnadegcgm" rel="noopener noreferrer"&gt;TabScribe on Chrome Web Store&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It's free. No signup. No tracking. Just productivity.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Preact + TypeScript + Tailwind CSS. Source code available on &lt;a href="https://github.com/auraflame/tabscribe" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>chrome</category>
      <category>extension</category>
      <category>productivity</category>
      <category>markdown</category>
    </item>
  </channel>
</rss>
