@nanobpm/urban 0.50.0 → 0.51.0

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.
@@ -498,6 +498,16 @@ table.pc-grid { width:100%; border-collapse:collapse; font-size:.9rem; table-lay
498
498
  neighbours. */
499
499
  table.pc-grid th, table.pc-grid td { text-align:left; padding:.4rem .6rem; border-bottom:1px solid var(--nano-edge); overflow-wrap:anywhere; word-wrap:break-word; vertical-align:top; }
500
500
  table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
501
+ /* A cell's optional muted second line (col.subtitleField / subtitleTemplate):
502
+ the identity key rendered under the title, GitHub/Linear style. Plain DOM
503
+ text (never a link/href source), smaller and muted so the primary line leads. */
504
+ .pc-cell-sub { font-size:.8rem; color:var(--nano-text-muted); margin-top:.1rem; }
505
+ /* One-line clamp (col.truncate): stop a bounded column wrapping and show an
506
+ ellipsis, with the full value carried in a title=/aria-label tooltip. Needs a
507
+ bounded column (per-column width/weight) to clip against; a native <col> width
508
+ is honoured under table-layout:fixed. Applied to the primary and, when present,
509
+ the subtitle line so both stay single-line. */
510
+ .pc-truncate { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
501
511
  .pc-tabs { display:flex; gap:.5rem; margin-bottom:.75rem; }
502
512
  .pc-tab { padding:.35rem .8rem; border:1px solid var(--nano-edge); border-radius:.4rem; background:transparent; color:inherit; font:inherit; cursor:pointer; }
503
513
  .pc-tab.active { background:var(--nano-accent); color:var(--nano-on-accent); border-color:var(--nano-accent); }
@@ -987,6 +997,15 @@ function renderButton(node) {
987
997
  // tooltip stay keyed to the raw 'field' value (a badge shows a fixed glyph, not
988
998
  // the field text), so a template does not change badge behaviour. Sorting,
989
999
  // groupBy and orderBy also key off real fields, never the rendered template.
1000
+ //
1001
+ // Two-line identity + one-line clamp (additive, opt-in, backward-compatible):
1002
+ // a column may also declare 'subtitleField'/'subtitleTemplate' to render a muted
1003
+ // second line under the primary text (the identity key beneath a title, GitHub/
1004
+ // Linear style) and 'truncate: true' to clamp each line to one row with an
1005
+ // ellipsis and a full-text title/aria-label tooltip (pairs with a per-column
1006
+ // width/weight — see buildColgroup — to give the clip a bounded column). The
1007
+ // subtitle is ALWAYS plain DOM text, never an href source. With none of these
1008
+ // set the cell is a single inline node exactly as before (see cellTd).
990
1009
  function gridCell(col, row) {
991
1010
  // Raw single-field value: drives the badge presence gate + tooltip and any
992
1011
  // field-derived defaults. Kept separate from the (possibly templated) display
@@ -1016,17 +1035,28 @@ function gridCell(col, row) {
1016
1035
  el("span", { class: "pc-badge pc-badge-" + tone, title: rawText, "aria-label": rawText }, label),
1017
1036
  );
1018
1037
  }
1038
+ // Optional second line (col.subtitleField / subtitleTemplate): a muted key
1039
+ // under the primary title. subtitleTemplate wins (interpolated from the row);
1040
+ // else the raw subtitleField value. Absent/null → "" → no extra line. It is
1041
+ // ALWAYS plain DOM text (see cellTd), never a link/href source — a subtitle
1042
+ // cannot smuggle a scheme or path, only linkField/keyField drive hrefs.
1043
+ const subRaw =
1044
+ col.subtitleField != null && row[col.subtitleField] != null ? String(row[col.subtitleField]) : "";
1045
+ const subText = typeof col.subtitleTemplate === "string" ? interpTemplate(col.subtitleTemplate, row) : subRaw;
1046
+ const truncate = col.truncate === true;
1047
+ // Primary inline content: an anchor for a valid link mode (checked in the same
1048
+ // badge < linkField < link(page) < link(processExplorer) precedence as before),
1049
+ // else the plain display text. Each link mode only claims the cell while
1050
+ // 'primary' is still the bare text — an invalid href falls through to the next
1051
+ // mode and ultimately to plain text, exactly as the early-return chain did.
1052
+ let primary = text;
1019
1053
  if (col.linkField) {
1020
1054
  const href = row[col.linkField] == null ? "" : String(row[col.linkField]);
1021
1055
  if (text !== "" && /^https?:\/\//i.test(href)) {
1022
- return el(
1023
- "td",
1024
- {},
1025
- el("a", { class: "pc-link", href, target: "_blank", rel: "noopener noreferrer" }, text),
1026
- );
1056
+ primary = el("a", { class: "pc-link", href, target: "_blank", rel: "noopener noreferrer" }, text);
1027
1057
  }
1028
1058
  }
1029
- if (col.link && col.link.kind === "page" && col.link.page && col.link.keyField) {
1059
+ if (primary === text && col.link && col.link.kind === "page" && col.link.page && col.link.keyField) {
1030
1060
  // An in-app link to another page, scoped to this row: #/<page>/<key>. The
1031
1061
  // key (e.g. a plan_key "owner/repo#123") is URL-encoded into the route's
1032
1062
  // param tail, which parseRoute() decodes back verbatim; the page id is
@@ -1037,37 +1067,130 @@ function gridCell(col, row) {
1037
1067
  const keyStr = key == null ? "" : String(key).trim();
1038
1068
  if (text !== "" && safePageId(col.link.page) && keyStr !== "") {
1039
1069
  const href = "#/" + encodeURIComponent(col.link.page) + "/" + encodeURIComponent(keyStr);
1040
- return el("td", {}, el("a", { class: "pc-link", href }, text));
1070
+ primary = el("a", { class: "pc-link", href }, text);
1041
1071
  }
1042
1072
  }
1043
- if (col.link && col.link.kind === "processExplorer" && col.link.keyField) {
1073
+ if (primary === text && col.link && col.link.kind === "processExplorer" && col.link.keyField) {
1044
1074
  const key = row[col.link.keyField];
1045
1075
  const keyStr = key == null ? "" : String(key).trim();
1046
1076
  if (text !== "" && keyStr !== "") {
1047
1077
  const href = "/console/explorer?instance=" + encodeURIComponent(keyStr);
1048
- return el(
1049
- "td",
1050
- {},
1051
- el("a", {
1052
- class: "pc-link",
1053
- href,
1054
- target: "_blank",
1055
- rel: "noopener noreferrer",
1056
- // Embedded in the console a plain primary click routes the host's
1057
- // explorer in place (via the nano-navigate bridge) instead of opening
1058
- // a new window. A non-primary button (middle/right = new-tab/context
1059
- // intent), a modified click (⌘/ctrl/shift/alt = new-tab intent) and
1060
- // any standalone run fall through to the native anchor above.
1061
- onclick: (ev) => {
1062
- if (ev.button !== 0) return;
1063
- if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
1064
- if (hostNavigate("processExplorer", { instance: keyStr })) ev.preventDefault();
1065
- },
1066
- }, text),
1067
- );
1078
+ primary = el("a", {
1079
+ class: "pc-link",
1080
+ href,
1081
+ target: "_blank",
1082
+ rel: "noopener noreferrer",
1083
+ // Embedded in the console a plain primary click routes the host's
1084
+ // explorer in place (via the nano-navigate bridge) instead of opening
1085
+ // a new window. A non-primary button (middle/right = new-tab/context
1086
+ // intent), a modified click (⌘/ctrl/shift/alt = new-tab intent) and
1087
+ // any standalone run fall through to the native anchor above.
1088
+ onclick: (ev) => {
1089
+ if (ev.button !== 0) return;
1090
+ if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
1091
+ if (hostNavigate("processExplorer", { instance: keyStr })) ev.preventDefault();
1092
+ },
1093
+ }, text);
1068
1094
  }
1069
1095
  }
1070
- return el("td", {}, text);
1096
+ return cellTd(primary, text, subText, truncate);
1097
+ }
1098
+
1099
+ // Compose a grid <td> from its primary inline content (a string or an anchor),
1100
+ // an optional muted subtitle line, and optional one-line truncation. With
1101
+ // NEITHER a subtitle nor truncation the cell is exactly the single inline node
1102
+ // as before, so existing grids render byte-for-byte unchanged (backward
1103
+ // compatible). When either is set the content is wrapped in a .pc-cell-main
1104
+ // line and, if a subtitle is present, a .pc-cell-sub line beneath it. truncate
1105
+ // adds .pc-truncate (nowrap + ellipsis) to each line and mirrors the full text
1106
+ // into title=/aria-label so the clipped value is still recoverable on hover and
1107
+ // to assistive tech. When the primary is an element (a link cell's <a>), title=/
1108
+ // aria-label= are ALSO mirrored onto it: HTML does not inherit those to
1109
+ // descendants, so hovering the link text or naming the anchor for assistive tech
1110
+ // would otherwise miss the full value the wrapper carries. The subtitle is set as
1111
+ // DOM text only — never an href.
1112
+ function cellTd(primary, primaryText, subText, truncate) {
1113
+ if (subText === "" && !truncate) return el("td", {}, primary);
1114
+ const mainAttrs = { class: "pc-cell-main" + (truncate ? " pc-truncate" : "") };
1115
+ if (truncate && primaryText !== "") {
1116
+ mainAttrs.title = primaryText; mainAttrs["aria-label"] = primaryText;
1117
+ if (primary && typeof primary.setAttribute === "function") {
1118
+ primary.setAttribute("title", primaryText); primary.setAttribute("aria-label", primaryText);
1119
+ }
1120
+ }
1121
+ const main = el("div", mainAttrs, primary);
1122
+ if (subText === "") return el("td", {}, main);
1123
+ const subAttrs = { class: "pc-cell-sub" + (truncate ? " pc-truncate" : "") };
1124
+ if (truncate) { subAttrs.title = subText; subAttrs["aria-label"] = subText; }
1125
+ return el("td", {}, main, el("div", subAttrs, subText));
1126
+ }
1127
+
1128
+ // Parse a "<number>%" width into its numeric percentage, or null if the string
1129
+ // is not a bare percentage (e.g. "22rem", "120px") — used to decide whether the
1130
+ // remainder left for weighted columns can be computed.
1131
+ function colWidthPct(width) {
1132
+ const m = /^\s*([0-9]*\.?[0-9]+)%\s*$/.exec(width);
1133
+ return m ? parseFloat(m[1]) : null;
1134
+ }
1135
+
1136
+ // A column carries an explicit width only when it is a non-empty string. Single
1137
+ // source of truth so colWidthStyle/buildColgroup can't drift on what "explicit"
1138
+ // means (which columns win verbatim, consume the remainder, or use their weight).
1139
+ function hasExplicitWidth(col) {
1140
+ return typeof col.width === "string" && col.width !== "";
1141
+ }
1142
+
1143
+ // A column's <col> width for the grid's <colgroup>: an explicit col.width string
1144
+ // ("40%", "22rem") wins verbatim; else a positive numeric col.weight is
1145
+ // normalised across the weighted columns into a percentage share of the width
1146
+ // left over by explicit columns (remainderPct); else null (no width — the column
1147
+ // shares the remainder equally under table-layout:fixed).
1148
+ function colWidthStyle(col, weightTotal, remainderPct) {
1149
+ if (hasExplicitWidth(col)) return col.width;
1150
+ if (typeof col.weight === "number" && col.weight > 0 && weightTotal > 0) {
1151
+ return Math.round((col.weight / weightTotal) * remainderPct * 100) / 100 + "%";
1152
+ }
1153
+ return null;
1154
+ }
1155
+
1156
+ // Build a <colgroup> that sizes the grid's columns so table-layout:fixed stops
1157
+ // splitting width equally. Returns null when no column declares width/weight, so
1158
+ // an unsized grid emits no colgroup and lays out exactly as today. 'extraCount'
1159
+ // adds trailing unsized <col>s for the row-actions/detail column so the sized
1160
+ // data columns stay aligned with their headers.
1161
+ function buildColgroup(cols, extraCount) {
1162
+ const hasSizing = cols.some(
1163
+ (c) => hasExplicitWidth(c) || (typeof c.weight === "number" && c.weight > 0),
1164
+ );
1165
+ if (!hasSizing) return null;
1166
+ // Only columns that will actually use their weight (no explicit width — an
1167
+ // explicit width wins verbatim in colWidthStyle and never consumes weight)
1168
+ // contribute to weightTotal; counting a width-bearing column's weight here
1169
+ // would inflate the divisor and shrink the truly-weighted columns' share.
1170
+ let weightTotal = 0;
1171
+ for (const c of cols)
1172
+ if (!hasExplicitWidth(c) && typeof c.weight === "number" && c.weight > 0) weightTotal += c.weight;
1173
+ // Weighted columns share only the width the explicit columns leave behind, so
1174
+ // mixing e.g. width:"40%" with weighted columns can't oversubscribe past 100%.
1175
+ // The remainder is only computable when every explicit width is a percentage;
1176
+ // if any uses an absolute/relative unit (rem/px) we can't subtract it from
1177
+ // 100%, so weighted columns fall back to a share of the full width.
1178
+ let explicitPctTotal = 0;
1179
+ let allExplicitArePct = true;
1180
+ for (const c of cols) {
1181
+ if (hasExplicitWidth(c)) {
1182
+ const pct = colWidthPct(c.width);
1183
+ if (pct === null) allExplicitArePct = false;
1184
+ else explicitPctTotal += pct;
1185
+ }
1186
+ }
1187
+ const remainderPct = allExplicitArePct ? Math.max(0, 100 - explicitPctTotal) : 100;
1188
+ const colEls = cols.map((c) => {
1189
+ const w = colWidthStyle(c, weightTotal, remainderPct);
1190
+ return el("col", w ? { style: "width:" + w } : {});
1191
+ });
1192
+ for (let i = 0; i < extraCount; i++) colEls.push(el("col", {}));
1193
+ return el("colgroup", {}, ...colEls);
1071
1194
  }
1072
1195
 
1073
1196
  function renderActionForm(node) {
@@ -1263,7 +1386,13 @@ function renderDataGrid(node) {
1263
1386
  if (hasExtra) headCells.push(el("th", {}, ""));
1264
1387
  const thead = el("thead", {}, el("tr", {}, ...headCells));
1265
1388
  const tbody = el("tbody", {});
1266
- const table = el("table", { class: "pc-grid" }, thead, tbody);
1389
+ // A <colgroup> (present only when a column declares width/weight) sizes the
1390
+ // columns so table-layout:fixed stops splitting width equally; it must precede
1391
+ // <thead> in the table.
1392
+ const colgroup = buildColgroup(cols, hasExtra ? 1 : 0);
1393
+ const table = colgroup
1394
+ ? el("table", { class: "pc-grid" }, colgroup, thead, tbody)
1395
+ : el("table", { class: "pc-grid" }, thead, tbody);
1267
1396
  card.append(table);
1268
1397
  const span = String((cols.length || 1) + (hasExtra ? 1 : 0));
1269
1398
 
@@ -1355,9 +1484,12 @@ function renderDataGrid(node) {
1355
1484
  if (cg.title) wrap.append(el("div", { class: "pc-child-title" }, cg.title));
1356
1485
  const ccols = cg.columns || [];
1357
1486
  const cbody = el("tbody", {});
1358
- const ctable = el("table", { class: "pc-grid" },
1359
- el("thead", {}, el("tr", {}, ...ccols.map((c) => el("th", {}, c.header || c.field)),
1360
- ...(cg.lazyField ? [el("th", {}, "")] : []))), cbody);
1487
+ const ccolgroup = buildColgroup(ccols, cg.lazyField ? 1 : 0);
1488
+ const cthead = el("thead", {}, el("tr", {}, ...ccols.map((c) => el("th", {}, c.header || c.field)),
1489
+ ...(cg.lazyField ? [el("th", {}, "")] : [])));
1490
+ const ctable = ccolgroup
1491
+ ? el("table", { class: "pc-grid" }, ccolgroup, cthead, cbody)
1492
+ : el("table", { class: "pc-grid" }, cthead, cbody);
1361
1493
  wrap.append(ctable);
1362
1494
  try {
1363
1495
  const { rows } = await getJSON(dataUrl(cg.source || "app", cg.table,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.50.0",
3
+ "version": "0.51.0",
4
4
  "description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -371,6 +371,91 @@ test("renderer interpolates a column's per-cell template into DOM text (#214)",
371
371
  assert.match(js, /const text = typeof col\.template === "string" \? interpTemplate\(col\.template, row\) : rawText;/);
372
372
  });
373
373
 
374
+ test("renderer renders a column's subtitle as a muted second line, never an href (#257)", async () => {
375
+ const res = await dispatch("GET", "/app/runtime.js");
376
+ const js = res.body ?? "";
377
+ // A column may render an optional muted second line beneath the primary text
378
+ // (the identity key under a title, GitHub/Linear style). The value comes from
379
+ // col.subtitleField (raw) or col.subtitleTemplate (interpolated), null/absent →
380
+ // "" → no extra line. Guard the shape: subtitleTemplate wins via interpTemplate,
381
+ // the subtitle is emitted as a .pc-cell-sub DOM-text line, and — crucially for
382
+ // the security posture — it is NEVER an href source (only linkField/keyField
383
+ // drive anchors; the subtitle stays plain text).
384
+ assert.match(js, /col\.subtitleField != null && row\[col\.subtitleField\] != null \? String\(row\[col\.subtitleField\]\) : ""/);
385
+ assert.match(js, /const subText = typeof col\.subtitleTemplate === "string" \? interpTemplate\(col\.subtitleTemplate, row\) : subRaw;/);
386
+ assert.match(js, /class: "pc-cell-sub"/);
387
+ // Absent subtitle (empty) and no truncation collapses back to the single inline
388
+ // node, so existing grids render exactly as before (backward compatible).
389
+ assert.match(js, /if \(subText === "" && !truncate\) return el\("td", \{\}, primary\);/);
390
+ });
391
+
392
+ test("renderer truncates a column to one line with an ellipsis + full-text tooltip (#257)", async () => {
393
+ const res = await dispatch("GET", "/app/runtime.js");
394
+ const js = res.body ?? "";
395
+ // col.truncate:true clamps the primary (and any subtitle) line to a single row
396
+ // with an ellipsis, carrying the full value in a native title= tooltip and
397
+ // aria-label so the clipped text stays recoverable on hover and to assistive
398
+ // tech. Guard the shape: opt-in on `=== true`, a .pc-truncate class is added to
399
+ // the line, and the full text is mirrored into title/aria-label.
400
+ assert.match(js, /const truncate = col\.truncate === true;/);
401
+ assert.match(js, /"pc-cell-main"\s*\+\s*\(truncate \? " pc-truncate" : ""\)/);
402
+ assert.match(js, /mainAttrs\.title = primaryText; mainAttrs\["aria-label"\] = primaryText;/);
403
+ // title/aria-label are NOT inherited by descendants in HTML, so a link cell's
404
+ // <a> primary would miss the full-text tooltip/accessible name the wrapper div
405
+ // carries. Guard that they're also mirrored onto the primary element itself when
406
+ // it's a node (an anchor), so link cells get the same full-text recovery as text.
407
+ assert.match(js, /if \(primary && typeof primary\.setAttribute === "function"\) \{/);
408
+ assert.match(js, /primary\.setAttribute\("title", primaryText\); primary\.setAttribute\("aria-label", primaryText\);/);
409
+ assert.match(js, /"pc-cell-sub"\s*\+\s*\(truncate \? " pc-truncate" : ""\)/);
410
+ });
411
+
412
+ test("renderer emits a <colgroup> sizing columns by width/weight (#257)", async () => {
413
+ const res = await dispatch("GET", "/app/runtime.js");
414
+ const js = res.body ?? "";
415
+ // A column may declare an explicit `width` ("40%", "22rem") or a relative
416
+ // integer `weight`, so table-layout:fixed stops splitting width equally. This
417
+ // emits a <colgroup> of <col style="width:…"> (fixed layout honours <col>
418
+ // widths). Guard the shape: an explicit width string wins verbatim, a positive
419
+ // weight is normalised across weighted columns into a percentage of the width
420
+ // the explicit columns leave (the remainder), unsized columns get no width
421
+ // (share the remainder), and no colgroup is emitted at all when no column
422
+ // declares sizing (so unsized grids are unchanged).
423
+ assert.match(js, /function buildColgroup\(cols, extraCount\)/);
424
+ assert.match(js, /if \(!hasSizing\) return null;/);
425
+ assert.match(js, /function hasExplicitWidth\(col\)/);
426
+ assert.match(js, /if \(hasExplicitWidth\(col\)\) return col\.width;/);
427
+ assert.match(js, /Math\.round\(\(col\.weight \/ weightTotal\) \* remainderPct \* 100\) \/ 100 \+ "%"/);
428
+ assert.match(js, /el\("col", w \? \{ style: "width:" \+ w \} : \{\}\)/);
429
+ // Regression (#258 review): mixing explicit % widths with weighted columns
430
+ // must not oversubscribe past 100% — weighted columns share only the remainder
431
+ // (100 - sum of explicit %), and only when every explicit width is a
432
+ // percentage (rem/px explicit widths fall back to a share of the full width).
433
+ assert.match(js, /const remainderPct = allExplicitArePct \? Math\.max\(0, 100 - explicitPctTotal\) : 100;/);
434
+ // Regression (#258 review): a column carrying both an explicit width and a
435
+ // weight uses its width verbatim and never consumes its weight, so it must be
436
+ // excluded from weightTotal or the truly-weighted columns' share is shrunk.
437
+ assert.match(js, /if \(!hasExplicitWidth\(c\) && typeof c\.weight === "number" && c\.weight > 0\) weightTotal \+= c\.weight;/);
438
+ assert.match(js, /function colWidthPct\(width\)/);
439
+ // The colgroup precedes <thead> in the table, and only when sizing is present.
440
+ assert.match(js, /\? el\("table", \{ class: "pc-grid" \}, colgroup, thead, tbody\)/);
441
+ });
442
+
443
+ test("the shell styles the subtitle line muted and the truncate clamp to one line (#257)", async () => {
444
+ const res = await dispatch("GET", "/");
445
+ const html = res.body ?? "";
446
+ // The muted subtitle line and the one-line clamp are styled in the shell CSS
447
+ // (served with the shell HTML, like the other pc-grid rules). Guard both rules.
448
+ const subRule = (html.match(/\.pc-cell-sub\s*\{[^}]*\}/) ?? [""])[0];
449
+ assert.ok(subRule, ".pc-cell-sub rule must be present");
450
+ assert.match(subRule, /color:var\(--nano-text-muted\)/);
451
+ assert.match(subRule, /font-size:\.8rem/);
452
+ const truncRule = (html.match(/\.pc-truncate\s*\{[^}]*\}/) ?? [""])[0];
453
+ assert.ok(truncRule, ".pc-truncate rule must be present");
454
+ assert.match(truncRule, /white-space:nowrap/);
455
+ assert.match(truncRule, /overflow:hidden/);
456
+ assert.match(truncRule, /text-overflow:ellipsis/);
457
+ });
458
+
374
459
  test("renderer wires a column's processExplorer link to the console explorer", async () => {
375
460
  const res = await dispatch("GET", "/app/runtime.js");
376
461
  const js = res.body ?? "";
@@ -549,6 +549,16 @@ table.pc-grid { width:100%; border-collapse:collapse; font-size:.9rem; table-lay
549
549
  neighbours. */
550
550
  table.pc-grid th, table.pc-grid td { text-align:left; padding:.4rem .6rem; border-bottom:1px solid var(--nano-edge); overflow-wrap:anywhere; word-wrap:break-word; vertical-align:top; }
551
551
  table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
552
+ /* A cell's optional muted second line (col.subtitleField / subtitleTemplate):
553
+ the identity key rendered under the title, GitHub/Linear style. Plain DOM
554
+ text (never a link/href source), smaller and muted so the primary line leads. */
555
+ .pc-cell-sub { font-size:.8rem; color:var(--nano-text-muted); margin-top:.1rem; }
556
+ /* One-line clamp (col.truncate): stop a bounded column wrapping and show an
557
+ ellipsis, with the full value carried in a title=/aria-label tooltip. Needs a
558
+ bounded column (per-column width/weight) to clip against; a native <col> width
559
+ is honoured under table-layout:fixed. Applied to the primary and, when present,
560
+ the subtitle line so both stay single-line. */
561
+ .pc-truncate { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
552
562
  .pc-tabs { display:flex; gap:.5rem; margin-bottom:.75rem; }
553
563
  .pc-tab { padding:.35rem .8rem; border:1px solid var(--nano-edge); border-radius:.4rem; background:transparent; color:inherit; font:inherit; cursor:pointer; }
554
564
  .pc-tab.active { background:var(--nano-accent); color:var(--nano-on-accent); border-color:var(--nano-accent); }
@@ -1039,6 +1049,15 @@ function renderButton(node) {
1039
1049
  // tooltip stay keyed to the raw 'field' value (a badge shows a fixed glyph, not
1040
1050
  // the field text), so a template does not change badge behaviour. Sorting,
1041
1051
  // groupBy and orderBy also key off real fields, never the rendered template.
1052
+ //
1053
+ // Two-line identity + one-line clamp (additive, opt-in, backward-compatible):
1054
+ // a column may also declare 'subtitleField'/'subtitleTemplate' to render a muted
1055
+ // second line under the primary text (the identity key beneath a title, GitHub/
1056
+ // Linear style) and 'truncate: true' to clamp each line to one row with an
1057
+ // ellipsis and a full-text title/aria-label tooltip (pairs with a per-column
1058
+ // width/weight — see buildColgroup — to give the clip a bounded column). The
1059
+ // subtitle is ALWAYS plain DOM text, never an href source. With none of these
1060
+ // set the cell is a single inline node exactly as before (see cellTd).
1042
1061
  function gridCell(col, row) {
1043
1062
  // Raw single-field value: drives the badge presence gate + tooltip and any
1044
1063
  // field-derived defaults. Kept separate from the (possibly templated) display
@@ -1068,17 +1087,28 @@ function gridCell(col, row) {
1068
1087
  el("span", { class: "pc-badge pc-badge-" + tone, title: rawText, "aria-label": rawText }, label),
1069
1088
  );
1070
1089
  }
1090
+ // Optional second line (col.subtitleField / subtitleTemplate): a muted key
1091
+ // under the primary title. subtitleTemplate wins (interpolated from the row);
1092
+ // else the raw subtitleField value. Absent/null → "" → no extra line. It is
1093
+ // ALWAYS plain DOM text (see cellTd), never a link/href source — a subtitle
1094
+ // cannot smuggle a scheme or path, only linkField/keyField drive hrefs.
1095
+ const subRaw =
1096
+ col.subtitleField != null && row[col.subtitleField] != null ? String(row[col.subtitleField]) : "";
1097
+ const subText = typeof col.subtitleTemplate === "string" ? interpTemplate(col.subtitleTemplate, row) : subRaw;
1098
+ const truncate = col.truncate === true;
1099
+ // Primary inline content: an anchor for a valid link mode (checked in the same
1100
+ // badge < linkField < link(page) < link(processExplorer) precedence as before),
1101
+ // else the plain display text. Each link mode only claims the cell while
1102
+ // 'primary' is still the bare text — an invalid href falls through to the next
1103
+ // mode and ultimately to plain text, exactly as the early-return chain did.
1104
+ let primary = text;
1071
1105
  if (col.linkField) {
1072
1106
  const href = row[col.linkField] == null ? "" : String(row[col.linkField]);
1073
1107
  if (text !== "" && /^https?:\/\//i.test(href)) {
1074
- return el(
1075
- "td",
1076
- {},
1077
- el("a", { class: "pc-link", href, target: "_blank", rel: "noopener noreferrer" }, text),
1078
- );
1108
+ primary = el("a", { class: "pc-link", href, target: "_blank", rel: "noopener noreferrer" }, text);
1079
1109
  }
1080
1110
  }
1081
- if (col.link && col.link.kind === "page" && col.link.page && col.link.keyField) {
1111
+ if (primary === text && col.link && col.link.kind === "page" && col.link.page && col.link.keyField) {
1082
1112
  // An in-app link to another page, scoped to this row: #/<page>/<key>. The
1083
1113
  // key (e.g. a plan_key "owner/repo#123") is URL-encoded into the route's
1084
1114
  // param tail, which parseRoute() decodes back verbatim; the page id is
@@ -1089,37 +1119,130 @@ function gridCell(col, row) {
1089
1119
  const keyStr = key == null ? "" : String(key).trim();
1090
1120
  if (text !== "" && safePageId(col.link.page) && keyStr !== "") {
1091
1121
  const href = "#/" + encodeURIComponent(col.link.page) + "/" + encodeURIComponent(keyStr);
1092
- return el("td", {}, el("a", { class: "pc-link", href }, text));
1122
+ primary = el("a", { class: "pc-link", href }, text);
1093
1123
  }
1094
1124
  }
1095
- if (col.link && col.link.kind === "processExplorer" && col.link.keyField) {
1125
+ if (primary === text && col.link && col.link.kind === "processExplorer" && col.link.keyField) {
1096
1126
  const key = row[col.link.keyField];
1097
1127
  const keyStr = key == null ? "" : String(key).trim();
1098
1128
  if (text !== "" && keyStr !== "") {
1099
1129
  const href = "/console/explorer?instance=" + encodeURIComponent(keyStr);
1100
- return el(
1101
- "td",
1102
- {},
1103
- el("a", {
1104
- class: "pc-link",
1105
- href,
1106
- target: "_blank",
1107
- rel: "noopener noreferrer",
1108
- // Embedded in the console a plain primary click routes the host's
1109
- // explorer in place (via the nano-navigate bridge) instead of opening
1110
- // a new window. A non-primary button (middle/right = new-tab/context
1111
- // intent), a modified click (⌘/ctrl/shift/alt = new-tab intent) and
1112
- // any standalone run fall through to the native anchor above.
1113
- onclick: (ev) => {
1114
- if (ev.button !== 0) return;
1115
- if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
1116
- if (hostNavigate("processExplorer", { instance: keyStr })) ev.preventDefault();
1117
- },
1118
- }, text),
1119
- );
1130
+ primary = el("a", {
1131
+ class: "pc-link",
1132
+ href,
1133
+ target: "_blank",
1134
+ rel: "noopener noreferrer",
1135
+ // Embedded in the console a plain primary click routes the host's
1136
+ // explorer in place (via the nano-navigate bridge) instead of opening
1137
+ // a new window. A non-primary button (middle/right = new-tab/context
1138
+ // intent), a modified click (⌘/ctrl/shift/alt = new-tab intent) and
1139
+ // any standalone run fall through to the native anchor above.
1140
+ onclick: (ev) => {
1141
+ if (ev.button !== 0) return;
1142
+ if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
1143
+ if (hostNavigate("processExplorer", { instance: keyStr })) ev.preventDefault();
1144
+ },
1145
+ }, text);
1146
+ }
1147
+ }
1148
+ return cellTd(primary, text, subText, truncate);
1149
+ }
1150
+
1151
+ // Compose a grid <td> from its primary inline content (a string or an anchor),
1152
+ // an optional muted subtitle line, and optional one-line truncation. With
1153
+ // NEITHER a subtitle nor truncation the cell is exactly the single inline node
1154
+ // as before, so existing grids render byte-for-byte unchanged (backward
1155
+ // compatible). When either is set the content is wrapped in a .pc-cell-main
1156
+ // line and, if a subtitle is present, a .pc-cell-sub line beneath it. truncate
1157
+ // adds .pc-truncate (nowrap + ellipsis) to each line and mirrors the full text
1158
+ // into title=/aria-label so the clipped value is still recoverable on hover and
1159
+ // to assistive tech. When the primary is an element (a link cell's <a>), title=/
1160
+ // aria-label= are ALSO mirrored onto it: HTML does not inherit those to
1161
+ // descendants, so hovering the link text or naming the anchor for assistive tech
1162
+ // would otherwise miss the full value the wrapper carries. The subtitle is set as
1163
+ // DOM text only — never an href.
1164
+ function cellTd(primary, primaryText, subText, truncate) {
1165
+ if (subText === "" && !truncate) return el("td", {}, primary);
1166
+ const mainAttrs = { class: "pc-cell-main" + (truncate ? " pc-truncate" : "") };
1167
+ if (truncate && primaryText !== "") {
1168
+ mainAttrs.title = primaryText; mainAttrs["aria-label"] = primaryText;
1169
+ if (primary && typeof primary.setAttribute === "function") {
1170
+ primary.setAttribute("title", primaryText); primary.setAttribute("aria-label", primaryText);
1171
+ }
1172
+ }
1173
+ const main = el("div", mainAttrs, primary);
1174
+ if (subText === "") return el("td", {}, main);
1175
+ const subAttrs = { class: "pc-cell-sub" + (truncate ? " pc-truncate" : "") };
1176
+ if (truncate) { subAttrs.title = subText; subAttrs["aria-label"] = subText; }
1177
+ return el("td", {}, main, el("div", subAttrs, subText));
1178
+ }
1179
+
1180
+ // Parse a "<number>%" width into its numeric percentage, or null if the string
1181
+ // is not a bare percentage (e.g. "22rem", "120px") — used to decide whether the
1182
+ // remainder left for weighted columns can be computed.
1183
+ function colWidthPct(width) {
1184
+ const m = /^\s*([0-9]*\.?[0-9]+)%\s*$/.exec(width);
1185
+ return m ? parseFloat(m[1]) : null;
1186
+ }
1187
+
1188
+ // A column carries an explicit width only when it is a non-empty string. Single
1189
+ // source of truth so colWidthStyle/buildColgroup can't drift on what "explicit"
1190
+ // means (which columns win verbatim, consume the remainder, or use their weight).
1191
+ function hasExplicitWidth(col) {
1192
+ return typeof col.width === "string" && col.width !== "";
1193
+ }
1194
+
1195
+ // A column's <col> width for the grid's <colgroup>: an explicit col.width string
1196
+ // ("40%", "22rem") wins verbatim; else a positive numeric col.weight is
1197
+ // normalised across the weighted columns into a percentage share of the width
1198
+ // left over by explicit columns (remainderPct); else null (no width — the column
1199
+ // shares the remainder equally under table-layout:fixed).
1200
+ function colWidthStyle(col, weightTotal, remainderPct) {
1201
+ if (hasExplicitWidth(col)) return col.width;
1202
+ if (typeof col.weight === "number" && col.weight > 0 && weightTotal > 0) {
1203
+ return Math.round((col.weight / weightTotal) * remainderPct * 100) / 100 + "%";
1204
+ }
1205
+ return null;
1206
+ }
1207
+
1208
+ // Build a <colgroup> that sizes the grid's columns so table-layout:fixed stops
1209
+ // splitting width equally. Returns null when no column declares width/weight, so
1210
+ // an unsized grid emits no colgroup and lays out exactly as today. 'extraCount'
1211
+ // adds trailing unsized <col>s for the row-actions/detail column so the sized
1212
+ // data columns stay aligned with their headers.
1213
+ function buildColgroup(cols, extraCount) {
1214
+ const hasSizing = cols.some(
1215
+ (c) => hasExplicitWidth(c) || (typeof c.weight === "number" && c.weight > 0),
1216
+ );
1217
+ if (!hasSizing) return null;
1218
+ // Only columns that will actually use their weight (no explicit width — an
1219
+ // explicit width wins verbatim in colWidthStyle and never consumes weight)
1220
+ // contribute to weightTotal; counting a width-bearing column's weight here
1221
+ // would inflate the divisor and shrink the truly-weighted columns' share.
1222
+ let weightTotal = 0;
1223
+ for (const c of cols)
1224
+ if (!hasExplicitWidth(c) && typeof c.weight === "number" && c.weight > 0) weightTotal += c.weight;
1225
+ // Weighted columns share only the width the explicit columns leave behind, so
1226
+ // mixing e.g. width:"40%" with weighted columns can't oversubscribe past 100%.
1227
+ // The remainder is only computable when every explicit width is a percentage;
1228
+ // if any uses an absolute/relative unit (rem/px) we can't subtract it from
1229
+ // 100%, so weighted columns fall back to a share of the full width.
1230
+ let explicitPctTotal = 0;
1231
+ let allExplicitArePct = true;
1232
+ for (const c of cols) {
1233
+ if (hasExplicitWidth(c)) {
1234
+ const pct = colWidthPct(c.width);
1235
+ if (pct === null) allExplicitArePct = false;
1236
+ else explicitPctTotal += pct;
1120
1237
  }
1121
1238
  }
1122
- return el("td", {}, text);
1239
+ const remainderPct = allExplicitArePct ? Math.max(0, 100 - explicitPctTotal) : 100;
1240
+ const colEls = cols.map((c) => {
1241
+ const w = colWidthStyle(c, weightTotal, remainderPct);
1242
+ return el("col", w ? { style: "width:" + w } : {});
1243
+ });
1244
+ for (let i = 0; i < extraCount; i++) colEls.push(el("col", {}));
1245
+ return el("colgroup", {}, ...colEls);
1123
1246
  }
1124
1247
 
1125
1248
  function renderActionForm(node) {
@@ -1315,7 +1438,13 @@ function renderDataGrid(node) {
1315
1438
  if (hasExtra) headCells.push(el("th", {}, ""));
1316
1439
  const thead = el("thead", {}, el("tr", {}, ...headCells));
1317
1440
  const tbody = el("tbody", {});
1318
- const table = el("table", { class: "pc-grid" }, thead, tbody);
1441
+ // A <colgroup> (present only when a column declares width/weight) sizes the
1442
+ // columns so table-layout:fixed stops splitting width equally; it must precede
1443
+ // <thead> in the table.
1444
+ const colgroup = buildColgroup(cols, hasExtra ? 1 : 0);
1445
+ const table = colgroup
1446
+ ? el("table", { class: "pc-grid" }, colgroup, thead, tbody)
1447
+ : el("table", { class: "pc-grid" }, thead, tbody);
1319
1448
  card.append(table);
1320
1449
  const span = String((cols.length || 1) + (hasExtra ? 1 : 0));
1321
1450
 
@@ -1407,9 +1536,12 @@ function renderDataGrid(node) {
1407
1536
  if (cg.title) wrap.append(el("div", { class: "pc-child-title" }, cg.title));
1408
1537
  const ccols = cg.columns || [];
1409
1538
  const cbody = el("tbody", {});
1410
- const ctable = el("table", { class: "pc-grid" },
1411
- el("thead", {}, el("tr", {}, ...ccols.map((c) => el("th", {}, c.header || c.field)),
1412
- ...(cg.lazyField ? [el("th", {}, "")] : []))), cbody);
1539
+ const ccolgroup = buildColgroup(ccols, cg.lazyField ? 1 : 0);
1540
+ const cthead = el("thead", {}, el("tr", {}, ...ccols.map((c) => el("th", {}, c.header || c.field)),
1541
+ ...(cg.lazyField ? [el("th", {}, "")] : [])));
1542
+ const ctable = ccolgroup
1543
+ ? el("table", { class: "pc-grid" }, ccolgroup, cthead, cbody)
1544
+ : el("table", { class: "pc-grid" }, cthead, cbody);
1413
1545
  wrap.append(ctable);
1414
1546
  try {
1415
1547
  const { rows } = await getJSON(dataUrl(cg.source || "app", cg.table,