Accessibility & Internationalization

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

A command-line program is accessible when a person who cannot see the screen, cannot distinguish colours or cannot read the author’s language can still use it, and it is international when the same program speaks to users in their own language and formats. Both goals are met mostly by the same habit: keep the program’s meaning in plain text on a stream, and treat decoration (colour, animation, layout) as an extra that can be switched off. This page collects the practices that follow from that habit for the shelf tool of the Bookshelf scenario, and it is explicit about where the official documentation stops.

The page was written in October 2026 against these sources and versions:

  • The GitHub CLI accessibility write-up (GitHub engineering blog, 2025; the changes described shipped as a public preview in GitHub CLI 2.72.0), the NO_COLOR convention, Huh v2 (charm.land/huh/v2) and Ink 8.0.0.

  • Unicode UAX #9 (bidirectional algorithm), UAX #15 (normalization forms) and the CLDR plural rules.

  • GNU gettext, Project Fluent, .NET 10 LTS (10.0.13) globalization, Swift 6.4.1 with Foundation, and Python 3.15.1 (the Python snippets were run on Python 3.9).

  • Windows, macOS and Linux behaviour as documented by Microsoft, Apple and POSIX.

The Python and Swift snippets on this page were compiled or run on macOS, and their output is shown. The C# snippet was not compiled while this page was written, because no .NET SDK was available.

What the Official Documentation Provides, and What It Does Not

The honest summary is that terminal accessibility is mostly undocumented by the people who make screen readers.

  • Screen-reader vendors. The documentation consulted for this page (NVDA, VoiceOver, Narrator and Orca were looked for) contains no guidance on how to write a command-line or full-screen terminal program for them, and none is given here as if it did. Everything in the accessibility half of this page comes from library documentation (Huh, Ink) and from one engineering write-up by the GitHub CLI team, and each statement says which.

  • Terminal emulators. No standard defines how a terminal exposes its content to assistive technology. The GitHub write-up states that screen readers interpret the terminal’s character grid and not markup, and cites the WCAG2ICT guidance that all text output should be exposed and that structure should be programmatically determinable. A program cannot attach roles, labels or landmarks to terminal text, with one library-level exception (Ink’s subset of ARIA, below).

  • Full-screen interfaces. Of the TUI libraries covered in this section, only Huh (an accessible mode that replaces the interface by line prompts) and Ink (a screen-reader rendering) document a screen-reader feature. The others document none; see TUI Architecture & Concepts for the architectural answer.

  • Bidirectional text in terminals. The Unicode bidirectional algorithm is specified, but how a terminal emulator applies it to a grid of cells is not. A proposal ("BiDi in Terminal Emulators", from the freedesktop terminal working group) is a draft that says it may still change incompatibly.

  • Localization frameworks are well documented (gettext, Fluent, .NET, Apple String Catalogs, CLDR), but none of them documents a command-line workflow specifically; the examples below apply their general documentation.

The design of the program’s streams, exit codes and colour decisions is covered once, on TTY, Colour & Interactivity and Streams & Exit Codes; this page does not repeat it.

Part 1: Accessibility

What a screen reader sees

A screen reader reads the terminal’s text, line by line, and follows the cursor. Consequences, taken from the GitHub CLI write-up and from the nature of the character grid:

Technique Effect for a screen-reader user Source of the claim

Animated spinner that rewrites one cell (braille frames)

Read poorly. GitHub replaced its spinner with a static line of text that names the action, or the general text "Working…​"

GitHub CLI write-up

Prompts drawn with non-alphanumeric visual cues and constant redraws

Often misread. GitHub moved its prompts to the Huh library, which has an accessible mode

GitHub CLI write-up, Huh documentation

Redrawing regions of the screen and moving the cursor around a layout

Read as fragments, because the reader follows text and cursor and has no notion of panes

Consequence of the character grid; no vendor guidance exists

Information that exists only in colour

Lost to blind users, and to colour-blind users

GitHub CLI write-up (target groups)

Plain lines with one fact each

Read in order; also scriptable, testable and usable by agents

General practice; the TUI is a view over the command line rule on TUIs vs. CLIs

The GitHub team names three groups it designed for: blind users who rely on screen readers, low-vision users who need high contrast, and colour-blind users who need customizable colours. The page you are reading adopts the same three.

A plain mode for shelf

The most portable accessibility feature is a plain mode: no colour, no animation, no cursor movement, one record per line. It should turn on by itself when the output is not a terminal or the terminal is dumb, and the user must be able to turn it on explicitly (--plain here, or an environment variable that can be set once in a profile). The NO_COLOR convention asks software that adds colour by default to stop doing so when the variable is present and not an empty string, whatever its value; a user option such as --color always may still override it. The convention covers colour only, not bold or underline.

import os, sys, argparse

def output_mode(args, stream=sys.stdout):
    """Decide how `shelf list` should present itself."""
    is_tty = stream.isatty()
    term = os.environ.get("TERM", "")
    plain = (
        args.plain
        or not is_tty
        or term == "dumb"
        or bool(os.environ.get("SHELF_PLAIN"))
    )
    if args.color == "always":          # an explicit option beats every guess
        no_colour = False
    else:
        no_colour = (args.color == "never" or plain
                     or bool(os.environ.get("NO_COLOR")))
    animate = not plain and not args.no_progress and not os.environ.get("CI")
    return {"plain": plain, "colour": not no_colour, "animate": animate}

def status(text, bad=False, colour=False):
    # meaning is always in the text; colour only reinforces it
    label = "ON LOAN  " if bad else "AVAILABLE"
    if colour:
        code = "33" if bad else "32"
        return "\x1b[" + code + "m" + label + "\x1b[0m " + text
    return label + " " + text

def main():
    p = argparse.ArgumentParser(prog="shelf list")
    p.add_argument("--plain", action="store_true")
    p.add_argument("--color", choices=["auto", "always", "never"], default="auto")
    p.add_argument("--no-progress", action="store_true")
    args = p.parse_args()
    mode = output_mode(args)
    books = [("978-0-13-110362-7", "The C Programming Language", False),
             ("978-0-596-52068-7", "Learning Python", True)]
    if mode["animate"]:
        print("Working on 2 books...")   # one static line, never a spinner
    for isbn, title, loaned in books:
        print(status(isbn + "  " + title, bad=loaned, colour=mode["colour"]))
    print("2 books listed, 1 on loan")

if __name__ == "__main__":
    main()

Run with the output redirected (so it is not a terminal), the program prints plain text, and the loan state is a word, not a colour:

$ python3 shelf_list.py | cat
AVAILABLE 978-0-13-110362-7  The C Programming Language
ON LOAN   978-0-596-52068-7  Learning Python
2 books listed, 1 on loan

$ python3 shelf_list.py --color always | cat -v
^[[32mAVAILABLE^[[0m 978-0-13-110362-7  The C Programming Language
^[[33mON LOAN  ^[[0m 978-0-596-52068-7  Learning Python
2 books listed, 1 on loan

The decision function was also exercised with a stand-in terminal stream: with a terminal and TERM=xterm-256color it chose colour and the one-line progress message; with NO_COLOR=1 it dropped colour; with TERM=dumb it chose plain mode. (Starting the program under a real pseudo-terminal was not done.)

Points the example illustrates:

  1. Meaning in words. AVAILABLE and ON LOAN carry the state; colour repeats it. Remove the colour and nothing is lost.

  2. Padding for a column is spaces, not tabs or box-drawing characters. A table drawn with line characters is read character by character by some readers. The GitHub team lists "table formatting that screen readers can read more easily" as future work, so no official recommendation exists for tables; one record per line with labelled fields is the conservative choice.

  3. Static progress text. Print the action once (Working on 2 books...), not a rotating frame, and not in plain mode or in CI at all.

  4. An explicit switch always wins over a guess, and the environment variables are the persistent way to choose.

Screen-reader modes in libraries

Two libraries in this section document a screen-reader feature; their behaviour is quoted from their own documentation.

Library Feature How to enable

Huh v2 (Go)

An accessible rendering mode that replaces the full-screen form by standard line-based prompts, for screen readers and dictation software. Individual fields such as Input, Select and Confirm have a method (RunAccessible) that takes a reader and a writer

form.WithAccessible(true); the documentation recommends driving it from an environment variable or a configuration option so the user chooses, and its example reads an ACCESSIBLE variable

Ink 8 (Node.js)

A text rendering of the component tree, with a small subset of ARIA (aria-role, aria-state, aria-label, aria-hidden) that changes what is read

isScreenReaderEnabled option of render, or the environment variable INK_SCREEN_READER=true

The Go example that wires this up is on Go TUIs with Bubble Tea, and Ink’s subset is described on Other TUI Libraries. Neither documentation includes a test with a particular screen reader, so "works better with screen readers" is the vendor’s claim and not a certified result.

For the interactive prompts of ordinary command-line tools, see Interactive Prompts & Line Editing. Whatever library you pick, offer the non-interactive path (shelf add --title ... --author ...) so a prompt is never the only way in.

Colour, contrast and themes

Three rules, all from the GitHub CLI write-up unless noted:

  • Never colour alone. Pair every colour with a word or a symbol that is in the text itself.

  • Use the 16 ANSI colours for text. Most terminals let the user customize only those 16 (4-bit) colours, so a program that sticks to them lets users control the appearance through their terminal settings. GitHub mapped its palettes to them and followed its design system’s accessibility guidance for the choices. A hard-coded 256-colour or 24-bit value ignores the user’s theme and can produce poor contrast on a background the author never saw; the legacy GitHub palette did exactly that.

  • Respect NO_COLOR and --color. See above.

Detecting whether the background is light or dark is possible only by asking the terminal, and the answer is optional. The mechanism and one library’s API (Bubble Tea’s background-colour message) are on Escape Sequences & Terminal Capabilities and Go TUIs with Bubble Tea. No official document defines a contrast ratio for terminal text; the GitHub write-up refers to its design system’s guidance and gives no number. Choose theme-relative colours (the ANSI palette) and let the user’s theme decide.

Pitfalls

  • A spinner in a log. Animation is useless in CI and unreadable in a screen reader; disable it when the output is not a terminal.

  • Cursor jumps for decoration. Keep the cursor on the element that has focus when a TUI must be used, and avoid constant redraws. This is advice derived from how readers follow the cursor, not a published rule.

  • Emoji and symbols as the only label. Their spoken names are the screen reader’s choice; add a word.

  • Sound or flashing alerts have no standard in a terminal; the bell character is interpreted by the terminal or not at all.

  • Treating a full-screen UI as the product. Keep shelf list, shelf add, shelf loan and shelf return as complete commands; shelf tui calls the same functions.

Part 2: Internationalization

Internationalization (making the program able to speak many languages) and localization (the translations themselves) have two halves, as on Cross-Platform Portability: formatting values for a locale, and translating messages. That page already explains POSIX locale variables, the GNU gettext workflow with bindtextdomain, Project Fluent files, and .NET resources; this page does not repeat them and adds the pieces they leave out.

Three kinds of output, three rules

Output Localize? Why

Human text on a terminal (messages, help, errors)

Yes, from the user’s locale and language

That is what a translation is for

Numbers and dates shown to a person

Yes, formatted for the locale

Decimal separators, digits and date order differ by region

Machine-readable output (JSON, CSV, exit codes, field names, log keys)

No. Use a fixed, locale-independent format (ISO 8601 for dates, a dot for decimals)

A script written on one machine must work on another

The .NET documentation states the same split: use the current culture’s formatting conventions when showing values in the user interface, and the invariant culture when saving or exchanging them. It also separates two properties: CurrentCulture controls formatting and CurrentUICulture selects which translated resources are loaded.

using System.Globalization;

var copies = 1234.5m;
var loanedOn = new DateTime(2026, 10, 14, 17, 46, 40, DateTimeKind.Utc);

// Human output: follows CurrentCulture (formatting)
Console.WriteLine($"{copies:N1} copies, loaned {loanedOn:D}");

// Machine output: invariant, never the user's locale
Console.WriteLine(copies.ToString("0.0", CultureInfo.InvariantCulture));
Console.WriteLine(loanedOn.ToString("o", CultureInfo.InvariantCulture));

This snippet was not compiled or run while this page was written.

Plural rules

The message "%d book(s)" is wrong in every language, and a test for one is wrong in most. The GNU gettext manual explains why: the form of a plural noun and the number of plural forms both differ between languages. It gives Polish as its example (1 plik, 2 to 4 pliki, 5 to 21 plikow, 22 to 24 pliki, and so on) and concludes that applications must not solve plurals in code but use the extended interface that takes two strings and a number, with the rule chosen by the translator. CLDR names six plural categories (zero, one, two, few, many, other); English uses only one and other, Russian uses one, few, many and other, and Welsh is the CLDR documentation’s six-category example. The category other is required.

gettext stores the rule in the catalogue header as a Plural-Forms expression. The Python standard library compiles such an expression, which makes the effect visible:

import gettext

RULES = {
    "en": "nplurals=2; plural=(n != 1);",
    "pl": "nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);",
    "ru": "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);",
    "ja": "nplurals=1; plural=0;",
}
for lang, header in RULES.items():
    expr = header.split("plural=", 1)[1].rstrip(";")
    n_forms = int(header.split("=", 1)[1].split(";", 1)[0])
    f = gettext.c2py(expr)
    print(lang, n_forms, [f(n) for n in (0, 1, 2, 5, 12, 21, 22, 25)])

The output, for the counts 0, 1, 2, 5, 12, 21, 22 and 25, is the index of the message form to use:

en 2 [1, 0, 1, 1, 1, 1, 1, 1]
pl 3 [2, 0, 1, 2, 2, 2, 1, 2]
ru 3 [2, 0, 1, 2, 2, 0, 1, 2]
ja 1 [0, 0, 0, 0, 0, 0, 0, 0]

Russian puts 21 with 1 (form 0) and Polish does not, which is why one if cannot serve both. In a program, the call is ngettext(singular, plural, n) (C, Python’s gettext module) or the equivalent in your framework:

import gettext

t = gettext.translation("shelf", localedir="locale", fallback=True)
n = 3
print(t.ngettext("%d book is on loan", "%d books are on loan", n) % n)

With fallback=True and no catalogue installed, the call returns the English singular or plural, as the gettext manual describes for programs without catalogues. This snippet was not run against a translated catalogue.

The other systems handle plurals like this:

  • Project Fluent puts the selector in the translation file, as shown on Cross-Platform Portability, so the translator chooses the branches.

  • Swift String Catalogs (.xcstrings) let Xcode add plural variants for a string with an interpolated number. Apple’s documentation says Xcode adds One and Other variants for English and One, Few, Many and Other for Russian when that language is added. The documentation describes the workflow inside Xcode (add the catalog, build, add a language, vary by plural); it does not describe a command-line-only flow. Whether a Swift package executable built with swift build extracts strings into a catalog is therefore not stated by that page, and this page makes no claim about it.

  • .NET resources carry whole messages per culture. For plurals use a composite format with a rule you write or a library; the globalization pages cited here do not describe a built-in CLDR plural selector, and none is claimed.

Locale-aware numbers and dates

Formatting is the part a platform library does for you, provided you pass the locale explicitly in tests. This Swift program prints the same number and date for four locales and then shows the locale-independent forms:

import Foundation

let loanedOn = Date(timeIntervalSince1970: 1_792_000_000)   // a fixed instant
let copies = 1234.5

for id in ["en_US", "de_DE", "ar_EG", "ja_JP"] {
    let locale = Locale(identifier: id)
    let number = copies.formatted(.number.locale(locale))
    let date = loanedOn.formatted(
        Date.FormatStyle(date: .long, time: .omitted, locale: locale, timeZone: TimeZone(identifier: "UTC")!))
    print(id, number, date)
}

// Machine-readable output must not follow the locale.
let iso = ISO8601DateFormatter().string(from: loanedOn)
print("machine:", iso, String(format: "%.1f", locale: Locale(identifier: "en_US_POSIX"), copies))

Compiled and run with Swift 6.4 on macOS, it prints:

en_US 1,234.5 October 14, 2026
de_DE 1.234,5 14. Oktober 2026
ar_EG ١٬٢٣٤٫٥ ١٤ أكتوبر، ٢٠٢٦
ja_JP 1,234.5 2026年10月14日
machine: 2026-10-14T17:46:40Z 1234.5

Two lessons. The Arabic (Egypt) result uses Arabic-Indic digits, so a program that parses its own output, or pipes it to another tool, would break; this is why machine output uses a fixed locale. And a terminal font may lack glyphs for the user’s script, a problem the program cannot detect. The same functions exist elsewhere: setlocale plus the LC_NUMERIC and LC_TIME categories in C (a program must call setlocale(LC_ALL, "") first), CultureInfo in .NET and locale in Python.

Choosing the language on each platform

Platform Where the user’s language comes from Notes

Linux

POSIX variables: LC_ALL, then the individual LC_* variables, then LANG; gettext adds LANGUAGE as a priority list

A program that never calls setlocale stays in the C locale

macOS

The same POSIX variables in a shell, and the system language list for Foundation

Terminal.app and other terminals may set LANG; a program started from a launcher may not see it

Windows

The system’s user locale and preferred UI languages, as BCP 47 names; not the POSIX variables

Under MSYS2 or Git Bash LANG may be set by the environment, but a native program should not depend on it

Add an explicit override for support and tests (a --lang option or a SHELF_LANG variable) so a bug report can be reproduced in a given language without changing the whole system. The code page and UTF-8 questions on Windows are on Windows Console & Processes.

Unicode normalization

Two strings that look identical can differ in code points: "cafe" with a precomposed e-acute (U+00E9) and with an e followed by a combining acute accent (U+0301). UAX #15 defines the normalization forms: NFD (full canonical decomposition), NFC (decomposition then canonical composition), and the compatibility forms NFKD and NFKC. It names NFC as the form the W3C recommends for content in general, warns that the compatibility forms fold away distinctions that may matter, and recommends normalizing both strings to one form before a binary comparison. macOS file systems and keyboards or input methods can hand the program either form, so a title typed into shelf add may not equal the stored one.

The two snippets from this page’s runs show that languages differ:

import unicodedata

a = "café"        # precomposed e-acute
b = "café"       # e + combining acute
print(a == b, unicodedata.normalize("NFC", a) == unicodedata.normalize("NFC", b), len(a), len(b))
False True 4 5

In Python the raw strings are unequal and normalizing both to NFC makes them equal. The Swift program above also compares a precomposed and a decomposed title:

let stored = "Caf\u{00E9} Society"
let typed = "Cafe\u{0301} Society"
print(stored == typed, stored.precomposedStringWithCanonicalMapping == typed.precomposedStringWithCanonicalMapping)
print(stored.count, typed.count, stored.unicodeScalars.count, typed.unicodeScalars.count)
true true
12 12 12 13

Swift’s String comparison treated the two as equal (in this run) and counted 12 characters for both, while the Unicode scalar counts are 12 and 13. The lesson is to know which of your language’s operations are normalization-aware, normalize user input once at the boundary (NFC), and compare, sort and index on that normalized form. The related question of how many terminal cells a string takes (East Asian width and grapheme clusters, UAX #11) is on Escape Sequences & Terminal Capabilities.

Right-to-left text

UAX #9 specifies how a renderer orders mixed-direction text. The paragraph’s base direction comes from its first strong character (skipping isolates), and the isolates LRI, RLI and FSI (U+2066 to U+2068), closed by PDI (U+2069), keep the ordering of the text inside from affecting the text outside. That is the standard tool for embedding an Arabic or Hebrew title inside an English line, or the reverse.

In a terminal the situation is less settled. The documentation consulted does not say which emulators apply the algorithm to a row of cells, and the draft proposal for terminal emulators still says it may change incompatibly. For a command-line author this gives practical, conservative advice, not a published rule:

  • Keep right-to-left text in its own cell range or line (a separate column, or the last field of a line), so neither a bidi-aware nor a bidi-unaware terminal can reorder other fields.

  • When mixing directions in one line is unavoidable, wrap the foreign segment in an isolate pair; a terminal that does not understand them may show the control characters or ignore them, which is another reason to keep the plain mode free of them.

  • Never use tab-and-space alignment based on string length for such text; compute cells (see the width page above).

  • In machine-readable output, emit the text unchanged and never add direction marks.

  • Test with real terminals on each platform; none of the three platforms documents the behaviour.

A translation workflow

  1. Write messages as whole sentences with named or positional placeholders (%1$s), never concatenated fragments, so word order can change.

  2. Use the plural interface of your framework for every count.

  3. Extract the messages (xgettext, the Fluent files, .resx, a String Catalog), hand them to translators with context comments, and compile them into the build.

  4. Keep exit codes, JSON field names, flags and log keys in English.

  5. Test each translation with the longest message in a narrow terminal, and test plural boundaries (0, 1, 2, 5, 21).

  6. Run the plain, non-interactive path in at least one non-English locale.

Pitfalls

  • Translating machine output. It breaks scripts. Localize human text only.

  • Hard-coded one-or-many plural logic. Use ngettext or the framework’s plural variants.

  • Formatting numbers and dates with string concatenation. Use the locale-aware formatter, and the invariant one for data.

  • Comparing user text byte for byte. Normalize first.

  • Assuming one character is one column. Measure cells.

  • Relying on LANG on Windows. Ask the system.

  • Claiming screen-reader support you have not tested. State what you did (static text, no colour-only meaning), not what a reader will do.