Skip to content
C.W.K.
Lesson 07 of 07 · published

Pick a Role, Not a Pixel

~14 min · typography, localization, inheritance, evolution

Level 0Cold Workshop
0 XP0/58 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

Five and Six Drawings of One Card

A dub keeps the owner's master as its picture (lesson 6 of this track) and redraws only the lettering that carries words: Pippa's own on-air cards, and in a mobile film's dub also the speakers' names, the synthesized-voice labels, the subtitles, the opening notices and the chapter lettering. Every other pixel is the owner's master. For the first two weeks of dubbing mobile films, each card region carried its own font size, weight, leading and padding, written by whichever session was translating, and the renderer drew whatever it was given. No single choice was wrong. The trouble was that every session made them all again. One dub's Chinese and Japanese cards were drawn five and six times. The next dub's were drawn three times each, and the owner stopped that run mid-render, with a screenshot of the session's own log: a subtitle that now outranked the gold headline being put back in its place, the English sheet refilled too, which would have shifted the regions of an English movie already uploaded, and the type being grown to 50 px for yet another render.

His verdict was about the missing rule, not about any one card: “한번 룰로 정하면? 저렇게 계속 실수를 하는 건 룰이 없다는 건데. 세션마다 처음부터 다시 한다는 거거든” (“Why not settle it once as a rule? Making the same mistakes again and again means there is no rule. It means every session starts from scratch”), 2026-10-19.

One Canonical Scale, and the Children Pay the Tax

The ruling came with its shape, and the shape is the owner's root-class doctrine: one canonical definition, and a child that needs something different pays for it by overriding only what it must, inheriting the rest. beacon/card_type.py defines a root scale, Latin, at the dub canvas's own 1920×1080. It has eight roles: headline, lead, subtitle, body, cell-head, cell, label and caption. Each carries a rank, a weight, a nominal size that is the largest it is ever drawn, a floor it may shrink to, a leading, a padding and a default ink. English is a child that changes nothing. CJK is a child that changes one value, the caption's floor, and Chinese, Japanese and Korean inherit from it. Adding a language is adding a class with its code. The numbers were not invented in the module either. They were read from the cards the owner had already accepted in the uploaded dubs of 2026-10-08 to 2026-10-19, and where those sessions had disagreed with each other the scale took the middle and kept a visible step between ranks.

The Measurement That Had Been Growing the Type

The most instructive thing in the module is what the Chinese and Japanese children do not change: their sizes and leading are the root's. Every Chinese and Japanese size bump in the dubs before the ruling had the same cause, and it was not a reader's need. After a render the workshop reads its own frames back and compares the ink it drew with the ink in the encoded video (track 6's new lesson, "Nothing Burns as a Box", is that instrument). Present, correct Han glyphs at title size read slightly over the strict line of 14: 14.3 for one title face, 15.4 to 16.6 for regular Han at 37 px, 14.03 to 15.02 for bold Han at 42 px. A glyph that is actually missing reads 44 to 77, and a box 51 to 135. Sessions answered a correct glyph's high reading by growing the type until the strict line passed, and that is how the cards lost their hierarchy. The fix went to the instrument: stroke-dense script keeps the looser small-type line up to 56/1080 of the frame's height.

The same line was not wrong every time, which is what makes it a lesson. On 2026-10-18 thin Chinese strokes failing it at 14.6 and 15.3 exposed a real bug: the face index for bold pointed at a regular weight of the font collection, so every bold Han line had been drawn regular beside bold digits and kana. An instrument that sits close to its threshold catches some true faults and some correct work alike. When people answer its readings by changing the product, ask first which kind each reading is.

Sizing Is Mechanical

A region now states only what the master dictates: where the source ink sits (bounds), its ground and its ink, the master's own text inset, wrapping, alignment, a bullet. And it names a role. A region that sets font_size, weight, line_height, padding or paragraph_gap itself is refused; a measured exception lives in an override with a reason, printed at every run and kept in the card lock. Then the layout decides every size once for the whole dub. Each region takes the largest size in its role's range at which its text fits, on one line first and then wrapped, and below the floor it stops by name: the region, its box, its role, the floor, and “shorten its translation”. A role is drawn at one size across a card, the smallest any of its regions needs. The hierarchy is an invariant: a higher rank stays at least 2 px above every lower one on the card, the lower giving way, and if giving way would push the lower one under its own floor, the run stops and tells you to shorten the higher line.

The procedure says it in one line: shorten the translation, never grow the type. A card has a room the master set, and the words have to fit that room. It is the same constraint lesson 6 put on every spoken line.

Bounds Freeze, and an Uploaded Language Is Never Redrawn

The first render of a language writes dub/<language>/card-lock.json: each region's bounds, role and size, and every override with its reason. A later run that moves a drawn region's bounds stops, because bounds are the master's geometry, never a way to make type fit; a region measured wrong is unlocked only by saying why in the rejection log and removing the lock. A language that already has an upload record is never drawn again at all, and a fix there is a revision (track 4's new lesson, "A Correction Is a Successor"). That is the refilled English sheet from the screenshot, turned into a refusal.

The Script's Own Rules Came Along

Settling the scale exposed the other ways CJK lettering had been wrong without anyone choosing it. Lines follow kinsoku: a line never opens on a closing mark or a small kana (2026-10-17 for the boards, 2026-10-26 for opening notices that had kept a greedy wrap of their own). Since 2026-10-26 the wrap also carries a whole run of closing marks down together with the character before them, and an opening bracket standing just before that character goes down with them. That bracket check runs only on this carry. When the break falls before an ordinary character, an opening bracket can still end a line, so the change's own commit message, which says the wrap never leaves one at a line's end, holds only for that case. A number stays whole in a CJK subtitle and is never split at its separator (2026-10-13). And the coverage gate now checks every surface an authored dub burns, names, voice tags, notices, chapter cards and every region, not only the voiced subtitles (2026-10-17). Each was found by measurement and fixed once, in the code every language draws through.

The proof the ruling asked for was a render with no tuning. Two earlier dubs were rendered again in Chinese and Japanese, 18 regions on 3 cards and 24 regions on 12 cards, one pass each, with no readback failure, and the card whose subtitle had once outranked its headline drew the headline at 38 over a 33 subtitle in every language.

Name a role, not a pixel. When every author picks a value by eye, every author starts from zero, and the system has no rule, only a history of guesses. Put the canonical values in one root, let each variant override only what it truly needs, make the fit mechanical and its failure name the thing to change, and freeze what has already shipped.

Code

verbatim, beacon/card_type.py, as of 2026-10-27·python
class Latin:
    """The root scale: Latin script at the 1920x1080 dub canvas."""
    code = None
    ROLES = {
        'headline':  Role(8, 'bold',     38, 30, 1.25, 8,  INK),
        'lead':      Role(7, 'regular',  35, 20, 1.48, 12, INK),
        'subtitle':  Role(6, 'bold',     33, 26, 1.3,  8,  INK),
        'body':      Role(5, 'regular',  30, 20, 1.42, 6,  INK),
        'cell-head': Role(4, 'bold',     26, 18, 1.3,  2,  GOLD),
        'cell':      Role(3, 'regular',  24, 16, 1.3,  2,  INK),
        'label':     Role(2, 'semibold', 21, 14, 1.2,  4,  GOLD),
        'caption':   Role(1, 'regular',  19, 12, 1.3,  4,  MUTED),
    }
    #: What this script changes, per role, as {role: {field: value}}.
    TAX: dict[str, dict] = {}

    @classmethod
    def role(cls, name: str) -> Role:
        if name not in Latin.ROLES:
            raise ValueError(f'unknown card role {name!r}; one of {", ".join(Latin.ROLES)}')
        role = Latin.ROLES[name]
        for klass in reversed(cls.__mro__[:-1]):          # root first, the child last
            role = replace(role, **klass.__dict__.get('TAX', {}).get(name, {}))
        return role


class English(Latin):
    code = 'en'


class CJK(Latin):
    """Han, kana and Hangul fill the em with dense strokes.

    The sizes and leading stay the root's. A Han glyph already fills more of
    the em than a Latin capital, and the accepted Chinese and Japanese cards
    used the English leading throughout; every CJK size bump before #558 was
    the glyph readback misreading present dense strokes (beacon.glyphs
    DENSE_GLYPH), not a reader's need. What the script does need: the
    smallest captions stay at a size where a stroke-dense character is still
    a character, and line breaks follow kinsoku (authored_dub.NO_LINE_START,
    applied by character class, so a mixed Latin/Han line obeys it too)."""
    TAX = {
        'caption': {'min': 14},
    }


class Chinese(CJK):
    code = 'zh'


class Japanese(CJK):
    code = 'ja'


class Korean(CJK):
    code = 'ko'
A region, before and after the ruling, and the lock it leaves (sketch)·text
// before 2026-10-20: every session wrote its own type
{ "key": "c2-title", "bounds": [212, 140, 1708, 236],
  "font_size": 52, "weight": "bold", "translated": "<the line>" }

// now: the region states the master's geometry and names a role
{ "key": "c2-title", "card": "c2", "role": "headline",
  "bounds": [212, 140, 1708, 236], "background": [10, 12, 18, 255],
  "translated": "<the line>" }

// a measured exception is explicit, and logged
{ "key": "c2-foot", "card": "c2", "role": "caption",
  "bounds": [212, 980, 1708, 1012], "translated": "<the line>",
  "override": { "padding": 2, "reason": "<what was measured>" } }

// dub/ja/card-lock.json after the first render
{ "schema": "beacon.card-lock.v1", "language": "ja",
  "regions": { "c2-title": { "bounds": [212, 140, 1708, 236],
                             "role": "headline", "size": 38 } },
  "overrides": [ { "key": "c2-foot", "padding": 2, "reason": "<...>" } ] }

External links

Exercise

Find a value in your work that every author chooses by eye each time: a font size in slides, a timeout in service calls, a retry count, a log level, a spacing value in a UI. Collect what people actually chose over the last few months and look at the spread. Then design the canonical root: the named roles, the values taken from the accepted cases, what a variant (a platform, a language, a tier) may override and what it must inherit, and the message the system gives when a case does not fit. Decide also what freezes once a case has shipped.
Hint
The useful moment is the failure message. If the only way to make something fit is to change the canonical value, the root is wrong; if the fit can be reached by changing the content, the message should say which content and by how much. And look for an instrument behind the drift: values that crept upward are often answers to a measurement nobody questioned.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.