···5353 return o;
5454}
55555656+function sideWidth(side: BorderSide | undefined): number {
5757+ return typeof side === "number" ? side : side?.width ?? 0;
5858+}
5959+6060+function sideFg(side: BorderSide | undefined, shared: number): number {
6161+ let color = typeof side === "object" && side.color !== undefined
6262+ ? side.color
6363+ : shared;
6464+ return color & 0x00FFFFFF;
6565+}
6666+6767+function sideBg(
6868+ side: BorderSide | undefined,
6969+ shared: number | undefined,
7070+): number {
7171+ let bg = typeof side === "object" && side.bg !== undefined ? side.bg : shared;
7272+ // ATTR_DEFAULT sentinel (bit 31 set) means "keep the existing cell bg"
7373+ return bg === undefined ? 0x80000000 : bg & 0x00FFFFFF;
7474+}
7575+5676function packString(
5777 view: DataView,
5878 bytes: Uint8Array,
···162182163183 if (op.border) {
164184 let b = op.border;
165165- view.setUint32(o, b.color, true);
166166- o += 4;
167167-168168- // ATTR_DEFAULT sentinel (bit 31 set) means "use terminal default bg"
169169- let bg = b.bg === undefined ? 0x80000000 : b.bg & 0x00FFFFFF;
170170- view.setUint32(o, bg, true);
171171- o += 4;
172172-173185 view.setUint32(
174186 o,
175175- (b.left ?? 0) | ((b.right ?? 0) << 8) | ((b.top ?? 0) << 16) |
176176- ((b.bottom ?? 0) << 24),
187187+ sideWidth(b.left) | (sideWidth(b.right) << 8) |
188188+ (sideWidth(b.top) << 16) | (sideWidth(b.bottom) << 24),
177189 true,
178190 );
179191 o += 4;
192192+193193+ // Must match render_border() in src/clayterm.c.
194194+ // Resolve CSS-like side fallbacks here, then write eight required
195195+ // attribute words: fg/bg pairs in top, right, bottom, left order.
196196+ // C treats the presence and order of these words as a wire-format
197197+ // invariant and only consumes the explicit values.
198198+ for (let side of [b.top, b.right, b.bottom, b.left]) {
199199+ view.setUint32(o, sideFg(side, b.color), true);
200200+ o += 4;
201201+ view.setUint32(o, sideBg(side, b.bg), true);
202202+ o += 4;
203203+ }
180204 }
181205182206 if (op.clip) {
···300324 directive: typeof OP_CLOSE_ELEMENT;
301325}
302326327327+export type BorderSide =
328328+ | number
329329+ | {
330330+ width: number;
331331+ color?: number;
332332+ bg?: number;
333333+ };
334334+303335export interface OpenElement {
304336 directive: typeof OP_OPEN_ELEMENT;
305337 id: string;
···317349 border?: {
318350 color: number;
319351 bg?: number;
320320- left?: number;
321321- right?: number;
322322- top?: number;
323323- bottom?: number;
352352+ left?: BorderSide;
353353+ right?: BorderSide;
354354+ top?: BorderSide;
355355+ bottom?: BorderSide;
324356 };
325357 clip?: { horizontal?: boolean; vertical?: boolean };
326358 floating?: {
···453485 if (op.layout) n += 6 * 4 + 4 + 4 + 4; // 2 axes (3 words each) + pad + gap + align
454486 if (op.bg !== undefined) n += 4;
455487 if (op.cornerRadius) n += 4;
456456- if (op.border) n += 12;
488488+ if (op.border) n += 36; // widths word + 4 sides × (fg + bg)
457489 if (op.clip) n += 4;
458490 // x, y, expand width/height, parent, attach/pointer, clip/z
459491 if (op.floating) n += 7 * 4;
+46-8
specs/renderer-spec.md
···681681 padding (per-side), alignment (`alignX`: `"left"` | `"center"` | `"right"`;
682682 `alignY`: `"top"` | `"center"` | `"bottom"`, defaulting to left/top when
683683 omitted), direction (top-to-bottom or left-to-right), and gap
684684-- **`border`** — per-side border widths, border color, and border background
685685- color
684684+- **`border`** — per-side border configuration. Each side field (`top`, `right`,
685685+ `bottom`, `left`) accepts either a scalar width or a structured object
686686+ `{ width, color?, bg? }`. The shared `color` field is required and is the
687687+ fallback foreground for every side; the optional shared `bg` field is the
688688+ fallback border-cell background for every side
686689- **`cornerRadius`** — per-corner radius values, producing rounded box-drawing
687690 characters
688691- **`clip`** — Declares the element as a clip region (see §7.5). Currently
···749752These property groups represent the current implementation surface. New groups
750753and fields have been added incrementally and more may follow.
751754752752-**Border background.** When `border.bg` is provided, the renderer MUST apply
753753-that background color to all cells occupied by border glyphs (corners,
754754-horizontal edges, and vertical edges). When `border.bg` is omitted, border
755755-rendering MUST NOT override the background already present in each border cell;
756756-element backgrounds established by `open({ bg })` remain in effect, and the
757757-terminal default remains in effect where no element background applies.
755755+**Border sides.** Each border side is declared independently as either a scalar
756756+width (`top: 1`) or a structured object (`top: { width: 1, color?, bg? }`). The
757757+two forms are equivalent when the object form provides only `width`. A side is
758758+enabled when its resolved width is greater than zero; an omitted side or a side
759759+with width `0` MUST NOT be drawn. Scalar side declarations MUST keep their
760760+pre-existing behavior.
761761+762762+**Border side colors (fallback resolution).** Side attributes resolve in a
763763+CSS-like shorthand/longhand fashion before rendering:
764764+765765+- A structured side with `color` MUST render with that foreground color. A
766766+ scalar side, or a structured side that omits `color`, MUST fall back to the
767767+ shared `border.color`. The shared `color` remains required.
768768+- A structured side with `bg` MUST render border cells of that side with that
769769+ background color. A scalar side, or a structured side that omits `bg`, MUST
770770+ fall back to the shared `border.bg` when it is provided.
771771+- When neither the side nor the shared border provides `bg`, border rendering
772772+ MUST NOT override the background already present in each border cell of that
773773+ side; element backgrounds established by `open({ bg })` remain in effect, and
774774+ the terminal default remains in effect where no element background applies.
775775+776776+Fallback resolution is performed on the TypeScript side before the frame is
777777+transferred; the WASM renderer consumes explicit per-side attributes and does
778778+not implement the public fallback rules.
779779+780780+**Independent sides and corners.** Each enabled side renders as a straight edge
781781+(`─` for horizontal sides, `│` for vertical sides). A corner glyph MUST be
782782+rendered only when both adjacent sides for that corner are enabled; when either
783783+adjacent side is absent, the present side continues straight through the
784784+endpoint with no corner glyph. A left-only border is therefore a plain vertical
785785+line, and a top-plus-bottom border is two plain horizontal rules.
786786+787787+**Corner styling approximation.** A terminal cell carries a single glyph,
788788+foreground, and background, so CSS-style diagonally split corners cannot be
789789+represented. When corners are rendered: top corners (`┌`, `┐`, and their rounded
790790+variants) MUST use the resolved attributes of the `top` side, and bottom corners
791791+(`└`, `┘`, and their rounded variants) MUST use the resolved attributes of the
792792+`bottom` side. Left and right side attributes apply to vertical edge cells
793793+excluding joined corner cells. Per-side attributes affect only the styling of
794794+corner cells; corner glyph shape selection (including rounded corners via
795795+`cornerRadius`) is unchanged.
758796759797**Border width and layout interaction.** In the underlying layout engine (Clay),
760798border configuration does not affect layout computation. This is Clay's intended
+40-17
src/clayterm.c
···325325static void render_border(struct Clayterm *ct, int x0, int y0, int x1, int y1,
326326 Clay_RenderCommand *cmd) {
327327 Clay_BorderRenderData *b = &cmd->renderData.border;
328328- uint32_t fg = color(b->color);
329329- /* userData is currently exclusively the packed border-bg word. */
330330- uint32_t bg = (uint32_t)(uintptr_t)cmd->userData;
328328+ /* Must match border packing in ops.ts.
329329+ * userData points at eight required words in the command buffer: resolved
330330+ * fg/bg pairs in top, right, bottom, left order. Fallback resolution
331331+ * (shared color/bg vs side overrides) happens on the TypeScript side; this
332332+ * renderer consumes explicit values only. The command buffer outlives the
333333+ * render pass within reduce(). Missing userData is a wire-format violation.
334334+ */
335335+ const uint32_t *s = (const uint32_t *)cmd->userData;
336336+ if (s == NULL) {
337337+ __builtin_trap();
338338+ }
339339+340340+ uint32_t top_fg = s[0];
341341+ uint32_t top_bg = s[1];
342342+ uint32_t right_fg = s[2];
343343+ uint32_t right_bg = s[3];
344344+ uint32_t bot_fg = s[4];
345345+ uint32_t bot_bg = s[5];
346346+ uint32_t left_fg = s[6];
347347+ uint32_t left_bg = s[7];
331348 int top = b->width.top > 0;
332349 int bot = b->width.bottom > 0;
333350 int left = b->width.left > 0;
334351 int right = b->width.right > 0;
335352336336- /* corners — rounded when corner radius > 0 */
353353+ /* corners — rounded when corner radius > 0. Drawn only when both adjacent
354354+ * sides are enabled; a terminal cell holds a single fg/bg, so top corners
355355+ * take the top side attributes and bottom corners take the bottom side
356356+ * attributes (deterministic approximation of CSS split corners). */
337357 uint32_t tl = b->cornerRadius.topLeft > 0 ? 0x256d : 0x250c;
338358 uint32_t tr = b->cornerRadius.topRight > 0 ? 0x256e : 0x2510;
339359 uint32_t bl = b->cornerRadius.bottomLeft > 0 ? 0x2570 : 0x2514;
340360 uint32_t br = b->cornerRadius.bottomRight > 0 ? 0x256f : 0x2518;
341361342362 if (top && left)
343343- setcell(ct, x0, y0, tl, fg, bg);
363363+ setcell(ct, x0, y0, tl, top_fg, top_bg);
344364 if (top && right)
345345- setcell(ct, x1 - 1, y0, tr, fg, bg);
365365+ setcell(ct, x1 - 1, y0, tr, top_fg, top_bg);
346366 if (bot && left)
347347- setcell(ct, x0, y1 - 1, bl, fg, bg);
367367+ setcell(ct, x0, y1 - 1, bl, bot_fg, bot_bg);
348368 if (bot && right)
349349- setcell(ct, x1 - 1, y1 - 1, br, fg, bg);
369369+ setcell(ct, x1 - 1, y1 - 1, br, bot_fg, bot_bg);
350370351371 /* horizontal edges */
352372 if (top)
353373 for (int x = x0 + left; x < x1 - right; x++)
354354- setcell(ct, x, y0, 0x2500, fg, bg);
374374+ setcell(ct, x, y0, 0x2500, top_fg, top_bg);
355375 if (bot)
356376 for (int x = x0 + left; x < x1 - right; x++)
357357- setcell(ct, x, y1 - 1, 0x2500, fg, bg);
377377+ setcell(ct, x, y1 - 1, 0x2500, bot_fg, bot_bg);
358378359359- /* vertical edges */
379379+ /* vertical edges — excluding joined corner cells owned by top/bottom */
360380 if (left)
361381 for (int y = y0 + top; y < y1 - bot; y++)
362362- setcell(ct, x0, y, 0x2502, fg, bg);
382382+ setcell(ct, x0, y, 0x2502, left_fg, left_bg);
363383 if (right)
364384 for (int y = y0 + top; y < y1 - bot; y++)
365365- setcell(ct, x1 - 1, y, 0x2502, fg, bg);
385385+ setcell(ct, x1 - 1, y, 0x2502, right_fg, right_bg);
366386}
367387368388/* ── Command buffer helpers ───────────────────────────────────────── */
···577597 }
578598579599 if (mask & PROP_BORDER) {
580580- decl.border.color = unpack_color(rd(buf, len, &i));
581581-582582- decl.userData = (void *)(uintptr_t)rd(buf, len, &i);
583583-584600 uint32_t bw = rd(buf, len, &i);
585601 decl.border.width.left = bw & 0xff;
586602 decl.border.width.right = (bw >> 8) & 0xff;
587603 decl.border.width.top = (bw >> 16) & 0xff;
588604 decl.border.width.bottom = (bw >> 24) & 0xff;
605605+606606+ /* Resolved per-side fg/bg attribute words (top, right, bottom,
607607+ * left). Routed to render_border via userData; the command buffer
608608+ * remains valid for the whole render pass. */
609609+ if (i + 8 <= len)
610610+ decl.userData = (void *)&buf[i];
611611+ i += 8;
589612 }
590613591614 if (mask & PROP_CLIP) {
+18-4
term-native.ts
···16161717const BOUNDING_BOX = offsets(BoundingBoxStruct);
18181919+const WASM_PAGE_BYTES = 65536;
2020+const TEXT_TRANSFER_BUFFER_BYTES = 1024 * 1024;
2121+const CLAY_DEFAULT_MAX_ELEMENT_COUNT = 8192;
2222+2323+// Conservative fixed wire-format budget per element. This covers the largest
2424+// non-text open/close element encoding we currently support; text, element id,
2525+// and snapshot payload bytes live in TEXT_TRANSFER_BUFFER_BYTES.
2626+const MAX_FIXED_ELEMENT_WIRE_BYTES = 116;
2727+1928export interface Native {
2029 memory: WebAssembly.Memory;
2130 statePtr: number;
···92101 let heap = ct.__heap_base.value as number;
93102 let size = ct.clayterm_size(w, h);
941039595- // grow memory to fit heap + state + ops buffer (1MB headroom for ops)
9696- let needed = heap + size + 1024 * 1024;
9797- let pages = Math.ceil(needed / 65536);
9898- let current = memory.buffer.byteLength / 65536;
104104+ // Grow memory once to fit heap + renderer state + fixed transfer buffer.
105105+ // The transfer budget is intentionally fixed: text/id/snapshot payload bytes
106106+ // get 1MB, and fixed op overhead gets one max-sized element per Clay element.
107107+ // Do not grow this dynamically per render; improve the wire format instead.
108108+ let transferBytes = TEXT_TRANSFER_BUFFER_BYTES +
109109+ CLAY_DEFAULT_MAX_ELEMENT_COUNT * MAX_FIXED_ELEMENT_WIRE_BYTES;
110110+ let needed = heap + size + transferBytes;
111111+ let pages = Math.ceil(needed / WASM_PAGE_BYTES);
112112+ let current = memory.buffer.byteLength / WASM_PAGE_BYTES;
99113 if (pages > current) {
100114 memory.grow(pages - current);
101115 }