Skip to content

Board Not Displayed on AnkiMobile (iOS) #10

Description

@SKOHscripts

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)

  1. 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.
  2. 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.
  3. 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:

  1. HTML: fix malformed attributes and tags
  2. JS: delay initialization until the DOM is ready
  3. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions