[Resources] Cross-platform Card design

Here are some code samples that should help with making cards look and function uniformly across all the Anki platforms. This includes:

  1. Typing scripts with typed error highlighting (reference issue; can also be used as a base for complex answer processing logic: reference issue)
  2. AnkiWeb audio buttons (can also be used as a base for custom audio with controllable playback: reference issue)
  3. AnkiWeb TTS (reference issue)
  4. AnkiWeb and AnkiMobile css resets to avoid unexpected styling behavior (reference issue)

Full code for each feature below:

Typing code

Based on the solution found in this thread. Tested with the new AnkiDroid study screen. The setup requires several components:

  1. Replacing {{type:Field}} with
    <input id="typeans" type="text" inputmode="text" autocorrect="off" autocomplete="off" autocapitalize="off" spellcheck="false">
    
    (on the backside, it is enough to leave just <input id="typeans"> for brevity) and adding <data id="expans">{{Field}}</data> anywhere on the front side of the card. This is the element that will indicate which field contains the expected typed answer (can be modified to make the answer composed out of multiple fields and plain text pieces)
  2. Appending the front-side typing script to the front template:
    <script>
    // cross-platform typing
    if (!window.answer) { // prevent execution on the back side
    
    // parsing and saving expected answer
    const expAnsL = document.getElementById('expans');
    const expAns = expAnsL?.innerText.trim();
    sessionStorage.setItem("card::expectedAnswer", expAnsL?.innerText || "");
    
    // saving typed answer
    const typeAnsL = document.getElementById('typeans');
    function storeAnswer(typeAns = "") {
      if (!typeAns && typeAnsL) {typeAns = typeAnsL.value};
      sessionStorage.setItem("card::typedAnswer", typeAns);
    }
    
    storeAnswer(); // clean up the storage after previous card
    sessionStorage.setItem("card::input", "typing"); // saving the front-side input method
    
    function ShowAnswer() {
      if (!!window.pycmd) { // desktop
        pycmd("ans");
      } else if (!!window.showAnswer) { // AnkiDroid
        showAnswer();
      } else if (!!window.qa_box) { // AnkiWeb
        document.querySelector('.btn.btn-primary.btn-lg').click();
      }
    }
    
    if (typeAnsL) {
      typeAnsL.addEventListener('input', (ev) => {
        storeAnswer();
      });
      typeAnsL.addEventListener('keydown', (ev) => {
        if (event.key === "Enter") {
          ShowAnswer();
        }
      });
      if (!!window.qa_box) { // AnkiWeb
        setTimeout( () => {  // re-focus the typing field
          document.activeElement.blur();
          typeAnsL.focus();
        }, 100);
      }
    }
    }
    </script>
    
  3. Adding <hr id=answer> to the back side of the card (if it’s not there already; not necessary if the backside does not use the {{FrontSide}} replacement) and appending the back-side typing script:
    <script>
    // cross-platform typed answer diff
    (() => {
    
      // prevent ankiweb from immediately rating a card flipped by Enter
      if (!!window.qa_box && !window.awRefocus) {
        const btnAreaL = document.getElementById("ansarea");
        window.awRefocus = new MutationObserver(() => {
          btnAreaL.querySelectorAll('[autofocus]').forEach(L => {
            L.removeAttribute('autofocus');
            L.blur();
          });
          setTimeout(() => {
            const rateButtonLs = btnAreaL.querySelectorAll('.btn.btn-primary.btn-lg');
            if (rateButtonLs.length === 4) {
              rateButtonLs[2].focus(); // refocus 'good' button
            }
          }, 0);
        });
        window.awRefocus.observe(btnAreaL, { childList: true, subtree: true });
      };
    
      // retrieving and parsing expected answer
      const expAns = sessionStorage.getItem("card::expectedAnswer")?.trim() || "";
    
      // retrieving typed answer
      const typeAns = sessionStorage.getItem("card::typedAnswer")?.trim() || "";
    
      function htmlEscape(string) {
        return string.replaceAll('&', '&amp;')
                     .replaceAll('<', '&lt;')
                     .replaceAll('>', '&gt;')
                     .replaceAll("'", '&#39;')
                     .replaceAll('"', '&quot;');
      }
    
      function stringDiff2(s1, s2) {
        const n = s1.length;
        const m = s2.length;
    
        // Matrix of common subsequences' lengths
        const M = Array.from({length: n + 1}, () => Array(m + 1).fill(0)); // init
        for (let i = 0; i < n; i++) {
          for (let j = 0; j < m; j++) {
            if (s1[i] === s2[j]) {
              M[i+1][j+1] = M[i][j] + 1;
            }
          }
        }
    
        // longest common subsequence in a given minor of M
        function minorLCS( [n1, n2], [m1, m2] ) {
          let max = 0;
          let pos;
          for (let i = n1; i <= n2; i++) {
            for (let j = m1; j <= m2; j++) {
              const Mij = Math.min(M[i][j], j - m1 + 1, i - n1 + 1);
              if (Mij > max) {
                max = Mij;
                pos = [i, j];
              }
            }
          }
          return { "length": max, pos };
        }
    
        // Ratcliff-Obershelp implementation  
        function Diff2( [n1, n2], [m1, m2] ) {
          const LCS = minorLCS( [n1, n2], [m1, m2] );
          const length0 = LCS.length;
          
          if (length0 === 0) { // purely different part (base case)
            let diffTyped = s1.substring(n1 - 1, n2);
            let diffExpected = s2.substring(m1 - 1, m2);
            if (diffTyped) {
              diffTyped = '<span class="typeBad">' + htmlEscape(diffTyped) + '</span>';
            } else if (diffExpected) {
              diffTyped = '<span class="typeMissed">' + "-".repeat(diffExpected.length) + '</span>';
            }
            if (diffExpected) {
              diffExpected = '<span class="typeMissed">' + htmlEscape(diffExpected) + '</span>';
            }
            return {
              diffTyped,
              diffExpected
            };
          } else { // recursive case
            const [n0, m0] = LCS.pos;
            const D1 = Diff2( [n1, n0-length0], [m1, m0-length0] ); // recursive calls
            const D2 = Diff2( [n0+1, n2], [m0+1, m2] );
            const diffCommon = '<span class="typeGood">' + htmlEscape(s1.substring(n0 - length0, n0)) + '</span>'; // common part
            return {
              diffTyped: D1.diffTyped + diffCommon + D2.diffTyped,
              diffExpected: D1.diffExpected + diffCommon + D2.diffExpected
            }
          }
        }
    
        return Diff2([1, n], [1, m]); // full diff
      }
    
      // displaying the diff
      const typeAnsL = document.getElementById('typeans');
      if (typeAns === expAns) { // correct typed answer
        typeAnsL.outerHTML = '<code id="typeans">' +
                             '<span class="typeGood">' + 
                             expAns + 
                             '</span></code>';
      } else if (typeAns) { // incorrect typed answer
        const ansDiff = stringDiff2(typeAns, expAns);
        typeAnsL.outerHTML = '<code id="typeans">' + 
                             ansDiff.diffTyped +
                             '<br><span id="typearrow">↓</span><br>' +
                             ansDiff.diffExpected +         
                             '</code>';
      } else { // no typed answer
        typeAnsL.outerHTML = '<code id="typeans">' + expAns + '</code>';
      }
    })();
    </script>
    
  4. Adding the styles for the typing and answer comparison elements
    data {
      display: none;
    }
    
    #typeans {
      width: 100%;
      box-sizing: border-box;
      line-height: 1.75;
    
      font-size: 20px; /* element.style */
    }
    
    code#typeans {
      white-space: pre-wrap;
      font-variant-ligatures: none;
    }
    
    .typeGood {
      background: #afa;
      color: #000
    }
    
    .typeBad {
      color: #000;
      background: #faa;
    }
    
    .typeMissed {
      color: #000;
      background: #ccc;
    }
    
AnkiWeb Audio and TTS buttons

The code should be appended to each side of each card that uses either audio fields (containing [sound: ...]) or text to speech tags (if the backside uses {{FrontSide}} replacement, putting the code on the frontside is enough to cover both sides of the card). The TTS solution should be cleaner than the previously published one, as it no longer rebuilds the whole DOM when performing the replacements, keeping all JS bindings that might be inserted by other card scripts intact.

<script>
// AnkiWeb [sound:] and TTS
(()=>{

  function createAudioButton(onclickFunction) {
    const btnL = document.createElement("a");
    btnL.classList.add("replay-button", "soundLink");
    btnL.setAttribute('draggable', false);
    btnL.innerHTML = `<svg class="playImage" viewBox="0 0 64 64" version="1.1" width="40px" height="40px"><circle cx="32" cy="32" r="29" fill="#fff" stroke="#414141"></circle><path fill="#414141" d="M56.502,32.301l-37.502,20.101l0.329,-40.804l37.173,20.703Z"></path></svg>`;
    btnL.href = "../#";
    btnL.onclick = onclickFunction;
    return btnL;
  }

  document.querySelectorAll("audio").forEach(audioL => {
    audioL.parentNode.replaceChild(createAudioButton(() => audioL.play()), audioL);
  });


  // AnkiWeb TTS

  const qaL = document.querySelector('#qa_box #qa');
  if (!qaL) return;

  const TTSRegex = /\[anki:tts([^\]]*)\]([^\[]*)\[\/anki:tts\]/g;
  function parseTTSAttrs(attr_string) {
    const attrs = {};
    attr_string.trim().split(/\s+/).forEach(attr => {
        const [key, value] = attr.split('=');
        if (key && value) attrs[key] = value;
    });
    return attrs;
  }

  const walker = document.createTreeWalker(qaL, NodeFilter.SHOW_TEXT, null);
  const textNodes = [];
  while (walker.nextNode()) {
    textNodes.push(walker.currentNode);
  }

  for (const textNode of textNodes) {
    const text = textNode.nodeValue;
    let lastIndex = 0, match;
    const frag = document.createDocumentFragment();
    TTSRegex.lastIndex = 0;

    while ((match = TTSRegex.exec(text))) {
      if (match.index > lastIndex) {
        frag.appendChild(document.createTextNode(text.slice(lastIndex, match.index)));
      }
      const attrs = parseTTSAttrs(match[1]);
      const word = match[2];
      frag.appendChild(createAudioButton(()=>{
        const ut = new SpeechSynthesisUtterance(word);
        ut.lang = attrs["lang"]?.replace('_','-') || "en-US";
        window.speechSynthesis.speak(ut);
      }));
      lastIndex = TTSRegex.lastIndex;
    }
    if (lastIndex < text.length) {
      frag.appendChild(document.createTextNode(text.slice(lastIndex)));
    }
    if (frag.childNodes.length) {
      textNode.parentNode.replaceChild(frag, textNode);
    }
  }

})();
</script>
Style resets

Removes rules specific to AnkiWeb and AnkiMobile (based on the code published here) and carries over desktop styling to other plaforms:

body:where(:has(#qa_box)),
:where(#qa_box).card {
  background: unset;
  width: 100%;
}
#qa_box.card #qa {
  width: 100%;
}

html:is(.iphone, ipad) img,
#qa_box.card img {
  max-width: 100%;
  max-height: 95vh;
}

#qa_box.card :is(img, svg) {
  vertical-align: baseline;
}

#qa_box.card code {
  color: inherit;
  font-size: 1em;
  font-family: courier, monospace;
}

#qa_box.card hr {
  opacity: 1;
}

a.replay-button.soundLink {
  display: inline-flex;
}

html:is(.iphone, ipad) body {
  text-align: start;
}

/* desktop reviewer css */

hr {
  background-color: #737373;
  margin: 1em 0;
  border: none;
  height: 1px;
}

The samples above have been appended to the card support add-on. It can be used to easily assemble all parts of the code into a base note type when making new card templates:

(any future updates and modifications will also be distributed through that add-on)

  1. AnkiWeb dark mode (reference issue, another one)

    The code below can be used to add an AnkiWeb dark mode toggle to a card. It will activate the same classes that are available on other Anki platforms by default, making any template automatically apply its dark-mode styling as long as it has any. The toggle state persists across different cards, browser sessions and is shared by all templates that have the toggle embedded:

    Minimal dark mode toggle

    The checkbox code (can be put in any convenient place in a card template):

    <input id="dark-mode-toggle" type=checkbox>
    

    Script for adding to both back and front side templates (the latter is not necessary if it takes advantage of the {{FrontSide}} replacement):

    <script>
    // dark mode toggle
    (() => {
    const toggleMode = (darkMode) => {
      const cardL = document.querySelector(".card");
      if (darkMode) {
        document.documentElement.classList.add("night-mode");
        cardL?.classList.add("nightMode");
        cardL?.classList.add("night_mode");
      } else {
        document.documentElement.classList.remove("night-mode");
        cardL?.classList.remove("nightMode");
        cardL?.classList.remove("night_mode");
      }
    }
    
    const toggleL = document.getElementById("dark-mode-toggle");
    toggleL.checked = (localStorage.getItem("darkMode") ?? "off") === "on";
    toggleMode(toggleL.checked);
    
    toggleL.onchange = () => {
      localStorage.setItem("darkMode", toggleL.checked ? "on" : "off");
      toggleMode(toggleL.checked);
    };
    })();
    </script>
    

    Styling for hiding the toggle on any platform other than AnkiWeb:

    #dark-mode-toggle {
        display: none;
    }
    #qa_box #dark-mode-toggle {
        display: inline-block;
    }
    

    Also, for a basic note type, the default reviewer dark mode styles can be useful (appended to the style resets from the first post):

    html:has(#qa_box.card.nightMode) #ansarea,
    html:has(#qa_box.card.nightMode) {
        background-color: #2c2c2c !important;
    }
    
    body:has(#qa_box.card.nightMode),
    #qa_box.card.nightMode {
        color: #fcfcfc;
    }
    
    #qa_box.card.nightMode input[type="text"] {
        color: white;
        background-color: rgb(59, 59, 59);
    }
    

    (AnkiWeb interface – the header, buttons, counters, etc. – can be adjusted in dark mode too, but the respective styles are not included in the sample to keep it simple)

Not limited to Anki cards, here is also a minimal code for turning any checkbox into a graphical toggle. The appearance is easily customizable and does not require any setup beyond pasting the css below into the styles. Can be used in combination with the dark mode toggle code.

Toggle styling
input[type=checkbox] {

  --radius: 9px;
  --move: 15px;
  --gap: 2px;

  --off-color: rebeccapurple;
  --on-color: gold;
  
  appearance: none;
  width: calc(2 * var(--radius) + var(--move));
  height: calc(2 * var(--radius));
  background-color: var(--off-color);
  border-radius: 100px;
  transition: background .2s;
  background-repeat: no-repeat;
  background-position: var(--gap);
  background-size: calc(2 * (var(--radius) - var(--gap)));
  background-image: url( "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='-1 -1 2 2'><circle r='1' fill='white'/></svg>" );
  
  box-shadow: inset .5px .5px 2px #10102050;
  cursor: pointer;
}

input:checked {
  background-position: calc(100% - var(--gap));
  background-color: var(--on-color);
}

Interactive demo:
https://codepen.io/Eltaurus/pen/NWJVbbq