Typescript
-
Building Speech and Thought Bubbles in Svelte
Have you ever looked at a comic-style speech bubble and thought “that’s just a rounded rectangle with a little triangle”? That’s what I thought too. Then I tried to build one that stays intentional at every size, and I was wrong.
Bubble Lab started as a small Svelte 5 playground for a deceptively specific UI problem: comic-style speech bubbles and thought bubbles you can copy into another project without dragging along a whole positioning system. Pretty good progress so far! It does bubbles, tails, and puffs in smooth or pixel-art style, plus a live editor to tune them and copy the snippet out.
Unfortunately, the code is not public. So let me know if that interests you.
The bubble body is easy but I struggled with the tails and puffs. This is not a full code walkthrough, but rather my approach at building it. The real implementation has props, presets, app state and tests.
Keep the Bubble Presentational
The presentation boundary is important.
SpeechBubbleowns the body frame, the fill and ink colors, the text padding, the tail shape, and smooth vs. pixel rendering.ThoughtBubbleowns the body frame, the puff trail, puff direction and count, and smooth vs. pixel rendering.Positioning is somebody else’s job. If a bubble needs to float next to a heading, point at a chart, or become an annotation, that belongs to a wrapper like
AnchoredAnnotation, not the bubble. That keeps the API small:<SpeechBubble tail="right" color="cyan" pixel> Copy this into your own scene. </SpeechBubble> <ThoughtBubble puffs={3} puffDirection="down-left" color="cream"> How should this trail land? </ThoughtBubble>SVG for the Frame, HTML for the Text
The body text stays real HTML. It wraps naturally, holds inline markup, stays selectable, and keeps accessibility boring in the best way. The frame is an absolutely positioned SVG behind the content:
<svg class="bubble-frame" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true"> <path d={d} fill="var(--bubble-fill)" stroke="var(--bubble-ink)" vector-effect="non-scaling-stroke" shape-rendering={pixel ? "crispEdges" : "geometricPrecision"} /> </svg> <div class="bubble-content">{@render children?.()}</div>preserveAspectRatio="none"earns its keep because the frame stretches to whatever the content needs. The path lives in a normalized 100x100 viewBox, and CSS controls the real size. Smooth mode uses quadratic curves at the corners. Pixel mode uses straight segments andcrispEdges. One clean switch.The Tail Should Not Live in the Body Path
I made this mistake so you don’t have to. The original speech tail lived inside the same stretched 100x100 body SVG.
The body frame stretches differently depending on the content. A left or right tail flattens as the bubble gets wider. An up or down tail collapses into a sad little nub. The tail is supposed to stay triangular, but it was living in a coordinate system whose entire job was to deform.
The fix was to split the tail into its own fixed-aspect 16x16 SVG. The body still stretches. The tail does not. A triangle stays a triangle at every bubble width. The tail path is intentionally open, so the fill path closes with
Zbut the stroked path doesn’t. That keeps an ink line from drawing across the tail base where it overlaps the body.One tiny detail:
stroke-linecap="round"left little nubs at the tail base corners. Switching the open stroke tobuttmade the overlap read as one continuous outline. That’s the fix.Pixel Tails Need More Vertices, Not a New Component
The pixel version doesn’t need a second component. It needs a different path generator. The smooth tail is
base corner -> tip -> base corner. The pixel tail isbase corner -> stair-step to tip -> stair-step to base corner. Same API, difference pushed intotailPath(tail, pixel).And it’s easy to test without booting a browser:
expect(tailPath("left", false)).not.toMatch(/Z\s*$/); expect(tailPath("left", true)).not.toMatch(/[QqCcSsTtAa]/); expect(pixelVertexCount).toBeGreaterThan(smoothVertexCount);Those tests don’t prove the bubble is pretty. They protect the geometry contract: open stroke, no curves in pixel mode, a real stepped path.
Pixel puffs were trickier. A clipped octagon with a normal border didn’t give a clean stepped outline, and on pale fills the smallest puffs read as solid ink dots. The fix was an ink-colored clipped backing with a smaller fill-colored clipped pseudo-element inset on top, where the inset scales with size but has a floor and a cap. It’s not perfect.
And padding counts as geometry too. The body path is inset inside the SVG, so if the text padding is too tight the copy crowds the frame stroke, especially on wide bubbles. The frame can be mathematically correct and still look wrong if the text doesn’t get room to breathe inside it.
What Still Needs Work
Bubble Lab is a good first attempt, not a finished bubble engine. The next round is more visual than architectural: more tail geometries including curved comic tails, better per-direction placement, more natural puff arcs, finer pixel tuning at tiny sizes, and browser screenshots as regression tests for clipping and legibility.
Svelte is a great shell for the API and the editor, but the quality comes from the small rendering choices. Not the exact code, the split: stretch the body, isolate the tail, keep text as HTML, generate paths with TypeScript, test the geometry, and use a playground to test taste. Do that and you get a component that’s simple to use but honest about the details that make a comic bubble feel drawn instead of assembled.
I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].
-
Ditch Jest for Vitest: A Ready-Made Migration Prompt
If you’ve ever sat there watching Jest crawl through your TypeScript test suite, you know pain. I mean, I know your pain.
When Switching to Vitest, and the speed difference is genuinely dramatic. The answers to why it’s slow are easy to figure out. There’s plenty of explanations on that, so I’ll leave that to you to go look up why.
I put together a prompt you can hand to Claude (or any AI assistant) that will handle the migration for you. Let me know how it goes!
The Migration Prompt
Convert all Jest tests in this project to Vitest. Here's what to do: ## Setup 1. Remove Jest dependencies (`jest`, `ts-jest`, `@types/jest`, `babel-jest`, any jest presets) 2. Install Vitest: `pnpm add -D vitest` 3. Remove `jest.config.*` files 4. Add a `test` section to `vite.config.ts` (or create `vitest.config.ts` if no Vite config exists): import { defineConfig } from 'vitest/config' export default defineConfig({ test: { globals: true, }, }) 5. Update the `test` script in `package.json` to `vitest` ## Test File Migration For every test file: 1. Replace imports — Remove any `import ... from '@jest/globals'`. If `globals: true` is set, no imports needed. Otherwise add: import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' 2. Replace `jest` with `vi` everywhere: - jest.fn() → vi.fn() - jest.mock() → vi.mock() - jest.spyOn() → vi.spyOn() - jest.useFakeTimers() → vi.useFakeTimers() - jest.useRealTimers() → vi.useRealTimers() - jest.advanceTimersByTime() → vi.advanceTimersByTime() - jest.clearAllMocks() → vi.clearAllMocks() - jest.resetAllMocks() → vi.resetAllMocks() - jest.restoreAllMocks() → vi.restoreAllMocks() - jest.requireActual() → vi.importActual() (note: async in Vitest) 3. Fix mock hoisting — vi.mock() is hoisted automatically, but variables used in the mock factory must be prefixed with `vi` or declared inside the factory. 4. Fix jest.requireActual — This becomes vi.importActual() and returns a Promise: // Jest jest.mock('./utils', () => ({ ...jest.requireActual('./utils'), fetchData: jest.fn(), })) // Vitest vi.mock('./utils', async () => ({ ...(await vi.importActual('./utils')), fetchData: vi.fn(), })) 5. Snapshot tests work the same way. No changes needed. 6. Timer mocks — same API after the jest → vi rename. 7. Module mocks with __mocks__ directories work identically. ## TypeScript Config Add Vitest types to tsconfig.json: { "compilerOptions": { "types": ["vitest/globals"] } } ## After Migration 1. Delete leftover Jest config files 2. Update CI config to use `vitest run` 3. Run tests and fix any remaining failures
If you’re still on Jest and your TypeScript test suite is dragging, give Vitest a shot. The migration is low-risk, the speed improvements are real, and with the prompt above, the hardest parts are handled. ☕
-
Why Svelte 5 Wants `let` Instead of `const` (And Why Your Linter Is Confused)
If you’ve been working with Svelte 5 and a linter like Biome, you might have run into this sitauation:
use
constinstead ofletHowever, Svelte actually needs that
let.Have you ever wondered why this is?
Here is an attempt to explain it to you.
Svelte 5’s reactivity model is different from the rest of the TypeScript world, and some of our tooling hasn’t quite caught up yet.
The
constRule Everyone KnowsIn standard TypeScript and React, the
prefer-construle is a solid best practice.If you declare a variable and never reassign it, use
const. It communicates intent clearly: this binding won’t change. You would thinkBut there is confusion when it comes to objects that are defined as
const.Let’s take a look at a React example:
// React — const makes perfect sense here const [count, setCount] = useState(0); const handleClick = () => setCount(count + 1);count is a number (primitive). setCount is a function.
Neither can modify itself so it makes sense to use
const.Svelte 5 Plays by Different Rules
Svelte 5 introduced runes — reactive primitives like
$state(),$derived(), and$props()that bring fine-grained reactivity directly into JavaScript.Svelte compiler transforms these declarations into getters and setters behind the scenes.
The value does get reassigned, even if your code looks like a simple variable.
<script lang="ts"> let count = $state(0); </script> <button onclick={() => count++}> Clicked {count} times </button>Biome Gets This Wrong
But they are trying to make it right. Biome added experimental Svelte support in v2.3.0, but it has a significant limitation: it only analyzes the
<script>block in isolation.It doesn’t see what happens in the template. So when Biome looks at this:
<script lang="ts"> let isOpen = $state(false); </script> <button onclick={() => isOpen = !isOpen}> Toggle </button>It only sees
let isOpen = $state(false)and thinks: “this variable is never reassigned, useconst.”It completely misses the
isOpen = !isOpenhappening in the template markup.If you run
biome check --write, it will automatically changelettoconstand break your app.The Biome team has acknowledged this as an explicit limitation of their partial Svelte support.
For now, the workaround is to either disable the
useConstrule for Svelte files or addbiome-ignorecomments where needed.What About ESLint?
The Svelte ESLint plugin community has proposed two new rules to handle this properly:
svelte/rune-prefer-let(which recommendsletfor rune declarations) and a Svelte-awaresvelte/prefer-constthat understands reactive declarations. These would give you proper linting without the false positives.My Take
Svelte 5’s runes are special and deserve their own way of handling variable declarations.
React hooks are different.
Svelte’s compiler rewrites your variable.
I like Svelte.
Please fix Biome.
Don’t make me use eslint.
-
JavaScript Still Doesn't Have Types (And That's Probably Fine)
Here’s the thing about JavaScript and types: it doesn’t have them, and it probably won’t any time soon.
Back in 2022, there was a proposal to add TypeScript-like type syntax directly to JavaScript. The idea was being able to write type annotations without needing a separate compilation step. But the proposal stalled because the JavaScript community couldn’t reach consensus on implementation details.
The core concern? Performance. JavaScript is designed to be lightweight and fast, running everywhere from browsers to servers to IoT devices. Adding a type system directly into the language could slow things down, and that’s a tradeoff many aren’t willing to make.
So the industry has essentially accepted that if you want types in JavaScript, you use TypeScript. And honestly? That’s fine.
TypeScript: JavaScript’s Type System
TypeScript has become the de facto standard for typed JavaScript development. Here’s what it looks like:
// TypeScript Example let name: string = "John"; let age: number = 30; let isStudent: boolean = false; // Function with type annotations function greet(name: string): string { return `Hello, ${name}!`; } // Array with type annotation let numbers: number[] = [1, 2, 3]; // Object with type annotation let person: { name: string; age: number } = { name: "Alice", age: 25 };TypeScript compiles down to plain JavaScript, so you get the benefits of static type checking during development without any runtime overhead. The types literally disappear when your code runs.
The Python Parallel
You might be interested to know that the closest parallel to this JavaScript/TypeScript situation is actually Python.
Modern Python has types, but they’re not enforced by the language itself. Instead, you use third-party tools like mypy for static analysis and pydantic for runtime validation. There’s actually a whole ecosystem of libraries supporting types in Python in various ways, which can get a bit confusing.
Here’s how Python’s type annotations look:
# Python Example name: str = "John" age: int = 30 is_student: bool = False # Function with type annotations def greet(name: str) -> str: return f"Hello, {name}!" # List with type annotation numbers: list[int] = [1, 2, 3] # Dictionary with type annotation person: dict[str, int] = {"name": "Alice", "age": 25}Look familiar? The syntax is surprisingly similar to TypeScript. Both languages treat types as annotations that help developers and tools understand the code, but neither strictly enforces them at runtime (unless you add additional tooling).
What This Means for You
If you’re writing JavaScript, stop, and use TypeScript. It’s mature and widely adopted. Now also you can run TypeScript directly in some runtimes like Bun or Deno.
Type systems were originally omitted from many of these languages because the creators wanted to establish a low barrier to entry, making it significantly easier for people to adopt the language.
Additionally, computers at the time were much slower, and compiling code with rigorous type systems took a long time, so creators prioritized the speed of the development loop over strict safety.
However, with the power of modern computers, compilation speed is no longer a concern. Furthermore, the type systems themselves have improved significantly in efficiency and design.
Since performance is no longer an issue, the industry has shifted back toward using types to gain better structure and safety without the historical downsides.