Skip to content

Designing for round glass

The round board is a 240x240 GC9A01 panel with the corners missing. Almost

everything else in mjsx ignores that; a handful of things must not. This page is the list of things that must not, each with the picture of what goes wrong or right.

The rule underneath all of it: an app does not branch on the board. It asks the shape one question, UI.isRound(), and the layout primitives handle the rest. The page below is the same source on both shapes.

One list, one footer, one source file. The shot script runs it unchanged on the 3.5” rectangle and on the round 1.28”.

The identical source on round glass: UI.safe.inset holds the rows inside the chord, the footer follows the rim, and the corners stay empty because they do not exist.
Round, 240×240. The identical source on round glass: UI.safe.inset holds the rows inside the chord, the footer follows the rim, and the corners stay empty because they do not exist.
One page, square glass: full-bleed rows, the ArcFooter riding the bottom edge.
Portrait, 320×480. One page, square glass: full-bleed rows, the ArcFooter riding the bottom edge.
The identical source on round glass: UI.safe.inset holds the rows inside the chord, the footer follows the rim, and the corners stay empty because they do not exist.
Round, 240×240. The identical source on round glass: UI.safe.inset holds the rows inside the chord, the footer follows the rim, and the corners stay empty because they do not exist.
One page, square glass: full-bleed rows, the ArcFooter riding the bottom edge.
Portrait, 320×480. One page, square glass: full-bleed rows, the ArcFooter riding the bottom edge.

The whole of the shape-awareness is the first three lines (scripts/shoot.mjs, SHAPE_PAGE — the page these two shots run):

UI.safe = UI.isRound()
? { top: 36, left: 36, right: 36, bottom: 60, inset: true }
: { top: 0, left: 0, right: 0, bottom: 44, inset: true };
UI.mount(function () {
var rows = [];
var names = ['PLA MATTE', 'PETG BLACK', 'ABS RED', 'TPU CLEAR', 'PLA SILK'];
for (var i = 0; i < names.length; i++) {
rows.push(h('row', { bg: UI.theme.panel, radius: 6, pad: 8, gap: 8, h: 34 }, [
h(Swatch, { color: [0x4ade80, 0x98a1ae, 0xf87171, 0x4b8bf5, 0xfbbf24][i],
size: 18, w: 18 }),
h('text', { text: names[i], size: 1, middle: true })
]));
}
return h('box', { h: gfx.height(), pad: em(1), gap: em(0.5) }, [
h('text', { text: 'SPOOLS', size: 2, align: 'center', color: UI.theme.accent }),
h('box', { flex: 1, scroll: 'spools', gap: 4 }, rows),
h(ArcFooter, { items: [ /* three 44x30 buttons */ ], at: 90, spread: 60 })
]);
});

Nothing below that ternary asks what the glass is.

The app does not measure the panel and guess. The host declares it, and the answer never changes while the process runs, so the core reads it once and caches (packages/core/src/mjsx.js):

isRound: function () {
if (this._round === undefined) {
this._round = configStorage.get('round', '0') === '1';
}
return this._round;
},

The channel is configStorage’s 'round' key, and the firmware seeds it at boot before any script can read it — the round build compiles with ROUND_DISPLAY, so the board knows what it is and the bundle never has to (filament-rfid-bridge.ino):

#if defined(ROUND_DISPLAY) && ROUND_DISPLAY
{
// The bundle asks configStorage whether the glass is round; the
// host answers once, through the same store (UI.isRound()).
Preferences pf;
pf.begin("jsapp", false);
if (pf.getString("round", "") != "1") pf.putString("round", "1");
pf.end();
}
#endif

A host that says nothing leaves the key unset, so isRound() is false and the default '0' does the right thing — which is what the terminal and the SDL sim do today (see D4 in consistency.md: the sim draws a circular mask without telling the core, so it previews the shape without the behaviour).

Everything that does claim to show round glass seeds the key exactly as the firmware does. The headless shot renderer sets backend.sys.store('round', '1') on a round profile, the golden matrix does the same before requiring an example, and the browser simulator sets it from the panel you choose. That is why the round pictures on this page come out of the same code path a board runs, and why picking round glass in the simulator changes behaviour and not just the outline.

The safe rect: holding the flow inside the circle

Section titled “The safe rect: holding the flow inside the circle”

UI.safe = { top, left, bottom, right, inset } marks edge bands. With inset: true the layout is held inside the safe rect while the background still paints full-bleed, and touches in the band snap to the content edge. That is the one mechanism that keeps ordinary rectangular rows on round glass — see ui.md for the general contract.

The numbers are geometry, not taste. On the 240px circle, a 36px inset on three sides leaves a 168px-wide band; its top corners sit sqrt(84² + 84²) = 118.8px from centre, inside the 120px radius, so the whole rectangle is on the glass. The bottom band is 60px instead of 36 because the footer lives there.

The same field page on round glass, unfocused: the corners the layout cannot use.
Round, 240×240. The same field page on round glass, unfocused: the corners the layout cannot use.
Fields at rest: muted border, placeholder text, no caret — nothing is focused.
Portrait, 320×480. Fields at rest: muted border, placeholder text, no caret — nothing is focused.

Text metrics need no round-specific handling at all — em() follows the picked font face, so a page written once relays out for the smaller panel with no size in the source changing:

The font page on round glass: em() spacing follows the picked face, so the same source lays out for the smaller panel without a size in it changing.
Round, 240×240. The font page on round glass: em() spacing follows the picked face, so the same source lays out for the smaller panel without a size in it changing.
The AUTO ladder (the default): every text size picks the sharpest native font that fits it — 4x6 at size 1, 6x8 at size 2, 12x16 at size 3.
Portrait, 320×480. The AUTO ladder (the default): every text size picks the sharpest native font that fits it — 4x6 at size 1, 6x8 at size 2, 12x16 at size 3.

Modals get it for free, because their margins are minimums rather than fixed offsets:

The same modal on round glass — margins are minimums, so the panel keeps clear of the rim.
Round, 240×240. The same modal on round glass — margins are minimums, so the panel keeps clear of the rim.
Modal: a centred panel over the page it interrupts, with sticky header and footer rows. Everything under it stops listening.
Portrait, 320×480. Modal: a centred panel over the page it interrupts, with sticky header and footer rows. Everything under it stops listening.
The same modal on round glass — margins are minimums, so the panel keeps clear of the rim.
Round, 240×240. The same modal on round glass — margins are minimums, so the panel keeps clear of the rim.
Modal: a centred panel over the page it interrupts, with sticky header and footer rows. Everything under it stops listening.
Portrait, 320×480. Modal: a centred panel over the page it interrupts, with sticky header and footer rows. Everything under it stops listening.

The chord: how wide the glass is at a given height

Section titled “The chord: how wide the glass is at a given height”

A circle’s width depends on where you measure. gfx.width() is 240 on the round board, and that number is only true across the exact middle. Any row that is not in the middle has less. The core computes the real number — half the chord across a horizontal band, taking whichever of the two edges is farther from centre, minus a hair for the bezel:

function kbChordHW(yTop, yBot) {
var c = gfx.height() / 2, r = c - 2;
var a = yTop < c ? c - yTop : yTop - c;
var b = yBot < c ? c - yBot : yBot - c;
if (b > a) a = b;
if (a >= r) return 0;
return Math.floor(Math.sqrt(r * r - a * a)) - 3;
}

Two places in the core use it, and both are worth copying.

One: deciding what fits. The keyboard’s auto layout picks by width, and the width it asks about is the chord down where the bottom rows sit, not the bounding box:

if (UI.isRound()) kbW = kbChordHW(gfx.height() * 0.72, gfx.height() * 0.86) * 2;

On the 240px circle that band is 154px across, not 240. QWERTY wants ~220, T9 wants ~115, so the honest measurement picks T9 and the naive one would have picked a QWERTY whose outer columns were under the bezel.

The AUTO layout on round glass: at a quarter-screen height the keys come out under a finger, so the keyboard takes the whole display and insets every row to the chord it actually has — the trapezoid. auto on round glass measures the CHORD where the bottom rows sit (154px across a 240px circle, not 240) and picks T9.
Round, 240×240. The AUTO layout on round glass: at a quarter-screen height the keys come out under a finger, so the keyboard takes the whole display and insets every row to the chord it actually has — the trapezoid. auto on round glass measures the CHORD where the bottom rows sit (154px across a 240px circle, not 240) and picks T9.
The AUTO layout docked at the bottom of lcd147 (172x320), field focused: auto on 172px picks T9: ten columns do not fit, four do.
Portrait, 172×320. The AUTO layout docked at the bottom of lcd147 (172x320), field focused: auto on 172px picks T9: ten columns do not fit, four do.
The AUTO layout docked at the bottom of lcd35 (320x480), field focused: auto on 320px of glass picks QWERTY: ten columns of ~22px fit.
Portrait, 320×480. The AUTO layout docked at the bottom of lcd35 (320x480), field focused: auto on 320px of glass picks QWERTY: ten columns of ~22px fit.
The AUTO layout on a 480x320 desktop window: auto on 480px picks QWERTY with room to spare.
Landscape, 480×320. The AUTO layout on a 480x320 desktop window: auto on 480px picks QWERTY with room to spare.

Two: shaping each row. kbRoundRow insets a row to the chord at its own height, so a round keyboard is a trapezoid — the bottom row is narrowest because it is nearest the rim, which is exactly where an un-inset layout puts the space bar and OK.

The QWERTY layout on round glass: at a quarter-screen height the keys come out under a finger, so the keyboard takes the whole display and insets every row to the chord it actually has — the trapezoid. The arc under the panel carries an OK of its own, so QWERTY shows two.
Round, 240×240. The QWERTY layout on round glass: at a quarter-screen height the keys come out under a finger, so the keyboard takes the whole display and insets every row to the chord it actually has — the trapezoid. The arc under the panel carries an OK of its own, so QWERTY shows two.
The QWERTY layout docked at the bottom of lcd147 (172x320), field focused: a named layout is honoured exactly, however cramped.
Portrait, 172×320. The QWERTY layout docked at the bottom of lcd147 (172x320), field focused: a named layout is honoured exactly, however cramped.
The QWERTY layout docked at the bottom of lcd35 (320x480), field focused: a named layout is honoured exactly, however cramped.
Portrait, 320×480. The QWERTY layout docked at the bottom of lcd35 (320x480), field focused: a named layout is honoured exactly, however cramped.
The QWERTY layout on a 480x320 desktop window: the width ten columns were drawn for.
Landscape, 480×320. The QWERTY layout on a 480x320 desktop window: the width ten columns were drawn for.
The T9 layout on round glass: at a quarter-screen height the keys come out under a finger, so the keyboard takes the whole display and insets every row to the chord it actually has — the trapezoid. OK moves into the bottom arc — space a full-width row could never use.
Round, 240×240. The T9 layout on round glass: at a quarter-screen height the keys come out under a finger, so the keyboard takes the whole display and insets every row to the chord it actually has — the trapezoid. OK moves into the bottom arc — space a full-width row could never use.
The T9 layout docked at the bottom of lcd147 (172x320), field focused: a named layout is honoured exactly, however cramped.
Portrait, 172×320. The T9 layout docked at the bottom of lcd147 (172x320), field focused: a named layout is honoured exactly, however cramped.
The T9 layout docked at the bottom of lcd35 (320x480), field focused: a named layout is honoured exactly, however cramped.
Portrait, 320×480. The T9 layout docked at the bottom of lcd35 (320x480), field focused: a named layout is honoured exactly, however cramped.

The exception proves the mechanism. A keyboard that stays docked is not chord-inset, because a docked panel is a screen-edge overlay:

STRIP on round glass. Its two rows are tall enough to stay DOCKED, and a docked panel is NOT chord-inset — so the side keys run under the rim. The four-row layouts go full-display instead, and inset.
Round, 240×240. STRIP on round glass. Its two rows are tall enough to stay DOCKED, and a docked panel is NOT chord-inset — so the side keys run under the rim. The four-row layouts go full-display instead, and inset.
The STRIP layout docked at the bottom of lcd147 (172x320), field focused: a named layout is honoured exactly, however cramped.
Portrait, 172×320. The STRIP layout docked at the bottom of lcd147 (172x320), field focused: a named layout is honoured exactly, however cramped.
The STRIP layout docked at the bottom of lcd35 (320x480), field focused: a named layout is honoured exactly, however cramped.
Portrait, 320×480. The STRIP layout docked at the bottom of lcd35 (320x480), field focused: a named layout is honoured exactly, however cramped.

The keyboard fits every row to its own chord. Lists should not. The keyboard is a grid of keys where each row is visibly its own object and a stepped edge reads as shape; a list of rows is meant to read as one column, and per-row widths make that column look like it is wobbling as it scrolls.

So content pages take a single uniform inset that the whole circle contains — the safe rect above — and let the corners go unused. The sensors example states it in one line (examples/sensors/app.jsx):

/* Round glass gets a uniform inset that keeps every panel inside the
circle. The SHORT labels are a question of width, not shape: the
1.47" is a perfectly square 172px panel and truncates
"acceleration" just as readily as the circle does. */
var round = UI.isRound();
var narrow = round || gfx.width() < 200;
examples/sensors — Every motion sensor the host has, three ways to look at it. No device natives under the pure-js backend, so the app draws its own labelled fallback (the line under the title).
Round, 240×240. examples/sensors — Every motion sensor the host has, three ways to look at it. No device natives under the pure-js backend, so the app draws its own labelled fallback (the line under the title). ▶ Run it
examples/sensors — Every motion sensor the host has, three ways to look at it. No device natives under the pure-js backend, so the app draws its own labelled fallback (the line under the title).
Portrait, 172×320. examples/sensors — Every motion sensor the host has, three ways to look at it. No device natives under the pure-js backend, so the app draws its own labelled fallback (the line under the title). ▶ Run it
examples/sensors — Every motion sensor the host has, three ways to look at it. No device natives under the pure-js backend, so the app draws its own labelled fallback (the line under the title).
Landscape, 280×240. examples/sensors — Every motion sensor the host has, three ways to look at it. No device natives under the pure-js backend, so the app draws its own labelled fallback (the line under the title). ▶ Run it
examples/sensors — Every motion sensor the host has, three ways to look at it. No device natives under the pure-js backend, so the app draws its own labelled fallback (the line under the title).
Portrait, 320×480. examples/sensors — Every motion sensor the host has, three ways to look at it. No device natives under the pure-js backend, so the app draws its own labelled fallback (the line under the title). ▶ Run it

The second half of that comment is the other half of the lesson. Narrow is not the same question as round. Label truncation is a width problem, and the 172px 1.47” rectangle hits it just as hard as the circle, so the example gates short labels on round || gfx.width() < 200 rather than on shape alone:

examples/sensors — Every motion sensor the host has, three ways to look at it. No device natives under the pure-js backend, so the app draws its own labelled fallback (the line under the title).
Round, 240×240. examples/sensors — Every motion sensor the host has, three ways to look at it. No device natives under the pure-js backend, so the app draws its own labelled fallback (the line under the title). ▶ Run it
examples/sensors — Every motion sensor the host has, three ways to look at it. No device natives under the pure-js backend, so the app draws its own labelled fallback (the line under the title).
Portrait, 172×320. examples/sensors — Every motion sensor the host has, three ways to look at it. No device natives under the pure-js backend, so the app draws its own labelled fallback (the line under the title). ▶ Run it
examples/sensors — Every motion sensor the host has, three ways to look at it. No device natives under the pure-js backend, so the app draws its own labelled fallback (the line under the title).
Landscape, 280×240. examples/sensors — Every motion sensor the host has, three ways to look at it. No device natives under the pure-js backend, so the app draws its own labelled fallback (the line under the title). ▶ Run it
examples/sensors — Every motion sensor the host has, three ways to look at it. No device natives under the pure-js backend, so the app draws its own labelled fallback (the line under the title).
Portrait, 320×480. examples/sensors — Every motion sensor the host has, three ways to look at it. No device natives under the pure-js backend, so the app draws its own labelled fallback (the line under the title). ▶ Run it

ArcFooter places finished items where the ray from screen-centre at each item’s angle meets the boundary, pulled inward by the item’s own size. On round glass the boundary is the rim; on square glass it is the rectangle’s perimeter. One call serves both:

var round = UI.isRound();
var inset = p.inset === undefined ? (round ? 10 : 8) : p.inset;
var spread = (p.spread || 120) * Math.PI / 180;
var a0 = (p.at === undefined ? 90 : p.at) * Math.PI / 180 - spread / 2;
/* ...then, per item, at angle `ang` along the sweep: */
var c = Math.cos(ang), s = Math.sin(ang);
var d;
if (round) {
var big = items[i].w > items[i].h ? items[i].w : items[i].h;
d = H / 2 - inset - big / 2;
} else {
var hx = W / 2 - inset - items[i].w / 2;
var hy = H / 2 - inset - items[i].h / 2;
var tx = c < 0 ? -hx / c : (c > 0 ? hx / c : 1e9);
var ty = s < 0 ? -hy / s : (s > 0 ? hy / s : 1e9);
d = tx < ty ? tx : ty;
}

Props: items ([{ w, h, node }] — finished elements, w/h centre each on its boundary point without measuring), at (centre angle, 90 = bottom and the default, 270 = top), spread (total sweep, default 120), inset (margin from the boundary, default 10 round / 8 square). Full prop notes live in components.md.

ArcFooter on round glass: five items on the bottom arc, each pulled in from the rim by its own size, all upright.
Round, 240×240. ArcFooter on round glass: five items on the bottom arc, each pulled in from the rim by its own size, all upright.
The identical ArcFooter call on a rectangle: the boundary is the perimeter, so the arc becomes the bottom edge and a wide spread walks the corners.
Portrait, 320×480. The identical ArcFooter call on a rectangle: the boundary is the perimeter, so the arc becomes the bottom edge and a wide spread walks the corners.
The same call again on a 480x320 desktop window.
Landscape, 480×320. The same call again on a 480x320 desktop window.
ArcFooter on round glass: five items on the bottom arc, each pulled in from the rim by its own size, all upright.
Round, 240×240. ArcFooter on round glass: five items on the bottom arc, each pulled in from the rim by its own size, all upright.
The identical ArcFooter call on a rectangle: the boundary is the perimeter, so the arc becomes the bottom edge and a wide spread walks the corners.
Portrait, 320×480. The identical ArcFooter call on a rectangle: the boundary is the perimeter, so the arc becomes the bottom edge and a wide spread walks the corners.
The same call again on a 480x320 desktop window.
Landscape, 480×320. The same call again on a 480x320 desktop window.
ArcFooter on round glass: five items on the bottom arc, each pulled in from the rim by its own size, all upright.
Round, 240×240. ArcFooter on round glass: five items on the bottom arc, each pulled in from the rim by its own size, all upright.
The identical ArcFooter call on a rectangle: the boundary is the perimeter, so the arc becomes the bottom edge and a wide spread walks the corners.
Portrait, 320×480. The identical ArcFooter call on a rectangle: the boundary is the perimeter, so the arc becomes the bottom edge and a wide spread walks the corners.
The same call again on a 480x320 desktop window.
Landscape, 480×320. The same call again on a 480x320 desktop window.

Two rules the pictures make concrete:

  • Items stay upright. Text is never rotated. Only positions follow the edge. Rotated glyphs are neither drawable by the text primitive nor readable at these sizes.
  • Put wide items mid-list. The boundary is most generous near the centre angle; angles that cluster at a square’s corner can overlap, so keep spread and item count sane, and check the square shot as well as the round one.

An arc footer is also how a round app buys back the screen a toolbar would have cost. examples/draw/app.jsx moves its whole toolbar to the rim when the glass is round:

if (UI.isRound()) {
var items = [];
items.push({ w: 38, h: 22, node:
<Button label={tool.toUpperCase()} size={1} pad={em(0.25)} h={22} w={38}
bg={0x2e4a37} color={0x9fe8b9}
onTap={function () { /* cycle the tool */ }} /> });
// ... the colour swatches climb the rim from there
}

The end margin: a quarter screen of overscroll

Section titled “The end margin: a quarter screen of overscroll”

The bottom arc is the narrowest part of the display, and it is where the last row of any list ends up. So every scroll zone on round glass gets a quarter-screen of extra range, unconditionally:

/* Round glass: a quarter-screen of extra range, UNCONDITIONALLY,
so the last rows can be lifted out of the narrow bottom arc
into the wide middle. Content that FITS the square still
drowns in the circle — a zone whose maxOff computed to zero is
exactly the one whose bottom row is stuck in the arc. */
if (UI.isRound()) maxOff += gfx.height() >> 2;

On the 240px panel that is 60px of overscroll. The “unconditionally” is load-bearing: the zone that most needs the margin is the short one whose content already fits, because its maxOff is zero and without this line it cannot be scrolled at all — leaving its last row parked in the arc permanently.

The round end-margin: every scroll zone on round glass gets a quarter-screen of extra range at the end, so the last rows can be lifted out of the narrow bottom arc into the wide middle.
Round, 240×240. The round end-margin: every scroll zone on round glass gets a quarter-screen of extra range at the end, so the last rows can be lifted out of the narrow bottom arc into the wide middle.
The same zone scrolled to its end on square glass, for comparison: the last row stops at the bottom edge with no extra margin.
Portrait, 320×480. The same zone scrolled to its end on square glass, for comparison: the last row stops at the bottom edge with no extra margin.
The round end-margin: every scroll zone on round glass gets a quarter-screen of extra range at the end, so the last rows can be lifted out of the narrow bottom arc into the wide middle.
Round, 240×240. The round end-margin: every scroll zone on round glass gets a quarter-screen of extra range at the end, so the last rows can be lifted out of the narrow bottom arc into the wide middle.
The same zone scrolled to its end on square glass, for comparison: the last row stops at the bottom edge with no extra margin.
Portrait, 320×480. The same zone scrolled to its end on square glass, for comparison: the last row stops at the bottom edge with no extra margin.

The round SPOOLS page scrolled to its end

The end margin in a real page: the bottom of the list is readable in the middle of the circle rather than crushed against the rim.

UI._scrollTo clamps to the same maxOff, so programmatic scrolls, flings, and scrollBy all inherit the margin — round overscroll is baked into the zone’s limit rather than applied at each call site.

Round glass has no corner to put a close button in. Rather than spend rim space on one, the core makes an inward swipe from either rim send Escape — the same key the BOOT button sends, so an app that already handles hardware back needs no round-specific code.

Arming, at press time:

/* EDGE-BACK: a press at the left or right rim arms the gesture;
travelling inward turns the stroke into Escape. +1 = inward is
rightward, -1 leftward, 0 = not an edge press. */
eb: x < 12 ? 1 : (x > gfx.width() - 12 ? -1 : 0)

Firing, on move:

if (p.eb && !p.fired) {
var inw = dx * p.eb;
if (inw > 40 && (dy < 0 ? -dy : dy) < 60) {
p.fired = 1;
p.key = null;
this.key('press', 'Escape');
return;
}
}

The thresholds: the press must start within 12px of a side, travel more than 40px inward, and stay within 60px vertically. It is not round-only — every screen gets it — and drawing surfaces never see it, because a canvas that has taken the stroke owns it before this code is reached.

OK goes in the bottom arc. Below the last key row of a full-display keyboard there is a sliver of glass too narrow for a full-width row and perfectly sized for one button. Every layout but strip parks OK there, and caps its width to the chord it actually has:

var okInArc = UI.isRound() && layout !== 'strip' && !!UI._exclusive;
...
var okW2 = Math.min(kbChordHW(okTop, okTop + okH2) * 2, em(10));

Because the arc was unusable space, moving OK there costs the key grid no height at all.

Note the UI._exclusive term: only the full-display view draws that arc, so only it may take OK out of the rows. The flag does both jobs, and deciding it from the shape alone — before the auto-exclusive rule has even run — left a docked round T9 with no OK key anywhere: dropped from the row, and no arc to put it in.

The NUMBERS layout on round glass: at a quarter-screen height the keys come out under a finger, so the keyboard takes the whole display and insets every row to the chord it actually has — the trapezoid. OK moves into the bottom arc — space a full-width row could never use.
Round, 240×240. The NUMBERS layout on round glass: at a quarter-screen height the keys come out under a finger, so the keyboard takes the whole display and insets every row to the chord it actually has — the trapezoid. OK moves into the bottom arc — space a full-width row could never use.
The NUMBERS layout docked at the bottom of lcd147 (172x320), field focused: a named layout is honoured exactly, however cramped.
Portrait, 172×320. The NUMBERS layout docked at the bottom of lcd147 (172x320), field focused: a named layout is honoured exactly, however cramped.
The NUMBERS layout docked at the bottom of lcd35 (320x480), field focused: a named layout is honoured exactly, however cramped.
Portrait, 320×480. The NUMBERS layout docked at the bottom of lcd35 (320x480), field focused: a named layout is honoured exactly, however cramped.

The mirrored input in exclusive mode gets the same treatment from the other end — it is pushed a tenth of the display down, off the narrow top arc, where at y = 0 the chord is nearly nothing.

The same wifi password step on round glass: the same unnamed Keyboard resolves to T9 here, and at this height it takes the whole display and mirrors the field.
Round, 240×240. The same wifi password step on round glass: the same unnamed Keyboard resolves to T9 here, and at this height it takes the whole display and mirrors the field.
examples/wifi after tapping a secured network: the password field appears and the keyboard comes up with no layout named — the app asked for "whatever types best here", and 320px of glass buys QWERTY.
Portrait, 320×480. examples/wifi after tapping a secured network: the password field appears and the keyboard comes up with no layout named — the app asked for "whatever types best here", and 320px of glass buys QWERTY.

Everything else goes on an arc. at: 90 is the bottom arc (the default), at: 270 the top. Buttons need no round variant to sit there:

The same button page on round glass.
Round, 240×240. The same button page on round glass.
Button: the default key colour, the theme colours passed as bg, and a small button whose hitPad grows the touch target past the paint (the outlined box).
Portrait, 320×480. Button: the default key colour, the theme colours passed as bg, and a small button whose hitPad grows the touch target past the paint (the outlined box).

Exit is the edge-back swipe, not a control. That is the trade the section above buys: no rim space spent on a close affordance, and the gesture works identically on the boards that do have corners.

CheckWhere it bitesSection
Does the page set UI.safe with inset: true?Rows run under the rimsafe rect
Did you measure width with gfx.width()?It is only true across the middlechord
Do list rows share one width?Per-row insets read as wobbleuniform width
Can the last row be scrolled clear of the arc?Short lists, maxOff of zeroend margin
Is there a way out without a corner button?Exit affordanceedge-back
Does the same source still look right square?Round-only branches driftone page, two shapes

That last row is the one that catches most regressions. Every round shot on this page has a square counterpart generated from identical source, and that is the point: round is a shape the layout absorbs, not a fork of the app.

examples/hello — The smallest real mjsx app: a panel, a border, some centred text.
Round, 240×240. examples/hello — The smallest real mjsx app: a panel, a border, some centred text. ▶ Run it
examples/hello — The smallest real mjsx app: a panel, a border, some centred text.
Portrait, 172×320. examples/hello — The smallest real mjsx app: a panel, a border, some centred text. ▶ Run it
examples/hello — The smallest real mjsx app: a panel, a border, some centred text.
Portrait, 240×280. examples/hello — The smallest real mjsx app: a panel, a border, some centred text. ▶ Run it
examples/hello — The smallest real mjsx app: a panel, a border, some centred text.
Portrait, 320×480. examples/hello — The smallest real mjsx app: a panel, a border, some centred text. ▶ Run it

Related: ui.md for UI.safe and scroll zones, components.md for ArcFooter and Keyboard props, devices.md for the round board’s flashing quirks.