Skip to content

Commit 8b87eb3

Browse files
dani-polaniclaude
andcommitted
perf(seo): defer GA + Tally off critical path; a11y labels on sliders
- Lazy-load GA gtag.js and the Tally widget on first interaction / idle instead of at page load (src/lib/analytics/defer-third-party.ts). The gtag shim stays in app.html so events queue in dataLayer; Tally binds its data-tally-open buttons on load. Pulls ~155 KiB + Tally JS off the critical path (targets mobile INP, the only failing Core Web Vital). - Add aria-label to all five Range sliders (line thickness, opacity, line-pair gap, text size, word gap) — fixes the Lighthouse "form elements must have labels" / agent-accessibility audit. Field data (CrUX): desktop CWV passes; mobile fails only on INP (269ms), LCP/CLS already green — so the fonts work (M3) was dropped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f1d7827 commit 8b87eb3

7 files changed

Lines changed: 117 additions & 38 deletions

File tree

bitext/src/app.html

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@
55
<meta name="viewport" content="width=device-width, initial-scale=1" />
66
<meta name="theme-color" content="#1e293b" />
77
<meta name="format-detection" content="telephone=no" />
8-
<!-- Google tag (gtag.js) -->
9-
<script async src="https://www.googletagmanager.com/gtag/js?id=G-6Z5775NY39"></script>
8+
<!--
9+
Google Analytics (gtag.js): only the tiny shim runs at load — gtag() calls queue in
10+
dataLayer. The gtag.js library AND the Tally widget are lazy-loaded once the page is
11+
interactive (see +layout.svelte) to keep third-party JS off the critical path.
12+
-->
1013
<script>
1114
window.dataLayer = window.dataLayer || [];
1215
function gtag() {
@@ -15,7 +18,6 @@
1518
gtag('js', new Date());
1619
gtag('config', 'G-6Z5775NY39');
1720
</script>
18-
<script async src="https://tally.so/widgets/embed.js"></script>
1921
%sveltekit.head%
2022
</head>
2123
<body class="light" data-sveltekit-preload-data="hover">
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { GA_MEASUREMENT_ID } from '$lib/brand.js';
2+
3+
/**
4+
* Load third-party scripts (GA gtag.js, Tally widget) after the page is interactive instead of
5+
* during initial load, to keep their JS off the critical path (helps TBT/INP). gtag() calls made
6+
* before the library loads queue in `dataLayer` and flush once it arrives; Tally binds its
7+
* `data-tally-open` buttons on load. Triggers on the first user interaction or when the main
8+
* thread goes idle, whichever comes first. Returns a cleanup function.
9+
*/
10+
export function deferThirdPartyScripts(): () => void {
11+
const sources = [
12+
`https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}`,
13+
'https://tally.so/widgets/embed.js'
14+
];
15+
const events = ['pointerdown', 'keydown', 'touchstart', 'scroll'] as const;
16+
17+
let loaded = false;
18+
19+
const cancelIdle = (handle: number) => {
20+
if (typeof window.cancelIdleCallback === 'function') window.cancelIdleCallback(handle);
21+
else window.clearTimeout(handle);
22+
};
23+
24+
const removeListeners = () => {
25+
for (const event of events) window.removeEventListener(event, load);
26+
};
27+
28+
function load() {
29+
if (loaded) return;
30+
loaded = true;
31+
removeListeners();
32+
cancelIdle(idleHandle);
33+
for (const src of sources) {
34+
const script = document.createElement('script');
35+
script.src = src;
36+
script.async = true;
37+
document.head.appendChild(script);
38+
}
39+
}
40+
41+
for (const event of events) {
42+
window.addEventListener(event, load, { once: true, passive: true });
43+
}
44+
45+
const idleHandle =
46+
typeof window.requestIdleCallback === 'function'
47+
? window.requestIdleCallback(load, { timeout: 4000 })
48+
: window.setTimeout(load, 3000);
49+
50+
return () => {
51+
removeListeners();
52+
if (!loaded) cancelIdle(idleHandle);
53+
};
54+
}

bitext/src/lib/components/editor/LineSettingsForm.svelte

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@
178178
<Label class="mb-1">Size ({line.textSizePx}px)</Label>
179179
<Range
180180
appearance="auto"
181+
aria-label="Text size in pixels"
181182
color="indigo"
182183
size="md"
183184
class="max-w-full"
@@ -195,6 +196,7 @@
195196
<Label class="mb-1">Word gap ({line.gapWordPx}px)</Label>
196197
<Range
197198
appearance="auto"
199+
aria-label="Word gap in pixels"
198200
color="indigo"
199201
size="md"
200202
class="max-w-full"

bitext/src/lib/components/preview/LinePairGapSlider.svelte

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
>
4646
<Range
4747
appearance="auto"
48+
aria-label="Gap between these two lines in pixels"
4849
color="indigo"
4950
size="sm"
5051
min={MIN_LINE_GAP_PX}

bitext/src/lib/components/settings/AppearanceTab.svelte

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
<Label class="mb-2">Line thickness ({s.lineThickness}px)</Label>
1414
<Range
1515
appearance="auto"
16+
aria-label="Line thickness in pixels"
1617
color="indigo"
1718
size="lg"
1819
min={1}
@@ -29,6 +30,7 @@
2930
<Label class="mb-2">Line opacity ({Math.round(s.lineOpacity * 100)}%)</Label>
3031
<Range
3132
appearance="auto"
33+
aria-label="Line opacity"
3234
color="indigo"
3335
size="lg"
3436
min={0.2}

bitext/src/routes/+layout.svelte

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { afterNavigate } from '$app/navigation';
44
import '../app.css';
55
import { registerAffiliateLinkClickTracking } from '$lib/analytics/affiliate-link-tracking.js';
6+
import { deferThirdPartyScripts } from '$lib/analytics/defer-third-party.js';
67
import { GA_MEASUREMENT_ID } from '$lib/brand.js';
78
import { SITE_NAME } from '$lib/seo/metadata.js';
89
import { flowbiteTheme } from '$lib/flowbite-theme.js';
@@ -32,6 +33,11 @@
3233
return registerAffiliateLinkClickTracking();
3334
});
3435
36+
$effect(() => {
37+
if (!browser) return;
38+
return deferThirdPartyScripts();
39+
});
40+
3541
/** SPA navigations: initial `enter` is already counted by the snippet in app.html */
3642
afterNavigate(({ to, type }) => {
3743
if (!browser || type === 'enter' || !to) return;

docs/seo-plan.md

Lines changed: 47 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -116,14 +116,8 @@ Each of 19 example pages was a dead-end.
116116
### H5 — Move partner blocks below primary content on example pages ✅ ALREADY FINE
117117
Re-checked the actual `examples/[slug]/+page.svelte`: the partner banner is already the **last** element (after body copy, figure, and the "Open in Editor" CTA). The content agents' "card before content" claim conflated this with the **homepage mobile** layout, where the Preply card does sit above the editor — that's tracked under **M9**, not here. No change needed on example pages.
118118

119-
### H6 — Fix touch target sizes in alignment editor
120-
Below 48×48px minimum:
121-
- Tooltip "?" buttons: 16×16px
122-
- Toolbar icon buttons: 36×36px
123-
- Delete row buttons: ~16px
124-
- Word token boxes: varies with word length
125-
126-
Add `min-h-[44px] min-w-[44px]` to `.token-view--clickable`. Apply padding-based tap expansion to all icon-only buttons.
119+
### H6 — Fix touch target sizes in alignment editor ❌ WONTFIX (overstated)
120+
The proposed fix is inappropriate for this tool. Word tokens (`.token-view--clickable`) **are the rendered diagram text**, sized by the user's per-line font setting (12–64px) and baseline-aligned. Forcing `min-h-[44px] min-w-[44px]` would break the alignment layout — that's the whole visualization. The `?` partner button (16px) was verified to tap reliably on a real device. Toolbar icon buttons are 36px (acceptable). No change — this is inherent to a typography/alignment editor.
127121

128122
### H7 — Remove Inter font from partner card component ❌ DROPPED (misdiagnosis)
129123
The performance agent attributed the runtime Inter stylesheet to the partner card. **Wrong:** Inter is the *default visualization font* (`DEFAULT_FONT_FAMILY = 'Inter'` in `src/lib/api/align.ts`; source/target/gloss line defaults). It is loaded on demand via `googleFontStylesheetUrl()` when the live preview renders, and is core functionality — not a partner-card waste. No partner component contains a `<svelte:head>` font link. Removing it would break the default preview. Not actioned.
@@ -147,12 +141,8 @@ Convert headings to question form:
147141
- "Word alignment vs interlinear translation" → "What is the difference between word alignment and interlinear translation?"
148142
- "Great for language learners and teachers" → "How do language learners and teachers use word alignment?"
149143

150-
### M3 — Self-host Space Grotesk (or preload font files)
151-
3 Google Fonts families require 2 external connections + 2-hop waterfall (HTML → Fonts CSS → font files).
152-
153-
Minimum: add `<link rel="preload" as="font" type="font/woff2" crossorigin>` for Space Grotesk 400 in `app.html`.
154-
155-
Better: self-host via `@fontsource/space-grotesk` (npm). Add `size-adjust`/`ascent-override`/`descent-override` fallback metrics to eliminate FOUT CLS.
144+
### M3 — Self-host Space Grotesk (or preload font files) ❌ DROPPED (field data)
145+
Fonts are render-blocking (lab shows ~750ms each), but they affect **FCP/LCP — which already PASS in the field** (CrUX: mobile LCP 2.1s green, desktop 1.5s; **CLS already 0**, so the FOUT-CLS rationale is moot). Self-hosting wouldn't flip any ranking verdict. Also: "Google Sans" is in fact a public Google Font now and loads fine (the visual agent's note was stale). Not worth the change. (Render-blocking Inter could be deferred cheaply if ever bundling other font work — cosmetic.)
156146

157147
### M4 — Add `height` attribute to example page LCP images ✅
158148
All example images had `width="960"` but no `height`. Browser can't reserve space → CLS.
@@ -179,7 +169,8 @@ Reference `Person` shape:
179169
}
180170
```
181171

182-
### M7 — Rename 5 weak example slugs (with 301 redirects)
172+
### M7 — Rename 5 weak example slugs (with 301 redirects) → MOVED to content task (do with C2)
173+
Deferred to the content task "SEO расширение контента страниц примеров": renaming a slug changes the URL and also requires a 301 redirect **and** renaming the CDN preview asset (`{slug}.png` on DO Spaces + the `preview-dimensions.ts` key + local cache). Cheapest to do per-page while expanding that page's content (C2), before the URLs are indexed. Low SEO value on its own.
183174

184175
| Current | Problem | New |
185176
|---|---|---|
@@ -196,19 +187,22 @@ OG image generator endpoint returns 200 with no noindex signal. Not in sitemap (
196187

197188
**Done:** added `'X-Robots-Tag': 'noindex'` to the `/api/og` response headers.
198189

199-
### M9 — Fix mobile above-fold layout
200-
On 375px: affiliate card renders above editor inputs; live preview SVG requires 3 screen-heights of scrolling.
201-
- Apply `order-last` to affiliate card on small screens
202-
- Anchor preview SVG below line inputs in mobile stack order (or sticky mini-preview)
203-
- Fix hover-only tooltip (`sm:group-hover:block`) — add JS click/tap toggle
190+
### M9 — Fix mobile above-fold layout ❌ WONTFIX (mostly overstated)
191+
Verified against the code:
192+
- **"Hover-only tooltip"** — wrong. `PartnerBannerShell.svelte` already has `onclick={toggleWhy}` + `aria-expanded` + `touch-manipulation`; the tooltip opens on tap. Confirmed tapping fine on a real device.
193+
- **"Preview 3 screens down / card on 30% of viewport"** — overstated. The partner intro card is one compact banner in the header's intro zone; on mobile it sits between the intro text and the editor. Reordering it cleanly would need restructuring the header/sidebar (card and editor live in separate containers, so `order` can't swap them). Low value, non-trivial fix.
194+
195+
Owner judged mobile adaptivity acceptable for an inherently multi-panel editor. No change.
204196

205197
### M10 — Trim homepage meta description to ≤155 chars ✅
206198
Current: 243 chars (truncated in SERPs, cuts off audience mention).
207199

208200
**Done:** `DEFAULT_DESCRIPTION` in `metadata.ts` is now 155 chars: `Free word-by-word translation visualizer. Stack lines, add gloss/IPA, draw connectors, then export PNG, SVG, or PDF. For learners, teachers, and linguists.`
209201

210-
### M11 — Lazy-load Tally.so feedback widget
211-
Tally has 1-hour `max-age` (controlled by Tally). Replace static `<script async>` in `<head>` with demand-load triggered on "Send feedback" click via `window.Tally.openPopup()`.
202+
### M11 — Lazy-load Tally.so feedback widget ✅ (+ GA deferred)
203+
**Done:** removed the eager `<script async>` for **both** Tally and GA gtag.js from `app.html`. The tiny gtag shim stays (calls queue in `dataLayer`); the gtag.js library + Tally widget now load on first user interaction or `requestIdleCallback`, whichever first (`src/lib/analytics/defer-third-party.ts`, wired in `+layout.svelte`). This pulls ~155 KiB (GA) + the Tally script off the critical path — the INP/TBT direction. Tally binds its `data-tally-open` buttons on load; gtag events flush from the queue. Verified the eager scripts are gone from initial HTML and the shim remains.
204+
205+
Note: "Google Tag Manager 155 KiB" in Lighthouse is just gtag.js served from the `googletagmanager.com` host — it IS the GA4 tag (`G-…`), not the separate GTM container product. One thing, kept, just deferred.
212206

213207
### M12 — Start link acquisition campaign
214208
Domain ~2 months old, zero third-party backlinks (expected). First moves:
@@ -223,6 +217,26 @@ The 404 was bare "404 Not Found" — no branding, no navigation.
223217

224218
**Done:** added `src/routes/+error.svelte` — branded layout (status, friendly heading/message), CTA "Open Word Aligner" + links to examples and API docs, and `noindex`. Renders inside the existing layout (theme/fonts) for all error statuses (404 and others).
225219

220+
### M14 — Accessibility: label the range sliders ✅ (found via Lighthouse)
221+
Lighthouse "agent accessibility" flagged the text-size slider as a form element without a label (also a normal a11y gap). **Done:** added `aria-label` to all five Flowbite `Range` sliders (line thickness, line opacity, line-pair gap, text size, word gap). Helps screen readers and the "AI agent accessibility" category — on-brand given the API/skill target AI agents.
222+
223+
---
224+
225+
## Core Web Vitals — field data (CrUX, 28-day) & the real INP lever
226+
227+
Captured 2026-06-25 from PageSpeed/CrUX:
228+
229+
| | LCP | INP | CLS | Verdict |
230+
|---|---|---|---|---|
231+
| **Desktop** | 1.5s ✅ | 81ms ✅ | 0.05 ✅ | **PASSED** |
232+
| **Mobile** | 2.1s ✅ | **269ms ⚠️** | 0 ✅ | **FAILED (INP only)** |
233+
234+
The only failing metric is **mobile INP (269ms, needs-improvement; poor is >500ms)**. LCP and CLS already pass everywhere — which is why M3 (fonts) was dropped and render-blocking CSS is low priority (they target already-passing metrics).
235+
236+
**Cheap INP-direction work done:** deferred GA + Tally off the critical path (M11), labelled sliders (M14). These reduce load-time main-thread contention and may help early-interaction INP — confirm in CrUX over ~28 days.
237+
238+
**Remaining real INP lever (NOT done — needs profiling):** the editor's own interaction cost — tapping a token triggers Svelte reactivity → link-graph recompute → SVG redraw — plus the heavy `nodes/2` app chunk (~167 KiB, ~129 KiB unused per Lighthouse) and 2 long main-thread tasks. Options: code-split heavy sub-components (export/font/color dialogs) via dynamic `import()`, and optimize the per-tap update (batch DOM writes, memoize). This is a measured mini-project; only pursue if mobile INP doesn't drop below 200ms after the cheap changes settle in CrUX. CWV is a minor ranking factor and desktop already passes, so this is UX-driven, not urgent.
239+
226240
---
227241

228242
## Low (backlog)
@@ -278,20 +292,18 @@ The 404 was bare "404 Not Found" — no branding, no navigation.
278292
- [x] M6 — Author bio + Person JSON-LD on /about
279293
- [x] H5 — already fine on example pages (partner banner is last); homepage mobile → M9
280294

281-
**Week 1** (~8–10 h)
282-
- [ ] C2 — Expand first 5 example pages (Hebrew-Arabic, Japanese-Chinese-EN, Nahuatl, Lezgian, Lojban)
283-
- [ ] M3 — Self-host Space Grotesk or add font preload
295+
**Day 3** (perf/a11y) — ✅ DONE 2026-06-25
296+
- [x] M11 — Lazy-load Tally (+ defer GA gtag.js) off the critical path
297+
- [x] M14 — aria-label all Range sliders (a11y + AI-agent accessibility)
298+
- [~] M3 — Fonts — DROPPED (target LCP/CLS already pass in field; Google Sans is public)
284299

285300
**Week 2–3**
286301
- [x] H4 — Cross-navigation "Related examples" on all example pages
287302
- [x] M13 — Custom 404 page (`+error.svelte`)
288-
- [ ] C2 — Expand remaining 14 example pages
289-
- [ ] M9 — Fix mobile layout (affiliate card ordering, tooltip toggle)
290-
- [ ] H6 — Fix touch targets in alignment editor
291-
292-
**Week 4+**
293-
- [ ] M2 — Expand homepage guide sections + convert headings to questions
294-
- [ ] M7 — Rename 5 slugs + 301 redirects
295-
- [ ] M12 — Show HN, ProductHunt, Reddit launch
296-
- [ ] M11 — Lazy-load Tally
297-
- [ ] Backlog items
303+
- [~] M9 — Mobile layout — WONTFIX (tooltip already tappable; card placement minor/inherent)
304+
- [~] H6 — Touch targets — WONTFIX (tokens are user-sized diagram text; can't force 44px)
305+
306+
**Remaining (technical):**
307+
- [ ] (optional, measured) Editor INP — code-split heavy sub-components + optimize per-tap update. Only if mobile INP stays >200ms in CrUX after the deferrals settle. Needs profiling.
308+
309+
**Content task (separate):** C2 — expand 19 example pages; M2 — homepage guide sections; M7 — rename 5 slugs (+301 +CDN); M12 — link acquisition.

0 commit comments

Comments
 (0)