Problem Description
Some users on AnkiMobile (iOS) reported that the chessboard does not appear at all on the front side of the card, even though:
- The same template works correctly on Anki Desktop (Windows/macOS/Linux).
- The same template works correctly on AnkiDroid (Android).
- The FEN data and other fields (metadata, buttons, etc.) appear as expected.
The deck uses HTMLTTChess (inline in the template) to render a chessboard from FEN. The issue turns out to be specific to iOS’s WebKit/WebView behaviour inside AnkiMobile, which is stricter and slightly different from the WebView/engine used on desktop and Android.
The goal of the fix is:
- Keep the HTMLTTChess engine code unchanged as much as possible.
- Fix the template integration (HTML + JS call site) so that:
- It works on iOS.
- It does not regress on AnkiDesktop or AnkiDroid.
Root Causes (Short Version)
-
Malformed HTML in the template
- The container was written as:
<div class={{Display Theme}}>
- The
class attribute value was not quoted, which is invalid HTML.
- Desktop and Android WebViews are tolerant and still parse this, but iOS WebKit is stricter and can break the DOM tree after this point.
-
Custom <c:chess> element and timing
- HTMLTTChess uses a custom-tag approach:
<c:chess id="fen_fig" class="cdig"></c:chess>
- The script was called immediately at the end of the
<script> block:
applyFenToBoard();
parseChess();
scheduleBoardFit();
- On iOS, the DOM may not be fully parsed when these functions run, so
parseChess() may not find the <c:chess> element yet.
-
Viewport and font timing edge cases
- The board resizing relied on
window.visualViewport and document.fonts, which are implemented slightly differently across platforms and versions.
- On some iOS setups, these calls can give incomplete or
0 sizes, or document.fonts may be missing, which leads to overly small or badly timed layout.
None of this breaks Desktop or AnkiDroid (their engines are more forgiving), but together it is enough to yield a blank board on some iOS devices.
Fix Overview
The fix has three main parts:
- HTML: fix malformed attributes and tags
- JS: delay initialization until the DOM is ready
- Layout: make viewport/font handling robust across platforms
These changes are done only in the template, not in the core HTMLTTChess logic. The same modified template has been checked to work on:
- Anki Desktop (Chromium/QtWebEngine-based viewer).
- AnkiDroid (Android WebView, Chromium-based).
- AnkiMobile (iOS WebKit WebView).
1. HTML Fixes (Front Template)
1.1. Fix the class attribute and closing tag
Before:
<div class={{Display Theme}}>
<figure>
<c:chess id="fen_fig" class="cdig"></c:chess>
</figure>
</div
Issues:
class={{Display Theme}} has no quotes.
- The closing
</div is missing a >.
After:
<div class="{{Display Theme}}">
<figure>
<c:chess id="fen_fig" class="cdig"></c:chess>
</figure>
</div>
This alone removes a potential DOM parsing error on iOS.
2. JavaScript Initialization (DOM-Ready Wrapper)
Previously, the script ended with:
applyFenToBoard();
parseChess();
scheduleBoardFit();
On iOS, these calls can fire before <c:chess id="fen_fig"> is fully in the DOM, so parseChess() finds zero elements and renders nothing.
To fix this, I wrapped the initialization in a DOM-ready helper that works reliably on desktop, Android, and iOS.
2.1. New DOM-ready wrapper
Add this helper once near the bottom of the script (before the final init):
function onDomReady(fn) {
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", fn);
} else {
fn();
}
}
2.2. Updated initialization code
Replace the previous direct calls with:
onDomReady(function() {
applyFenToBoard();
parseChess();
scheduleBoardFit();
});
This ensures:
- On iOS, the code waits until the DOM is parsed before touching
<c:chess> or #fen_fig.
- On Desktop/AnkiDroid, this is effectively a no-op (DOM is usually ready by then), so behaviour is unchanged except being slightly more robust across future versions.
3. Board Resizing and Fonts: Making It Cross‑Platform
The board resizing logic uses:
window.visualViewport (if available)
document.documentElement.clientWidth / window.innerHeight as fallback
- Multiple
setTimeout calls to refit after fonts/layout changes
- Optional
document.fonts.ready when available
This pattern is already a decent cross-platform approach, but to avoid regressions:
- The code still checks for existence of
visualViewport and document.fonts before using them.
- There is always a sensible fallback to standard
clientWidth/innerHeight if these APIs are missing.
- Scaling is clamped (
0.25 ≤ zoom ≤ 1) to avoid microscopic boards on small viewports.
Because Android WebView and modern desktop engines implement these APIs in a mostly standard way, and because the code is defensive when they are missing, this approach is safe on all three platforms.
No change was required in the font-face declaration itself:
@font-face {
font-family: 'Chess Merida Unicode';
src: url('_chess_merida_unicode.ttf');
}
This continues to work on Desktop and AnkiDroid (where the TTF is shipped with the deck and loaded as a local resource), and on iOS the font is simply used once the WebView has loaded it.
4. Final Front Template (Relevant Parts)
Below is the relevant subset of the front template showing the fixed HTML wrapper and updated JS init. The core HTMLTTChess engine code is unchanged; only the call site and DOM-ready handling are adjusted.
Click to expand
<div class="type2">{{Deck}}</div>
<div class="{{Display Theme}}">
<figure>
<c:chess id="fen_fig" class="cdig"></c:chess>
</figure>
</div>
<script type="text/javascript">
/* HTMLTTChess engine code here – unchanged */
/* ... all the existing ChessBoard, parseChess, etc. ... */
function applyFenToBoard() {
const raw = ('{{FEN}}' || '').trim();
const [placement = '', sideRaw = 'w'] = raw.split(/\s+/, 3);
const side = sideRaw.toLowerCase().startsWith('w') ? 'w' : 'b';
const lang = (navigator.language || navigator.userLanguage || 'en').slice(0, 2).toLowerCase();
const labels = {
fr: { white: "Trait aux blancs", black: "Trait aux noirs" },
de: { white: "Weiß am Zug", black: "Schwarz am Zug" },
es: { white: "Juegan blancas", black: "Juegan negras" },
it: { white: "Il bianco muove", black: "Il nero muove" },
pt: { white: "Brancas jogam", black: "Pretas jogam" },
nl: { white: "Wit aan zet", black: "Zwart aan zet" },
ru: { white: "Ход белых", black: "Ход чёрных" },
zh: { white: "白方走", black: "黑方走" },
ja: { white: "白番", black: "黒番" },
pl: { white: "Ruch białych", black: "Ruch czarnych" },
tr: { white: "Beyazlar oynar", black: "Siyahlar oynar" },
en: { white: "White to move", black: "Black to move" }
};
const dict = labels[lang] || labels.en;
const oriented = side === 'w' ? placement : rotateFen180(placement);
const fig = document.getElementById('fen_fig');
const toMove = document.getElementById('to_move');
if (fig) fig.textContent = oriented;
if (toMove) toMove.textContent = side === 'w' ? dict.white : dict.black;
}
function fitChessBoardToScreen() {
const table = document.querySelector('table.chess, table.bwchess');
if (!table) return;
const board = table.querySelector('td.board > div.board') || table;
table.style.zoom = '';
table.style.transform = '';
table.style.transformOrigin = '';
const vv = window.visualViewport;
const vw = (vv ? vv.width : document.documentElement.clientWidth);
const vh = (vv ? vv.height : window.innerHeight);
const blocks = document.querySelectorAll('.type2, .puzzle-meta, .puzzle-actions');
let overhead = 0;
blocks.forEach(el => { overhead += el.getBoundingClientRect().height; });
const margin = 12;
const maxW = Math.max(50, vw - margin * 2);
const maxH = Math.max(50, vh - overhead - margin * 2);
const rect = board.getBoundingClientRect();
let z = Math.min(maxW / rect.width, maxH / rect.height, 1);
z = Math.max(z, 0.25);
if (z < 1) {
table.style.zoom = z;
}
}
function scheduleBoardFit() {
const run = () => requestAnimationFrame(fitChessBoardToScreen);
run();
setTimeout(run, 50);
setTimeout(run, 150);
setTimeout(run, 400);
setTimeout(run, 900);
if (document.fonts && document.fonts.ready) {
document.fonts.ready.then(() => {
setTimeout(run, 0);
setTimeout(run, 150);
setTimeout(run, 400);
});
}
}
function onDomReady(fn) {
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", fn);
} else {
fn();
}
}
onDomReady(function() {
applyFenToBoard();
parseChess();
scheduleBoardFit();
});
window.addEventListener('orientationchange', () => setTimeout(scheduleBoardFit, 100));
window.addEventListener('resize', () => setTimeout(scheduleBoardFit, 100));
</script>
5. Cross‑Platform Testing
To avoid regressions, validate the updated template with the same deck on:
-
Anki Desktop
- Board displays correctly.
- Font (Merida) loads as before.
- Zoom/fit behaviour unchanged (still uses the same sizing logic).
-
AnkiDroid
- Board displays correctly in Android WebView.
- The
table.style.zoom approach continues to work (it is a common technique on Android WebView).
- No layout regressions observed on typical phone resolutions.
-
AnkiMobile (iOS)
- Board now displays where it was previously blank.
- Orientation changes and resizes are handled by the
scheduleBoardFit calls.
- No crashes or visible JS errors during normal review.
If you encounter any remaining issues on a specific platform (iOS version, Android version, or desktop build), please open an issue with:
- Device and OS version.
- Anki client version (Desktop / AnkiDroid / AnkiMobile).
- A screenshot of the card.
- Whether a simple JS test card (e.g. “JS OK” test) works on that device.
Problem Description
Some users on AnkiMobile (iOS) reported that the chessboard does not appear at all on the front side of the card, even though:
The deck uses HTMLTTChess (inline in the template) to render a chessboard from FEN. The issue turns out to be specific to iOS’s WebKit/WebView behaviour inside AnkiMobile, which is stricter and slightly different from the WebView/engine used on desktop and Android.
The goal of the fix is:
Root Causes (Short Version)
Malformed HTML in the template
classattribute value was not quoted, which is invalid HTML.Custom
<c:chess>element and timing<script>block:parseChess()may not find the<c:chess>element yet.Viewport and font timing edge cases
window.visualViewportanddocument.fonts, which are implemented slightly differently across platforms and versions.0sizes, ordocument.fontsmay be missing, which leads to overly small or badly timed layout.None of this breaks Desktop or AnkiDroid (their engines are more forgiving), but together it is enough to yield a blank board on some iOS devices.
Fix Overview
The fix has three main parts:
These changes are done only in the template, not in the core HTMLTTChess logic. The same modified template has been checked to work on:
1. HTML Fixes (Front Template)
1.1. Fix the
classattribute and closing tagBefore:
Issues:
class={{Display Theme}}has no quotes.</divis missing a>.After:
This alone removes a potential DOM parsing error on iOS.
2. JavaScript Initialization (DOM-Ready Wrapper)
Previously, the script ended with:
On iOS, these calls can fire before
<c:chess id="fen_fig">is fully in the DOM, soparseChess()finds zero elements and renders nothing.To fix this, I wrapped the initialization in a DOM-ready helper that works reliably on desktop, Android, and iOS.
2.1. New DOM-ready wrapper
Add this helper once near the bottom of the script (before the final init):
2.2. Updated initialization code
Replace the previous direct calls with:
This ensures:
<c:chess>or#fen_fig.3. Board Resizing and Fonts: Making It Cross‑Platform
The board resizing logic uses:
window.visualViewport(if available)document.documentElement.clientWidth/window.innerHeightas fallbacksetTimeoutcalls to refit after fonts/layout changesdocument.fonts.readywhen availableThis pattern is already a decent cross-platform approach, but to avoid regressions:
visualViewportanddocument.fontsbefore using them.clientWidth/innerHeightif these APIs are missing.0.25 ≤ zoom ≤ 1) to avoid microscopic boards on small viewports.Because Android WebView and modern desktop engines implement these APIs in a mostly standard way, and because the code is defensive when they are missing, this approach is safe on all three platforms.
No change was required in the font-face declaration itself:
This continues to work on Desktop and AnkiDroid (where the TTF is shipped with the deck and loaded as a local resource), and on iOS the font is simply used once the WebView has loaded it.
4. Final Front Template (Relevant Parts)
Below is the relevant subset of the front template showing the fixed HTML wrapper and updated JS init. The core HTMLTTChess engine code is unchanged; only the call site and DOM-ready handling are adjusted.
Click to expand
5. Cross‑Platform Testing
To avoid regressions, validate the updated template with the same deck on:
Anki Desktop
AnkiDroid
table.style.zoomapproach continues to work (it is a common technique on Android WebView).AnkiMobile (iOS)
scheduleBoardFitcalls.If you encounter any remaining issues on a specific platform (iOS version, Android version, or desktop build), please open an issue with: