~/til — tail -f til.log6 entries

Today I learned

Short notes on things I learned, newest first. rss

  1. In a product demo video, AI voice is for narration only

    When the product being filmed is a voice assistant, the demo audio is the evidence. The rule I work to: text-to-speech can do the intro, the outro and the call to action, between demos and labelled as AI-generated. It never replaces or “cleans up” the person’s question or the device’s spoken answer. Nobody’s voice gets cloned without their written consent.

    What counts as “cleaning up”

    The tempting tools are the helpful ones. No AI noise removal and no voice isolation on the device’s voice: a cleaned-up answer is no longer what a buyer will hear. The edit is plain:

    • pick the take, and trim dead time outside the question and answer, never inside it;
    • level the audio, nothing more;
    • where a timer is used, start it at the end of the question and keep it on screen, so the response time is visible rather than claimed;
    • subtitles come from the real speech, corrected by hand.

    Rules hold better in code than in a checklist

    The videos are rendered from a Remotion template, one JSON spec per video. The render script checks the spec before anything renders, and stops when:

    • narration is present but aiVoice isn’t set: every narration file has to say whether it’s an AI voice. When it is, the render ends by reminding you to switch on the platform’s “AI-generated content” label;
    • on-screen text contains a phrase from the never-list, the claims we don’t make (things like “24/7”, security promises or discount wording);
    • the results card has no source: its numbers are read from the test log, and the render throws until that log’s summary is filled in.

    Price, warranty and tagline live in one file instead of being typed per video, and the raw footage folder is ignored by Git, so camera media never ends up in the repository.

    #ai#video

  2. Attribution belongs on the order, with a note of how it was decided

    Analytics can tell you where a visit came from. It can’t tell you where an order came from when the buyer ordered through a chat or a phone call, and in the market this store sells into, that’s common. So the order record carries the attribution itself, and analytics is only a funnel view on top.

    What the browser keeps

    A touch is one entry into the store from outside: a link with UTM values or an ad click ID, or an external referrer. The browser keeps two of them for 90 days:

    • first: never overwritten while it’s younger than 90 days;
    • last: replaced by every new touch. Direct visits and clicks inside the site never replace it.

    Each touch stores the referrer’s host only and the landing path without its query string. Both are sent with the order, never as analytics properties, and ad click IDs stay on the order record. If storage is blocked (private mode), the order goes through without touches. A bad value is dropped, and never allowed to reject an order.

    What staff add

    Chat and phone orders have no touch, so staff ask “where did you hear about us?” at confirmation and pick from a fixed list: Facebook, TikTok, YouTube, Instagram, Zalo, friend or family, search, the QR code on the packaging, other, unknown. No free text, so the field can never end up holding a name or a phone number.

    How a report decides

    One view resolves each order:

    source        = UTM source  → else the buyer's answer → else "unknown"
    source_basis  = "utm"       | "heard_from"           | "none"

    source_basis is the useful column. A weekly review can tell “measured from a link” apart from “the customer said so”, instead of adding the two together as if they were the same kind of number.

    What stays out

    The visitor’s IP address and user agent are personal data. They’re kept for fraud and cash-on-delivery risk checks, in a separate table, deleted after 180 days, and never joined into marketing reports.

    #ecommerce#analytics

  3. Sandpack's console prints nothing without a preview

    <SandpackConsole> only shows logs from a running client, and the client normally lives inside <SandpackPreview>. Hide the preview and the code never runs, so the console stays empty.

    The fix is the standalone prop, which gives the console its own client:

    <SandpackConsole standalone={!showPreview} />

    #sandpack#react

  4. A textarea remembers its original text in defaultValue

    For a <textarea>, .value is what the user has typed and .defaultValue is the text that was in the HTML. Editing changes .value only, so a “reset” button needs one line:

    editor.value = editor.defaultValue;

    There’s no need to keep a second copy of the original in a <template> or a data attribute.

    #dom#html

  5. Umami's tracker sends events next to wherever the script was loaded from

    The Umami tracker doesn’t need to be told where to send events. It takes the folder its own <script src> came from and posts to api/send beside it:

    <script src="/_a/s.js">   →   POST /_a/api/send

    That makes first-party analytics two paths on your own domain. I checked it in a browser on both sites I run it on, an ecommerce storefront and this one: each posts to /_a/api/send on its own host.

    The setup on Cloudflare

    The site is static, so the Worker only exists for those two paths. Everything else is served straight from the build output without invoking it:

    // wrangler.jsonc
    "main": "worker/index.ts",
    "assets": {
      "directory": "./dist",
      "binding": "ASSETS",
      "run_worker_first": ["/_a/*"]
    },
    "services": [{ "binding": "UMAMI_EDGE", "service": "umami-edge" }]

    The Worker maps /_a/s.js and /_a/api/send to the analytics edge Worker through a service binding, and does three small things on the way:

    • drops the Cookie header, so the site’s cookies never leave it;
    • passes Cloudflare’s cf object along, so Umami still sees the visitor’s country and city, not the Worker’s;
    • answers 502 if the call fails, because analytics must never break a page.

    The edge Worker keeps the checks in one place for every site: an origin allowlist, a website-ID allowlist and a per-IP rate limit. Until a site is on both lists its events get a 403, which is exactly what this one got in the few minutes between turning the tracker on and deploying the allowlist.

    Two details worth setting

    • data-domains="imtoanle.com" on the script tag, so dev, preview and workers.dev visits aren’t counted.
    • Nothing extra for client-side navigation: Umami follows history.pushState, so Astro’s view transitions still produce page views.

    Why bother

    The browser never makes a third-party request. There’s no tracker host for a blocker to drop, and because the request is same-origin, there’s no CORS preflight either.

    #analytics#cloudflare

  6. Adding a custom domain in wrangler.jsonc turns off the workers.dev URL

    I deployed this site as a Worker with static assets and gave it its domain in config:

    "routes": [{ "pattern": "imtoanle.com", "custom_domain": true }]

    The deploy succeeded, and imtoanle-blog.<account>.workers.dev started answering with error 1042. Once routes is set, Wrangler treats workers_dev as false unless you say otherwise, and the next deploy turns the workers.dev URL off. The API confirmed it: the Worker’s subdomain setting read enabled: false.

    If you want both addresses, say so:

    "workers_dev": true,
    "routes": [{ "pattern": "imtoanle.com", "custom_domain": true }]

    Keeping it on is harmless when every page sets a canonical URL to the real domain.

    The other half: “could not resolve host”

    The custom domain looked broken too, and wasn’t. custom_domain: true makes Cloudflare create the DNS record and certificate on deploy. But I had been polling the domain before that record existed, so my machine cached the “no such host” answer. Cloudflare’s own name servers, 1.1.1.1 and 8.8.8.8 all returned the right address while my laptop still failed.

    When a fresh custom domain “doesn’t resolve”, ask a public resolver before debugging anything else:

    dig +short @1.1.1.1 example.com
    resolvectl flush-caches   # systemd-resolved; browsers may keep their own cache too

    #cloudflare

↑↓ select ↵ open esc close