@swiftbrowser/web 0.26.3 → 0.26.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swiftbrowser/web",
3
- "version": "0.26.3",
3
+ "version": "0.26.4",
4
4
  "type": "module",
5
5
  "description": "SwiftBrowser web renderer and dev server: applies render ops from a Swift/Wasm app to the DOM inside an iPhone frame.",
6
6
  "repository": {
@@ -120,6 +120,12 @@ export interface Context {
120
120
  * bar, `actions`, when the sheet has one.
121
121
  */
122
122
  macSheet?: { actions: Rect | null };
123
+ /**
124
+ * The Mac idiom: a row of a form in the columns style. A control whose
125
+ * label went to the label column (a stepper's, a date picker's, a
126
+ * picker's) draws without it, at the row's leading edge.
127
+ */
128
+ formColumn?: boolean;
123
129
  /**
124
130
  * Phase 8: the y (screen coordinates) where content clears the bars above
125
131
  * it: the top safe area at the root, the bottom of a `navscreen`'s bar (and
@@ -392,6 +398,21 @@ const MAC_LIST_HEADER_TOP = 4;
392
398
  const MAC_LIST_HEADER_GAP = 9;
393
399
  const MAC_LIST_FIRST_ROWS_GAP = 10;
394
400
  const MAC_LIST_FOOTER_GAP = 8;
401
+ // A Mac's Form (measured in the Mac app): the columns style, with no
402
+ // insets of its own and centered vertically in the room it gets. The labels
403
+ // (13) right-aligned in a column as wide as the widest, from the leading
404
+ // edge; the controls in a column 10 after it that runs to the trailing
405
+ // edge; a section header and a footer (13 regular, the label color) in the
406
+ // controls column; 8 under a header, rows 7 apart, sections touching, a
407
+ // header 4 under the rows before it (right under a footer), a footer 2
408
+ // under the last row.
409
+ const MAC_FORM_LABEL_GAP = 10;
410
+ const MAC_FORM_HEADER_GAP = 8;
411
+ const MAC_FORM_HEADER_TOP = 4;
412
+ const MAC_FORM_ROW_GAP = 7;
413
+ const MAC_FORM_FOOTER_GAP = 2;
414
+ /** The row kinds whose label goes in a Mac form's label column (a stack with one is shifted to it). */
415
+ const MAC_FORM_LABELED_KINDS = ['textfield', 'picker', 'stepper', 'datepicker', 'slider'];
395
416
  /** Under a Mac footer: 5 to its rule, the rule. */
396
417
  const MAC_LIST_FOOTER_BOTTOM = 6;
397
418
  const MAC_NAVSPLIT_SIDEBAR_WIDTH = 220;
@@ -1449,10 +1470,24 @@ interface ListGroupPlan {
1449
1470
  outlines: { group: LayoutNode; frame: Rect }[];
1450
1471
  }
1451
1472
 
1473
+ /** A Mac form's label column entry: text the page draws (`layout`), or a view (`node`) placed at the rect. */
1474
+ interface FormLabelPlan {
1475
+ /** Relative to the list origin at scroll 0; a view's rect is its own size, at the column's trailing edge. */
1476
+ rect: Rect;
1477
+ layout: TextLayout | null;
1478
+ node: LayoutNode | null;
1479
+ ctx: Context;
1480
+ }
1481
+
1452
1482
  interface ListPlan {
1453
1483
  size: Size;
1454
1484
  contentSize: Size;
1455
1485
  groups: ListGroupPlan[];
1486
+ /** The Mac idiom's columns form: its label column's entries. */
1487
+ labels?: FormLabelPlan[];
1488
+ columns?: boolean;
1489
+ /** The columns form's controls column: where it starts (relative to the list origin) and its width. */
1490
+ column?: { x: number; width: number };
1456
1491
  }
1457
1492
 
1458
1493
  interface ScrollPlan {
@@ -2549,8 +2584,8 @@ export class LayoutPass {
2549
2584
  const font = mac ? makeFont('system', MAC_SEGMENTED_FONT_SIZE, 400) : makeFont('system', PICKER_SEGMENT_FONT_SIZE, 600);
2550
2585
  let natural = 0;
2551
2586
  for (const option of options) natural += this.measurer.measureWidth(option, font) + PICKER_SEGMENT_PAD;
2552
- // A Mac shows the picker's label before the control.
2553
- const labelText = mac ? (str(node.props['label']) ?? '') : '';
2587
+ // A Mac shows the picker's label before the control (in a form's row it is in the label column).
2588
+ const labelText = mac && !ctx.formColumn ? (str(node.props['label']) ?? '') : '';
2554
2589
  const labelWidth = labelText.length > 0 && !ctx.navBarItem ? this.measurer.measureWidth(labelText, ctx.font) + MAC_SEGMENTED_LABEL_GAP : 0;
2555
2590
  return {
2556
2591
  size: { width: finite(p.width) ? p.width : natural + labelWidth, height: mac ? MAC_SEGMENTED_HEIGHT : PICKER_SEGMENTED_HEIGHT },
@@ -2564,17 +2599,18 @@ export class LayoutPass {
2564
2599
  };
2565
2600
  }
2566
2601
  const font = ctx.font;
2567
- const labelWidth = this.measurer.measureWidth(str(node.props['label']) ?? '', font);
2602
+ // In a Mac form's row the label is in the label column and the pop-up spans the controls column.
2603
+ const labelWidth = ctx.formColumn ? 0 : this.measurer.measureWidth(str(node.props['label']) ?? '', font);
2568
2604
  const value = options[selected] ?? '';
2569
2605
  const mac = this.env.platform === 'macos';
2570
2606
  // The Mac's pop-up button holds the value and the chevrons in a bordered button.
2571
- const popup = mac && !ctx.listRow;
2572
- const valueWidth = this.measurer.measureWidth(value, font) + PICKER_MENU_CHEVRON_WIDTH + (popup ? 2 * MAC_PICKER_POPUP_PAD : 0);
2607
+ const popup = mac && (!ctx.listRow || ctx.formColumn === true);
2608
+ const valueWidth = ctx.formColumn && finite(p.width) ? p.width : this.measurer.measureWidth(value, font) + PICKER_MENU_CHEVRON_WIDTH + (popup ? 2 * MAC_PICKER_POPUP_PAD : 0);
2573
2609
  const natural = labelWidth + PICKER_MENU_GAP + valueWidth;
2574
2610
  return {
2575
2611
  size: {
2576
2612
  width: finite(p.width) ? p.width : natural,
2577
- height: ctx.listRow ? (mac ? MAC_PICKER_MENU_ROW_HEIGHT : PICKER_MENU_ROW_HEIGHT) : mac ? MAC_PICKER_MENU_HEIGHT : PICKER_MENU_HEIGHT,
2613
+ height: ctx.listRow && !ctx.formColumn ? (mac ? MAC_PICKER_MENU_ROW_HEIGHT : PICKER_MENU_ROW_HEIGHT) : mac ? MAC_PICKER_MENU_HEIGHT : PICKER_MENU_HEIGHT,
2578
2614
  },
2579
2615
  style: 'menu',
2580
2616
  font,
@@ -2635,7 +2671,8 @@ export class LayoutPass {
2635
2671
  const room = Math.max(0, size.width - PICKER_MENU_GAP);
2636
2672
  let labelWidth = plan.labelWidth;
2637
2673
  let valueWidth = plan.valueWidth;
2638
- if (labelWidth + valueWidth > room) {
2674
+ // In a Mac form's controls column the pop-up spans the column (its label is in the label column).
2675
+ if (!ctx.formColumn && labelWidth + valueWidth > room) {
2639
2676
  if (labelWidth <= valueWidth) {
2640
2677
  labelWidth = Math.min(labelWidth, room / 2);
2641
2678
  valueWidth = room - labelWidth;
@@ -2648,7 +2685,7 @@ export class LayoutPass {
2648
2685
  chrome['label'] = { x, y, width: labelWidth, height: size.height };
2649
2686
  chrome['value'] = { x: x + size.width - valueWidth, y, width: valueWidth, height: size.height };
2650
2687
  // The page draws the Mac's pop-up button around the value (the engine padded it).
2651
- if (this.env.platform === 'macos' && !ctx.listRow) chrome['popup'] = chrome['value'];
2688
+ if (this.env.platform === 'macos' && (!ctx.listRow || ctx.formColumn === true)) chrome['popup'] = chrome['value'];
2652
2689
  else delete chrome['popup'];
2653
2690
  }
2654
2691
  this.setChrome(node, chrome);
@@ -3161,6 +3198,7 @@ export class LayoutPass {
3161
3198
 
3162
3199
  private listPlan(node: LayoutNode, ctx: Context, p: Proposal): ListPlan {
3163
3200
  return this.plan(node, ctx, p, () => {
3201
+ if (this.env.platform === 'macos' && node.props['form'] === true) return this.macFormPlan(node, ctx, p);
3164
3202
  const { screen } = this.env;
3165
3203
  const width = finite(p.width) ? p.width : p.width === Infinity ? Infinity : screen.width;
3166
3204
  const layoutWidth = Number.isFinite(width) ? width : screen.width;
@@ -3915,9 +3953,198 @@ export class LayoutPass {
3915
3953
  return { p: { width: p.width, height: size.height + top + bottom }, y: y - top };
3916
3954
  }
3917
3955
 
3956
+ /** The labeled control a form row's stack is shifted to (the form's alignment guide), if it has one. */
3957
+ private macFormGuide(row: LayoutNode): LayoutNode | null {
3958
+ const inner = unwrapStyled(row);
3959
+ if (inner.kind !== 'hstack') return null;
3960
+ return inner.children.find((child) => MAC_FORM_LABELED_KINDS.includes(unwrapStyled(child).kind)) ?? null;
3961
+ }
3962
+
3963
+ /**
3964
+ * The Mac idiom's `Form` (MAC_FORM_* above, docs/notes/macos.md): the
3965
+ * columns style. A row whose control carries a label (a text field's
3966
+ * title, a picker's, a stepper's, a date picker's, a slider's) puts it
3967
+ * in the label column, right-aligned, and the control in the controls
3968
+ * column at the control's own height; a toggle, a button, text and other
3969
+ * rows go in the controls column whole. A stack row with such a control
3970
+ * in it is shifted so the control starts at the controls column, what is
3971
+ * before it reaching into the label column, as the form's alignment guide
3972
+ * does on a Mac. Headers and footers are 13-point text in the controls
3973
+ * column. No cards, no separators.
3974
+ */
3975
+ private macFormPlan(node: LayoutNode, ctx: Context, p: Proposal): ListPlan {
3976
+ const { screen } = this.env;
3977
+ const width = finite(p.width) ? p.width : p.width === Infinity ? Infinity : screen.width;
3978
+ const layoutWidth = Number.isFinite(width) ? width : screen.width;
3979
+ const rowCtx: Context = { ...this.childContext(node, ctx), listRow: true, labelColumn: false, listCell: true, formColumn: true, sidebarList: false };
3980
+ const font = rowCtx.font;
3981
+ const textFont = makeFont('system', textStyleSize('body', this.env.dynamicTypeSize, 'macos'), 400);
3982
+ type Row = { node: LayoutNode; label: string | null; labelNode: LayoutNode | null; guide: LayoutNode | null };
3983
+ type Source = { section: LayoutNode | null; rows: Row[]; headerView: LayoutNode | null; footerView: LayoutNode | null };
3984
+ // The rows, each with what goes in the label column.
3985
+ const classify = (row: LayoutNode): Row => {
3986
+ const inner = unwrapStyled(row);
3987
+ switch (inner.kind) {
3988
+ case 'textfield':
3989
+ return { node: row, label: str(inner.props['placeholder']) || null, labelNode: null, guide: null };
3990
+ case 'picker':
3991
+ return { node: row, label: str(inner.props['label']) || null, labelNode: null, guide: null };
3992
+ case 'stepper':
3993
+ case 'datepicker':
3994
+ return { node: row, label: null, labelNode: inner.props['labelsHidden'] === true ? null : this.contentOf(inner), guide: null };
3995
+ case 'slider': {
3996
+ const names = Array.isArray(inner.props['labels']) ? inner.props['labels'].map(str) : [];
3997
+ const at = names.indexOf('label');
3998
+ return { node: row, label: null, labelNode: at >= 0 ? (inner.children[at] ?? null) : null, guide: null };
3999
+ }
4000
+ default:
4001
+ return { node: row, label: null, labelNode: null, guide: this.macFormGuide(row) };
4002
+ }
4003
+ };
4004
+ const sources: Source[] = [];
4005
+ for (const child of node.children) {
4006
+ if (child.kind === 'section') {
4007
+ const rows = child.children.filter((row) => row.kind !== 'sectionheader' && row.kind !== 'sectionfooter').map(classify);
4008
+ sources.push({ section: child, rows, headerView: child.children.find((row) => row.kind === 'sectionheader') ?? null, footerView: child.children.find((row) => row.kind === 'sectionfooter') ?? null });
4009
+ } else {
4010
+ const last = sources[sources.length - 1];
4011
+ if (last && last.section === null) last.rows.push(classify(child));
4012
+ else sources.push({ section: null, rows: [classify(child)], headerView: null, footerView: null });
4013
+ }
4014
+ }
4015
+ // The label column: as wide as the widest label (a view label at its ideal size).
4016
+ const labelCtx: Context = { ...this.childContext(node, ctx), stackAxis: null, listRow: false, listCell: true };
4017
+ let labelWidth = 0;
4018
+ const labelSizes = new Map<LayoutNode, Size>();
4019
+ for (const source of sources) {
4020
+ for (const row of source.rows) {
4021
+ if (row.label !== null) labelWidth = Math.max(labelWidth, this.measurer.measureWidth(row.label, font));
4022
+ else if (row.labelNode) {
4023
+ const size = this.measure(row.labelNode, labelCtx, { width: null, height: null });
4024
+ labelSizes.set(row.labelNode, size);
4025
+ labelWidth = Math.max(labelWidth, size.width);
4026
+ }
4027
+ }
4028
+ }
4029
+ labelWidth = Math.ceil(labelWidth);
4030
+ const controlX = labelWidth + MAC_FORM_LABEL_GAP;
4031
+ const controlWidth = Math.max(0, layoutWidth - controlX);
4032
+ const textCtx: Context = { ...labelCtx, font: textFont };
4033
+ const makeText = (text: string): { layout: TextLayout; size: Size } => {
4034
+ const wrapped = wrapText(text, textFont, controlWidth, null, this.measurer);
4035
+ return { layout: { font: textFont, lines: wrapped.lines, alignment: 'leading', truncated: false }, size: { width: wrapped.width, height: textHeight(wrapped.lines.length, textFont) } };
4036
+ };
4037
+ const supplement = (view: LayoutNode | null, y: number): ListSupplementPlan | null => {
4038
+ if (!view) return null;
4039
+ const proposal: Proposal = { width: controlWidth, height: null };
4040
+ const size = this.measure(view, textCtx, proposal);
4041
+ return { node: view, ctx: textCtx, proposal, rect: { x: controlX, y, width: size.width, height: size.height } };
4042
+ };
4043
+ // Built from a top, then again centered in the room when the rows are shorter than it.
4044
+ const build = (startY: number): { groups: ListGroupPlan[]; labels: FormLabelPlan[]; end: number } => {
4045
+ const groups: ListGroupPlan[] = [];
4046
+ const labels: FormLabelPlan[] = [];
4047
+ let y = startY;
4048
+ for (let g = 0; g < sources.length; g++) {
4049
+ const source = sources[g] as Source;
4050
+ const previous = groups[g - 1];
4051
+ const headerText = source.section ? str(source.section.props['header']) : null;
4052
+ const footerText = source.section ? str(source.section.props['footer']) : null;
4053
+ const hasHeader = (headerText !== null && headerText.length > 0) || (source.headerView !== null && this.measure(source.headerView, textCtx, { width: controlWidth, height: null }).height > 0);
4054
+ // A header after rows is 4 under them; right under a footer.
4055
+ if (previous && hasHeader && previous.footer === null && !(previous.footerView !== null && previous.footerView.rect.height > 0)) y += MAC_FORM_HEADER_TOP;
4056
+ const top = y;
4057
+ let header: ListGroupPlan['header'] = null;
4058
+ let footer: ListGroupPlan['footer'] = null;
4059
+ const headerView = supplement(source.headerView, y);
4060
+ if (headerView && headerView.rect.height > 0) y += headerView.rect.height + MAC_FORM_HEADER_GAP;
4061
+ if (headerText !== null && headerText.length > 0) {
4062
+ const { layout, size } = makeText(headerText);
4063
+ header = { rect: { x: controlX, y, width: controlWidth, height: size.height }, layout };
4064
+ y += size.height + MAC_FORM_HEADER_GAP;
4065
+ }
4066
+ const cardTop = y;
4067
+ const rows: ListRowPlan[] = [];
4068
+ source.rows.forEach((row, i) => {
4069
+ if (i > 0) y += MAC_FORM_ROW_GAP;
4070
+ if (row.node.kind === 'lazyspacer') {
4071
+ const rect: Rect = { x: controlX, y, width: controlWidth, height: 0 };
4072
+ rows.push({ node: row.node, proposal: { width: controlWidth, height: 0 }, contentSize: ZERO, row: rect, content: rect, badge: null, edit: null });
4073
+ return;
4074
+ }
4075
+ // A shifted stack is proposed the whole row less the trailing inset; its place is set when the list is placed.
4076
+ // A shifted stack is proposed the whole row; its place and width are set when the list is placed.
4077
+ const proposal: Proposal = { width: row.guide ? layoutWidth : controlWidth, height: null };
4078
+ const contentSize = this.measure(row.node, rowCtx, proposal);
4079
+ const labelSize = row.labelNode ? labelSizes.get(row.labelNode) ?? ZERO : row.label !== null ? { width: this.measurer.measureWidth(row.label, font), height: font.lineHeight } : ZERO;
4080
+ const rowHeight = Math.max(contentSize.height, labelSize.height);
4081
+ // A text field's row is the field itself (the page draws the Mac's bezel on the row element); the others span the form.
4082
+ const field = unwrapStyled(row.node).kind === 'textfield';
4083
+ rows.push({
4084
+ node: row.node,
4085
+ proposal,
4086
+ contentSize,
4087
+ row: field ? { x: controlX, y, width: controlWidth, height: rowHeight } : { x: 0, y, width: layoutWidth, height: rowHeight },
4088
+ content: { x: row.guide ? 0 : controlX, y: y + (rowHeight - contentSize.height) / 2, width: contentSize.width, height: contentSize.height },
4089
+ badge: null,
4090
+ edit: null,
4091
+ separatorHidden: { top: true, bottom: true },
4092
+ });
4093
+ if (row.label !== null) {
4094
+ labels.push({
4095
+ rect: { x: 0, y: y + (rowHeight - labelSize.height) / 2, width: labelWidth, height: labelSize.height },
4096
+ layout: { font, lines: [row.label], alignment: 'trailing', truncated: false },
4097
+ node: null,
4098
+ ctx: labelCtx,
4099
+ });
4100
+ } else if (row.labelNode) {
4101
+ labels.push({
4102
+ rect: { x: labelWidth - labelSize.width, y: y + (rowHeight - labelSize.height) / 2, width: labelSize.width, height: labelSize.height },
4103
+ layout: null,
4104
+ node: row.labelNode,
4105
+ ctx: labelCtx,
4106
+ });
4107
+ }
4108
+ y += rowHeight;
4109
+ });
4110
+ // The rows' card spans the form: the label column is drawn in it too.
4111
+ const card: Rect = { x: 0, y: cardTop, width: layoutWidth, height: y - cardTop };
4112
+ if (footerText !== null && footerText.length > 0) {
4113
+ y += MAC_FORM_FOOTER_GAP;
4114
+ const { layout, size } = makeText(footerText);
4115
+ footer = { rect: { x: controlX, y, width: controlWidth, height: size.height }, layout };
4116
+ y += size.height;
4117
+ }
4118
+ let footerView = supplement(source.footerView, y + MAC_FORM_FOOTER_GAP);
4119
+ if (footerView && footerView.rect.height > 0) y += MAC_FORM_FOOTER_GAP + footerView.rect.height;
4120
+ else if (footerView) footerView = { ...footerView, rect: { ...footerView.rect, y } };
4121
+ groups.push({ section: source.section, frame: { x: 0, y: top, width: layoutWidth, height: y - top }, header, footer, headerView, footerView, card, rows, outlines: [] });
4122
+ }
4123
+ return { groups, labels, end: y };
4124
+ };
4125
+ let built = build(0);
4126
+ // Centered in the room under the bar when shorter than it (the Mac's columns form does not scroll).
4127
+ const scrollInset = this.scrollInsets.get(node);
4128
+ const inset = scrollInset ? scrollInset.bar + (scrollInset.largeTitle?.height ?? 0) + (scrollInset.search ?? 0) : 0;
4129
+ const room = finite(p.height) ? p.height - inset : null;
4130
+ if (room !== null && room > built.end) built = build((room - built.end) / 2);
4131
+ const contentHeight = built.end;
4132
+ return {
4133
+ size: { width, height: p.height === null ? contentHeight : p.height },
4134
+ contentSize: { width: layoutWidth, height: contentHeight },
4135
+ groups: built.groups,
4136
+ labels: built.labels,
4137
+ columns: true,
4138
+ column: { x: controlX, width: controlWidth },
4139
+ };
4140
+ }
4141
+
3918
4142
  private placeList(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number): void {
3919
4143
  const plan = this.listPlan(node, ctx, p);
3920
- const rowCtx: Context = { ...this.childContext(node, ctx), listRow: true, labelColumn: this.env.platform !== 'macos', listCell: this.env.platform === 'macos', sidebarList: str(node.props['style']) === 'sidebar' };
4144
+ const columns = plan.columns === true;
4145
+ const rowCtx: Context = columns
4146
+ ? { ...this.childContext(node, ctx), listRow: true, labelColumn: false, listCell: true, formColumn: true, sidebarList: false }
4147
+ : { ...this.childContext(node, ctx), listRow: true, labelColumn: this.env.platform !== 'macos', listCell: this.env.platform === 'macos', sidebarList: str(node.props['style']) === 'sidebar' };
3921
4148
  const mac = this.env.platform === 'macos';
3922
4149
  const cellPadding = mac ? MAC_LIST_CELL_PADDING : LIST_CELL_PADDING;
3923
4150
  const covered = this.coveredBottom(ctx, x, y, plan.size);
@@ -3925,14 +4152,27 @@ export class LayoutPass {
3925
4152
  // rows start below them.
3926
4153
  const scrollInset = this.scrollInsets.get(node);
3927
4154
  const listChrome: Record<string, Rect> = {};
4155
+ const listTexts: Record<string, TextLayout> = {};
3928
4156
  if (covered) listChrome['bottomInset'] = covered;
3929
4157
  if (scrollInset) listChrome['topInset'] = { x, y, width: plan.size.width, height: scrollInset.bar };
3930
4158
  if (scrollInset?.largeTitle) listChrome['largeTitle'] = scrollInset.largeTitle;
3931
- if (Object.keys(listChrome).length > 0) this.setChrome(node, listChrome);
3932
4159
  const inset = scrollInset ? scrollInset.bar + (scrollInset.largeTitle?.height ?? 0) + (scrollInset.search ?? 0) : 0;
3933
4160
  if (node.id >= 0) this.contentSizes.set(node.id, { width: plan.contentSize.width, height: plan.contentSize.height + inset });
3934
4161
  y += inset;
3935
4162
  const offset = (r: Rect): Rect => ({ x: x + r.x, y: y + r.y, width: r.width, height: r.height });
4163
+ // The Mac idiom's columns form: the label column (chrome `formLabel<i>`
4164
+ // with its text, or a view placed there) and the flag for the page.
4165
+ if (columns) {
4166
+ listChrome['columns'] = { x, y, width: plan.size.width, height: plan.size.height };
4167
+ (plan.labels ?? []).forEach((label, i) => {
4168
+ if (label.layout) {
4169
+ listChrome[`formLabel${i}`] = offset(label.rect);
4170
+ listTexts[`formLabel${i}`] = label.layout;
4171
+ }
4172
+ });
4173
+ }
4174
+ if (Object.keys(listChrome).length > 0) this.setChrome(node, listChrome);
4175
+ if (node.id >= 0 && Object.keys(listTexts).length > 0) this.chromeText.set(node.id, listTexts);
3936
4176
  for (const group of plan.groups) {
3937
4177
  if (group.section) {
3938
4178
  const chrome: Record<string, Rect> = { rows: offset(group.card) };
@@ -4004,15 +4244,31 @@ export class LayoutPass {
4004
4244
  if (row.edit.listRow === row.node) Object.assign(chrome, controls);
4005
4245
  else this.setChrome(row.edit.listRow, controls);
4006
4246
  }
4007
- const rowX = x + row.content.x;
4247
+ let rowX = x + row.content.x;
4008
4248
  const rowY = y + row.content.y;
4249
+ // A columns form's shifted stack: placed, then moved so its labeled
4250
+ // control starts at the controls column (the form's alignment guide).
4251
+ // Placed once to find the control's offset in it, then proposed the
4252
+ // width that ends it at the trailing edge, from where the control
4253
+ // lands at the column.
4254
+ const guide = columns ? this.macFormGuide(row.node) : null;
4255
+ let proposal = row.proposal;
4256
+ if (guide && plan.column) {
4257
+ this.place(row.node, rowCtx, row.proposal, rowX, rowY);
4258
+ const placed = this.frames.get(unwrapStyled(guide).id);
4259
+ if (placed) {
4260
+ const offset = placed.x - rowX;
4261
+ rowX = x + plan.column.x - offset;
4262
+ proposal = { width: Math.max(0, plan.size.width - (plan.column.x - offset)), height: row.proposal.height };
4263
+ }
4264
+ }
4009
4265
  // A row that is skipped keeps the chrome it set itself when it was
4010
4266
  // placed (a link's chevron and label); only the list's keys are new.
4011
- const own = this.placedAlready(row.node, rowCtx, row.proposal, rowX, rowY) && row.node.id >= 0
4267
+ const own = this.placedAlready(row.node, rowCtx, proposal, rowX, rowY) && row.node.id >= 0
4012
4268
  ? this.chrome.get(row.node.id)
4013
4269
  : undefined;
4014
4270
  this.setChrome(row.node, own ? { ...own, ...chrome } : chrome);
4015
- this.place(row.node, rowCtx, row.proposal, rowX, rowY);
4271
+ this.place(row.node, rowCtx, proposal, rowX, rowY);
4016
4272
  if (row.badge) this.badgeBeforeChevron(row.node);
4017
4273
  // iOS 26: the separator under a row starts where its first text
4018
4274
  // starts (after a Label's icon), else at the row's content.
@@ -4032,6 +4288,12 @@ export class LayoutPass {
4032
4288
  }
4033
4289
  }
4034
4290
  }
4291
+ // The label column's views (a stepper's, a slider's, a date picker's label) go where the plan put them, after the rows (which do not draw them).
4292
+ if (columns) {
4293
+ for (const label of plan.labels ?? []) {
4294
+ if (label.node) this.place(label.node, label.ctx, { width: null, height: null }, x + label.rect.x, y + label.rect.y);
4295
+ }
4296
+ }
4035
4297
  }
4036
4298
 
4037
4299
  /** The x of the first text (in tree order) placed under `node`, if any. */
@@ -4199,7 +4461,12 @@ export class LayoutPass {
4199
4461
  chrome['keyboardBar'] = { x: x + KEYBOARD_BAR_MARGIN_H, y: y + size.height - keyboard, width: Math.max(0, size.width - 2 * KEYBOARD_BAR_MARGIN_H), height: KEYBOARD_BAR_HEIGHT };
4200
4462
  }
4201
4463
  const title = str(node.props['title']) ?? '';
4464
+ // `.navigationSubtitle`: a second line under the title in a Mac's unified
4465
+ // title bar (11 regular, secondary); after the title in a plain one.
4202
4466
  const { platform } = this.env;
4467
+ const subtitle = mac ? (str(node.props['subtitle']) ?? '') : '';
4468
+ const shownTitle = subtitle.length > 0 && !unified && !sheetBar ? `${title} — ${subtitle}` : title;
4469
+ const subtitleFont = makeFont('system', textStyleSize('subheadline', dynamicTypeSize, platform), 400);
4203
4470
  // A unified title bar's title is the window title (15 semibold on macOS 14); a plain bar's and iOS's is the headline.
4204
4471
  const titleFont = makeFont('system', textStyleSize(unified ? 'title3' : 'headline', dynamicTypeSize, platform), sheetBar ? 700 : 600);
4205
4472
  const largeTitleFont = makeFont('system', textStyleSize('largeTitle', dynamicTypeSize, platform), 700);
@@ -4537,7 +4804,16 @@ export class LayoutPass {
4537
4804
  const titleX = Math.max(leadingStart + (unified && windowButtons && !back && leadingWidth === 0 && (cctx.barLeading ?? 0) === 0 ? MAC_TITLE_INSET : 0), leadingCursor);
4538
4805
  const roomEnd = trailingUsed ? trailingEdge + NAV_GROUP_SPACING - 8 : x + size.width - NAV_ITEM_MARGIN;
4539
4806
  const titleWidth = Math.min(Math.ceil(this.measurer.measureWidth(title, titleFont) - 0.001), Math.floor(Math.max(0, roomEnd - titleX)));
4540
- chrome['title'] = { x: titleX, y: rowY, width: titleWidth, height: rowHeight };
4807
+ if (subtitle.length > 0) {
4808
+ // The title and the subtitle stacked, centered in the row.
4809
+ const block = titleFont.lineHeight + subtitleFont.lineHeight;
4810
+ const top = rowY + (rowHeight - block) / 2;
4811
+ chrome['title'] = { x: titleX, y: top, width: titleWidth, height: titleFont.lineHeight };
4812
+ const subtitleWidth = Math.min(Math.ceil(this.measurer.measureWidth(subtitle, subtitleFont) - 0.001), Math.floor(Math.max(0, roomEnd - titleX)));
4813
+ chrome['subtitle'] = { x: titleX, y: top + titleFont.lineHeight, width: subtitleWidth, height: subtitleFont.lineHeight };
4814
+ } else {
4815
+ chrome['title'] = { x: titleX, y: rowY, width: titleWidth, height: rowHeight };
4816
+ }
4541
4817
  } else if ((!large || titleScrolls) && !principal && !barHidden) {
4542
4818
  const leftUsed = Math.max(
4543
4819
  back ? back.x + back.width - x : 0,
@@ -4553,7 +4829,7 @@ export class LayoutPass {
4553
4829
  const roomEnd = x + size.width - rightUsed - 8;
4554
4830
  const compactCap = screen.width < REGULAR_WIDTH ? Math.floor(size.width * 0.56) : Infinity;
4555
4831
  const titleWidth = Math.min(
4556
- Math.ceil(this.measurer.measureWidth(title, titleFont) - 0.001),
4832
+ Math.ceil(this.measurer.measureWidth(shownTitle, titleFont) - 0.001),
4557
4833
  compactCap,
4558
4834
  Math.floor(Math.max(0, roomEnd - roomStart)),
4559
4835
  );
@@ -4566,8 +4842,9 @@ export class LayoutPass {
4566
4842
  if (node.id >= 0) {
4567
4843
  this.fonts.set(node.id, large ? largeTitleFont : titleFont);
4568
4844
  const texts: Record<string, TextLayout> = {
4569
- title: { font: titleFont, lines: [title], alignment: unified || sheetBar ? 'leading' : 'center', truncated: false },
4845
+ title: { font: titleFont, lines: [shownTitle], alignment: unified || sheetBar ? 'leading' : 'center', truncated: false },
4570
4846
  };
4847
+ if (chrome['subtitle']) texts['subtitle'] = { font: subtitleFont, lines: [subtitle], alignment: 'leading', truncated: false };
4571
4848
  if (depth > 0) texts['back'] = { font: backFont, lines: [backLabel], alignment: 'leading', truncated: false };
4572
4849
  if (large) {
4573
4850
  const wrapped = wrapText(title, largeTitleFont, size.width - 32, 1, this.measurer);
@@ -5157,6 +5434,8 @@ export class LayoutPass {
5157
5434
  private measureStepper(node: LayoutNode, ctx: Context, p: Proposal): Size {
5158
5435
  const label = this.contentOf(node);
5159
5436
  const control = this.stepperMetrics();
5437
+ // A Mac form's row: the label is in the label column; the control alone.
5438
+ if (ctx.formColumn) return { width: control.width, height: control.height };
5160
5439
  const labelSize = label ? this.measure(label, this.childContext(node, ctx), this.stepperLabelProposal(p)) : ZERO;
5161
5440
  return {
5162
5441
  width: finite(p.width) ? p.width : labelSize.width + control.gap + control.width,
@@ -5168,11 +5447,12 @@ export class LayoutPass {
5168
5447
  const cctx = this.childContext(node, ctx);
5169
5448
  const label = this.contentOf(node);
5170
5449
  const labelProposal = this.stepperLabelProposal(p);
5171
- const labelSize = label ? this.measure(label, cctx, labelProposal) : ZERO;
5450
+ const labelSize = label && !ctx.formColumn ? this.measure(label, cctx, labelProposal) : ZERO;
5172
5451
  const labelY = y + (size.height - labelSize.height) / 2;
5173
- if (label) this.place(label, cctx, labelProposal, x, labelY);
5452
+ // A Mac form's row: the label is in the label column (the form places it), the control at the leading edge.
5453
+ if (label && !ctx.formColumn) this.place(label, cctx, labelProposal, x, labelY);
5174
5454
  const control = this.stepperMetrics();
5175
- const controlX = x + size.width - control.width;
5455
+ const controlX = ctx.formColumn ? x : x + size.width - control.width;
5176
5456
  const controlY = y + (size.height - control.height) / 2;
5177
5457
  const existing = node.id >= 0 ? this.chrome.get(node.id) : undefined;
5178
5458
  // iOS: − and + side by side; a Mac: up (increment) over down.
@@ -5232,7 +5512,8 @@ export class LayoutPass {
5232
5512
  private placeSlider(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number, size: Size): void {
5233
5513
  const plan = this.sliderPlan(node, ctx, p);
5234
5514
  const { cctx } = plan;
5235
- if (plan.label) this.hide(plan.label, node);
5515
+ // The label is not drawn (a Mac form places it in its label column).
5516
+ if (plan.label && !ctx.formColumn) this.hide(plan.label, node);
5236
5517
  const m = this.sliderMetrics();
5237
5518
  const rowY = y;
5238
5519
  let trackX = x;
@@ -5289,10 +5570,11 @@ export class LayoutPass {
5289
5570
  const dateWidth = capsule(dateText);
5290
5571
  const timeWidth = capsule(timeText);
5291
5572
  const capsulesWidth = dateWidth + timeWidth + (dateText !== null && timeText !== null ? capsuleGap : 0);
5292
- const label = node.props['labelsHidden'] === true ? null : this.contentOf(node);
5573
+ // A Mac form's row: the label is in the label column; the fields alone.
5574
+ const label = node.props['labelsHidden'] === true || ctx.formColumn ? null : this.contentOf(node);
5293
5575
  const labelProposal: Proposal = { width: shrink(p.width, capsulesWidth + DATEPICKER_GAP), height: p.height };
5294
5576
  const labelSize = label ? this.measure(label, cctx, labelProposal) : ZERO;
5295
- const width = finite(p.width) ? p.width : labelSize.width + (label ? DATEPICKER_GAP : 0) + capsulesWidth;
5577
+ const width = ctx.formColumn ? capsulesWidth : finite(p.width) ? p.width : labelSize.width + (label ? DATEPICKER_GAP : 0) + capsulesWidth;
5296
5578
  return {
5297
5579
  size: { width, height: Math.max(labelSize.height, mac ? MAC_DATEPICKER_HEIGHT : DATEPICKER_HEIGHT) },
5298
5580
  label,
@@ -5308,8 +5590,8 @@ export class LayoutPass {
5308
5590
 
5309
5591
  private placeDatePicker(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number, size: Size): void {
5310
5592
  const plan = this.datePickerPlan(node, ctx, p);
5311
- if (!plan.label) {
5312
- // `labelsHidden`: the label child exists but is not shown.
5593
+ if (!plan.label && (!ctx.formColumn || node.props['labelsHidden'] === true)) {
5594
+ // `labelsHidden`: the label child exists but is not shown (a Mac form places it in its label column).
5313
5595
  for (const child of node.children) this.hide(child, node);
5314
5596
  }
5315
5597
  const labelY = y + (size.height - plan.labelSize.height) / 2;
package/src/layoutDom.ts CHANGED
@@ -877,6 +877,27 @@ function applyChrome(
877
877
  }
878
878
  bindScrollUnderBar(el, frame, chrome?.['topInset'], chrome?.['largeTitle']);
879
879
  bindScrollEdgeBottom(el, chrome?.['bottomInset'], extent);
880
+ // The Mac idiom's columns form (`data-sb-form-columns`, styles.css):
881
+ // the label column's texts (chrome `formLabel<i>`) as spans in the
882
+ // content, as many as the engine placed.
883
+ setFlag(el, 'sbFormColumns', chrome?.['columns'] ? '' : null);
884
+ if (content) {
885
+ const texts = result.chromeText.get(node.id);
886
+ const labels = content.querySelectorAll<HTMLElement>(':scope > .sb-form-label');
887
+ let i = 0;
888
+ for (; chrome?.[`formLabel${i}`]; i++) {
889
+ let label = labels[i] ?? null;
890
+ if (!label) {
891
+ label = document.createElement('span');
892
+ label.className = 'sb-form-label';
893
+ content.append(label);
894
+ }
895
+ placeChrome(label, chrome[`formLabel${i}`], frame);
896
+ const layout = texts?.[`formLabel${i}`];
897
+ if (layout) setTextLayout(label, layout);
898
+ }
899
+ for (let extra = i; extra < labels.length; extra++) labels[extra]!.remove();
900
+ }
880
901
  return;
881
902
  }
882
903
  case 'scrollview': {
@@ -976,6 +997,17 @@ function applyChrome(
976
997
  if (inlineTitle.style.lineHeight !== lineHeight) inlineTitle.style.lineHeight = lineHeight;
977
998
  }
978
999
  }
1000
+ // The Mac idiom: the subtitle under the title in a unified title bar (chrome `subtitle`, hidden otherwise).
1001
+ const subtitleEl = el.querySelector<HTMLElement>(':scope > .sb-navbar > .sb-navbar-subtitle');
1002
+ if (subtitleEl) {
1003
+ const subtitleRect = chrome?.['subtitle'];
1004
+ placeChrome(subtitleEl, subtitleRect, frame);
1005
+ if (subtitleRect) {
1006
+ if (texts?.['subtitle']) setFont(subtitleEl, texts['subtitle'].font);
1007
+ const lineHeight = `${subtitleRect.height}px`;
1008
+ if (subtitleEl.style.lineHeight !== lineHeight) subtitleEl.style.lineHeight = lineHeight;
1009
+ }
1010
+ }
979
1011
  // The More button of the collapsed `secondary` items, at the trailing end.
980
1012
  placeChrome(el.querySelector<HTMLElement>(':scope > .sb-navbar > .sb-navbar-more'), chrome?.['more'], frame);
981
1013
  const back = el.querySelector<HTMLElement>(':scope > .sb-navbar > .sb-navbar-back');
package/src/protocol.ts CHANGED
@@ -623,6 +623,8 @@ export interface ScrollViewProps {
623
623
  export type ListStyle = 'plain' | 'insetGrouped' | 'grouped' | 'sidebar';
624
624
  export interface ListProps {
625
625
  style?: ListStyle;
626
+ /** A `Form`: on a Mac the page lays its rows out in the columns style (labels in a column before their controls). */
627
+ form?: boolean;
626
628
  }
627
629
  export interface SectionProps {
628
630
  header: string | null;
@@ -679,6 +681,8 @@ export type NavStackProps = Record<string, never>;
679
681
  export type NavDisplayMode = 'automatic' | 'large' | 'inline';
680
682
  export interface NavScreenProps {
681
683
  title: string;
684
+ /** `.navigationSubtitle`: a second line under the title in a Mac's unified title bar, after it in a plain one. */
685
+ subtitle?: string;
682
686
  displayMode: NavDisplayMode;
683
687
  /** 0 for the root screen; the renderer draws a back button when > 0. */
684
688
  depth: number;
package/src/renderer.ts CHANGED
@@ -3163,6 +3163,9 @@ function createShell(kind: ElementKind): HTMLElement {
3163
3163
  if (chevron) back.append(chevron);
3164
3164
  back.append(span('sb-navbar-back-label'));
3165
3165
  const inlineTitle = span('sb-navbar-title');
3166
+ // `.navigationSubtitle`: under the title in a Mac's unified title bar (chrome.subtitle).
3167
+ const subtitle = span('sb-navbar-subtitle');
3168
+ subtitle.hidden = true;
3166
3169
  // The More button of the collapsed `secondary` items (chrome.more).
3167
3170
  const more = document.createElement('button');
3168
3171
  more.type = 'button';
@@ -3172,7 +3175,7 @@ function createShell(kind: ElementKind): HTMLElement {
3172
3175
  more.hidden = true;
3173
3176
  const ellipsis = renderSymbol('ellipsis');
3174
3177
  if (ellipsis) more.append(ellipsis);
3175
- bar.append(back, inlineTitle, div('sb-navbar-trailing'), more);
3178
+ bar.append(back, inlineTitle, subtitle, div('sb-navbar-trailing'), more);
3176
3179
  const body = div('sb-navscreen-body');
3177
3180
  const slot = div('sb-slot sb-navscreen-content');
3178
3181
  // The `keyboard` toolbars' bar (chrome `keyboardBar`), under their items.
@@ -4787,6 +4790,11 @@ function applyNavScreen(el: HTMLElement, props: NavScreenProps): void {
4787
4790
  const largeTitle = largeTitleOf(el);
4788
4791
  // A large title that scrolls away collapses into the inline one (shown
4789
4792
  // with `data-sb-collapsed`, src/largeTitle.ts).
4793
+ const subtitleEl = el.querySelector<HTMLElement>(':scope > .sb-navbar > .sb-navbar-subtitle');
4794
+ if (subtitleEl) {
4795
+ const subtitle = props.subtitle ?? '';
4796
+ if (subtitleEl.textContent !== subtitle) subtitleEl.textContent = subtitle;
4797
+ }
4790
4798
  if (inlineTitle) {
4791
4799
  const appearing = inlineTitle.textContent === '' && title !== '' && inlineTitle.isConnected;
4792
4800
  inlineTitle.textContent = title;
package/src/styles.css CHANGED
@@ -3944,7 +3944,8 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
3944
3944
  .sb-datepicker > [hidden],
3945
3945
  .sb-stepper > [hidden],
3946
3946
  .sb-slider > [hidden],
3947
- .sb-slider-labels > [hidden] {
3947
+ .sb-slider-labels > [hidden],
3948
+ .sb-picker > .sb-picker-label[hidden] {
3948
3949
  display: none;
3949
3950
  }
3950
3951
 
@@ -4659,7 +4660,8 @@ html[data-sb-platform="macos"] .device[data-theme="light"] {
4659
4660
  --sb-mac-control-shadow:
4660
4661
  0 0 0 0.5px rgba(0, 0, 0, 0.12),
4661
4662
  0 1px 1px rgba(0, 0, 0, 0.08);
4662
- /* A text field's edges: light at the sides and top, darker at the bottom. */
4663
+ /* A text field: the text background, its edges light at the sides and top, darker at the bottom. */
4664
+ --sb-mac-field: #ffffff;
4663
4665
  --sb-mac-field-shadow:
4664
4666
  inset 0 0 0 1px rgba(0, 0, 0, 0.11),
4665
4667
  inset 0 -1px 0 0 rgba(0, 0, 0, 0.27);
@@ -4682,6 +4684,7 @@ html[data-sb-platform="macos"] .device[data-theme="dark"] {
4682
4684
  --sb-mac-control-shadow:
4683
4685
  0 0 0 0.5px rgba(0, 0, 0, 0.4),
4684
4686
  0 1px 1px rgba(0, 0, 0, 0.2);
4687
+ --sb-mac-field: #333333;
4685
4688
  --sb-mac-field-shadow:
4686
4689
  inset 0 0 0 1px rgba(255, 255, 255, 0.12),
4687
4690
  inset 0 -1px 0 0 rgba(255, 255, 255, 0.2);
@@ -4769,6 +4772,89 @@ html[data-sb-platform="macos"] .sb-navscreen[data-sb-unified] > .sb-navbar > .sb
4769
4772
  font-size: var(--sb-ts-title3-size, 15px);
4770
4773
  }
4771
4774
 
4775
+ /* `.navigationSubtitle`: a second line under the title in a unified title
4776
+ bar (the engine's `subtitle` rect; in a plain bar it joins the title). */
4777
+ .sb-navbar-subtitle {
4778
+ position: absolute;
4779
+ display: block;
4780
+ overflow: hidden;
4781
+ text-overflow: ellipsis;
4782
+ white-space: nowrap;
4783
+ text-align: start;
4784
+ font-size: 11px;
4785
+ color: var(--sb-color-secondary);
4786
+ }
4787
+
4788
+ .sb-navbar-subtitle[hidden] {
4789
+ display: none;
4790
+ }
4791
+
4792
+ /* A `Form` is the columns style (the engine's `columns` plan): on the
4793
+ window background with no cards or separators; the labels (chrome
4794
+ `formLabel<i>`) right-aligned in their column, the label color; a
4795
+ section's header and footer 13 regular, the label color; a text field's
4796
+ title moved to the column shows no placeholder. */
4797
+ html[data-sb-platform="macos"] .sb-list[data-sb-form-columns] {
4798
+ background: var(--sb-color-system-background);
4799
+ }
4800
+
4801
+ /* The rows' card spans the form (the label column is drawn in it): no card look, nothing clipped. */
4802
+ html[data-sb-platform="macos"] .sb-list[data-sb-form-columns] .sb-section-rows {
4803
+ border-radius: 0;
4804
+ background: transparent;
4805
+ overflow: visible;
4806
+ }
4807
+
4808
+ /* A text field's row is the field (the engine's row rect): the Mac's bezel, its text 3 in. */
4809
+ html[data-sb-platform="macos"] .sb-list[data-sb-form-columns] .sb-textfield[data-sb-style] {
4810
+ box-sizing: border-box;
4811
+ padding: 0 3px;
4812
+ border-radius: 5px;
4813
+ background: var(--sb-mac-field);
4814
+ box-shadow: var(--sb-mac-field-shadow);
4815
+ }
4816
+
4817
+ html[data-sb-platform="macos"] .sb-list[data-sb-form-columns] .sb-textfield[data-sb-style]:focus {
4818
+ box-shadow:
4819
+ var(--sb-mac-field-shadow),
4820
+ 0 0 0 3.5px color-mix(in srgb, var(--sb-tint, var(--sb-color-accent)) 50%, transparent);
4821
+ }
4822
+
4823
+ html[data-sb-platform="macos"] .sb-list[data-sb-form-columns] .sb-section-rows > ::after,
4824
+ html[data-sb-platform="macos"] .sb-list[data-sb-form-columns] > .sb-list-content > :not(.sb-section)::after,
4825
+ html[data-sb-platform="macos"] .sb-list[data-sb-form-columns] .sb-section-header::after,
4826
+ html[data-sb-platform="macos"] .sb-list[data-sb-form-columns] .sb-section-footer::after {
4827
+ display: none;
4828
+ }
4829
+
4830
+ html[data-sb-platform="macos"] .sb-list[data-sb-form-columns] .sb-section-header,
4831
+ html[data-sb-platform="macos"] .sb-list[data-sb-form-columns] .sb-section-footer {
4832
+ color: var(--sb-color-primary);
4833
+ }
4834
+
4835
+ html[data-sb-platform="macos"] .sb-list[data-sb-form-columns] .sb-form-label {
4836
+ position: absolute;
4837
+ white-space: pre;
4838
+ text-align: right;
4839
+ color: var(--sb-color-primary);
4840
+ }
4841
+
4842
+ @supports (text-box: trim-start cap alphabetic) {
4843
+ html[data-sb-platform="macos"] .sb-list[data-sb-form-columns] .sb-form-label {
4844
+ text-box: trim-start cap alphabetic;
4845
+ padding-top: var(--sb-cap-inset);
4846
+ }
4847
+ }
4848
+
4849
+ html[data-sb-platform="macos"] .sb-list[data-sb-form-columns] input.sb-textfield::placeholder {
4850
+ color: transparent;
4851
+ }
4852
+
4853
+ /* A pop-up button spanning the controls column: its value at the leading end, the chevrons at the trailing. */
4854
+ html[data-sb-platform="macos"] .sb-list[data-sb-form-columns] .sb-picker[data-sb-popup] > .sb-picker-value {
4855
+ justify-content: space-between;
4856
+ }
4857
+
4772
4858
  /* The tab bar: a segmented control at the top of the content (the engine's
4773
4859
  Mac tab view), each tab a segment with its title alone, the selected one
4774
4860
  raised; no icons, no glass. */
@@ -4987,11 +5073,15 @@ html[data-sb-platform="macos"] .sb-section-footer::after {
4987
5073
  background: var(--sb-color-row-separator);
4988
5074
  }
4989
5075
 
4990
- /* The rule under a footer only where another section follows. */
5076
+ /* The rule under a footer only where another section follows (a columns form has none). */
4991
5077
  html[data-sb-platform="macos"] .sb-section:has(+ .sb-section) > .sb-section-footer::after {
4992
5078
  display: block;
4993
5079
  }
4994
5080
 
5081
+ html[data-sb-platform="macos"] .sb-list[data-sb-form-columns] .sb-section:has(+ .sb-section) > .sb-section-footer::after {
5082
+ display: none;
5083
+ }
5084
+
4995
5085
  /* A button in a list row is a push button at its own size (the engine's
4996
5086
  `bordered` rect, `data-sb-bordered="inset"`), on the row, not the row
4997
5087
  itself; a destructive one is not tinted. */
@@ -5118,7 +5208,7 @@ html[data-sb-platform="macos"] .sb-textfield:not([data-sb-style="plain"]) {
5118
5208
  padding: 0 6px;
5119
5209
  border: 0;
5120
5210
  border-radius: 5px;
5121
- background: var(--sb-mac-control);
5211
+ background: var(--sb-mac-field);
5122
5212
  box-shadow: var(--sb-mac-field-shadow);
5123
5213
  font-size: 13px;
5124
5214
  }
@@ -5149,6 +5239,11 @@ html[data-sb-platform="macos"] .sb-stepper[data-sb-arrows] > .sb-stepper-control
5149
5239
  line-height: 0;
5150
5240
  }
5151
5241
 
5242
+ /* The arrows stand in for the plus and minus glyphs. */
5243
+ html[data-sb-platform="macos"] .sb-stepper[data-sb-arrows] > .sb-stepper-control > .sb-stepper-button > svg {
5244
+ display: none;
5245
+ }
5246
+
5152
5247
  html[data-sb-platform="macos"] .sb-stepper[data-sb-arrows] > .sb-stepper-control > .sb-stepper-button::after {
5153
5248
  content: "";
5154
5249
  width: 0;