You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@juneau.apache.org by ja...@apache.org on 2026/08/26 21:18:35 UTC
(juneau) branch master updated: Give the row-detail panel a real field grid, and delete the detail-subtab theme override instead of re-theming it.
This is an automated email from the ASF dual-hosted git repository.
jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git
The following commit(s) were added to refs/heads/master by this push:
new 51d3535464 Give the row-detail panel a real field grid, and delete the detail-subtab theme override instead of re-theming it.
51d3535464 is described below
commit 51d353546434296ead2b206b1f7fdd9f1961cd2b
Author: James Bognar <ja...@apache.org>
AuthorDate: Wed Aug 26 17:18:28 2026 -0400
Give the row-detail panel a real field grid, and delete the detail-subtab theme override instead of re-theming it.
The fields grid carried its column count as an inline grid-template-columns, which
out-ranks every rule in the stylesheet -- so a section declaring three or four columns
stayed that wide inside a 320px panel and overflowed it. The cap is now a class and
juneau-views.css owns a container-query ladder that steps it down, which is the file's
first at-rule of any kind. container-type: inline-size is declared on a new shared
selector whose only member today is .juneau-view-detail-panel, deliberately not on :root:
a :root container makes an unnamed @container query resolve against root width, i.e.
effectively the viewport, and the panel is nested inside a DataTables cell where viewport
width is the wrong signal. It renders correctly at desktop widths either way, which is
what makes that the dangerous placement rather than merely the wrong one.
Two closed enums join the definition beans -- FieldLayout (INLINE default, STACKED) on
DetailSection, and FieldSpan (ONE default, FULL) on DetailField -- both translated to a
class by the framework rather than passed through as CSS values. A span is a maximum in
the same sense columns now is: it clamps as the grid steps down and never creates a
column beyond the rendered count, so FULL and ONE are identical at one column.
DetailSection.columns keeps its default of 2 and every existing consumer is unchanged;
its javadoc now says cap rather than count, since the ladder tops out at four. Markdown
fields get their full-width span through that same span mechanism instead of a parallel
hardcoded rule doing the same job by a different route.
The subtab half is pure subtraction. chrome.css's .juneau-view-detail-tabs block
re-themed what the generic tab-mode rules directly above it already ship -- accent track
wash plus a solid accent fill on the selected tab -- which is what made a tab strip read
as a row of pressed buttons, and it doubled the accent band under the header's own wash.
Its hover rule was also unguarded and out-specified the disabled-guarded generic rule, so
disabled subtabs re-lit on hover; deleting the block fixes that for free rather than by
adding a second guarded copy. The correct diff adds zero declarations to that file, and
the deleted block is replaced by a comment saying so, because the natural instinct on
reading the result is to re-theme it. juneau-views.css's matching pill override goes too,
while J0445u's strip-trailed bar-slot host rule 389 lines further down survives.
DetailSection.count rides on the detail section element as data-juneau-detail-count and
is painted into its own span by a detail-only post-pass in the strip builder. It is
deliberately not folded into the section heading -- a single-section detail renders no
strip at all, so the heading itself would read "Suspensions (0)" -- and not appended to
the tab label either, since the builder paints that with textContent into one text node
where CSS cannot reach it, and the button being inline-flex collapses a leading space into
"Suspensions(0)". Its gap is a margin on a real child element. null renders no suffix; 0
renders, because "checked, none" is information.
The remaining sections are shape in -views and paint in -console: row separators as a
--jc-border hairline rather than an inherited near-black currentColor, an inverted
label/value emphasis that makes the detail field value --jc-chrome-font-size-3's first
consumer and so fixes that PROVISIONAL token, an em-dash on an unset value as :empty
generated content (the value div is a textContent-filled slot, so a real element inside it
would be overwritten on first paint), and a filled header icon tile that becomes the
panel's only solid accent now that the tab track wash is gone.
Board item TODO-J0454, wave WAVE-0006.
---
.../resources/org/apache/juneau/console/chrome.css | 48 ++--
.../juneau/rest/server/views/DetailField.java | 21 ++
.../juneau/rest/server/views/DetailSection.java | 57 ++++-
.../juneau/rest/server/views/FieldLayout.java | 40 ++++
.../apache/juneau/rest/server/views/FieldSpan.java | 39 ++++
.../apache/juneau/rest/server/views/ViewTable.java | 43 +++-
.../org/apache/juneau/views/juneau-views.css | 187 ++++++++++++---
.../org/apache/juneau/views/juneau-views.js | 23 +-
.../server/views/DetailFieldGrid_Emit_Test.java | 228 +++++++++++++++++++
.../views/RowDetail_FieldGrid_BrowserTest.java | 251 +++++++++++++++++++++
.../views/ViewTable_RowDetail_Emit_Test.java | 30 ++-
.../rest/server/views/ViewsMixin_Serving_Test.java | 19 +-
.../src/test/js/detail-field-grid-browser.cjs | 172 ++++++++++++++
13 files changed, 1095 insertions(+), 63 deletions(-)
diff --git a/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/chrome.css b/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/chrome.css
index 85703df65b..a5bc296d7a 100644
--- a/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/chrome.css
+++ b/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/chrome.css
@@ -703,26 +703,15 @@ table.dataTable.display > tbody > tr:hover {
border-bottom-color: var(--jc-accent);
}
-/* Detail-panel tabs: instance-detail look (light track, selected pill darker blue / white text).
- Each var(--jc-*) is the entire value of a color/border-color/background-color property (sink-property discipline). */
-.juneau-view-ribbon-group.juneau-view-detail-tabs[data-juneau-strip-mode="tab"] {
- background-color: var(--jc-accent-wash);
- border-bottom-color: var(--jc-accent-wash);
-}
-.juneau-view-ribbon-group.juneau-view-detail-tabs[data-juneau-strip-mode="tab"] .juneau-view-ribbon-btn {
- background-color: var(--jc-control-bg);
- color: var(--jc-text-soft);
- border-color: var(--jc-border);
-}
-.juneau-view-ribbon-group.juneau-view-detail-tabs[data-juneau-strip-mode="tab"] .juneau-view-ribbon-btn:hover {
- background-color: var(--jc-accent-wash);
- color: var(--jc-accent);
-}
-.juneau-view-ribbon-group.juneau-view-detail-tabs[data-juneau-strip-mode="tab"] .juneau-view-ribbon-btn[aria-selected="true"] {
- background-color: var(--jc-accent);
- color: var(--jc-on-accent);
- border-color: var(--jc-accent);
-}
+/* Detail-panel tabs are deliberately absent from this file. They used to carry a second theme here - accent
+ track wash, solid accent fill on the selected tab - which is what made them read as a row of pressed buttons
+ instead of a tab strip, and it also doubled the accent band under the header's own wash. The generic
+ tab-mode rules directly above already ship exactly the intended treatment, so the fix was to remove the
+ override, not to re-theme it: the correct diff for the detail strip adds ZERO declarations to this file.
+ Note in particular that the deleted hover rule was UNGUARDED - it out-specified the disabled-guarded generic
+ hover rule above and re-lit disabled tabs. Deleting it is what fixes that; do not re-add a guarded copy
+ here, because the guard already exists once and a second copy is the duplication that produced this block in
+ the first place. */
/* Detail-panel header bar (instance-detail header wash). */
.juneau-view-detail-header {
@@ -732,6 +721,25 @@ table.dataTable.display > tbody > tr:hover {
color: var(--jc-text);
}
+/* Header icon tile. -views ships the square + radius; this is the fill and the contrasting glyph. With the
+ tab track wash gone this is the panel's only solid accent, so it anchors the title rather than competing
+ with a second band. There is no per-record-type variant: a colour hook on RowDetailDef would be
+ author-facing colour API, which the -views/-console split keeps out of the definition beans. */
+.juneau-view-detail-icon {
+ background-color: var(--jc-accent);
+ color: var(--jc-on-accent);
+}
+
+/* Field grid. -views ships the separator as a currentColor border, which inherits near-black body text and
+ reads as a rule rather than a hairline; --jc-border is what makes it light. The label/value pair carries
+ the inverted emphasis in colour as -views carries it in size and weight: muted key, full-strength value.
+ The em-dash on an unset value is generated content in -views and is muted here so it reads as "known,
+ unset" rather than as a value someone typed. */
+.juneau-view-detail-field { border-bottom-color: var(--jc-border); }
+.juneau-view-detail-field-title { color: var(--jc-text-muted); }
+.juneau-view-detail-field-value { color: var(--jc-text); }
+.juneau-view-detail-field-value:empty::after { color: var(--jc-text-muted); }
+
/* Dedicated expander column (.juneau-view-detail-control): muted glyph, accent on hover. */
.juneau-view-detail-th,
.juneau-view-detail-control { color: var(--jc-text-muted); }
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/DetailField.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/DetailField.java
index 21ef28f78c..c734086845 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/DetailField.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/DetailField.java
@@ -92,6 +92,16 @@ public class DetailField {
*/
public String href;
+ /**
+ * How many of the section's grid columns this field occupies. <jk>null</jk> means {@link FieldSpan#ONE}.
+ *
+ * <p>
+ * A maximum, not a fixed width: it clamps as the grid steps down, and {@link FieldSpan#FULL} is identical to
+ * {@link FieldSpan#ONE} at one column. A {@link Format#MARKDOWN} field spans full width whether or not this
+ * is set.
+ */
+ public FieldSpan span;
+
/**
* Creates a field bound to the specified expand-JSON key.
*
@@ -161,4 +171,15 @@ public class DetailField {
href = value;
return this;
}
+
+ /**
+ * Sets how many of the section's grid columns this field occupies.
+ *
+ * @param value The span. <jk>null</jk> means {@link FieldSpan#ONE}.
+ * @return This object.
+ */
+ public DetailField span(FieldSpan value) {
+ span = value;
+ return this;
+ }
}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/DetailSection.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/DetailSection.java
index 3dcd7e414c..ac402c40ab 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/DetailSection.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/DetailSection.java
@@ -38,9 +38,36 @@ public class DetailSection {
/** Heading painted with {@code textContent}. */
public String title;
- /** CSS-grid column hint; default {@code 2}. Must be {@code >= 1}. */
+ /**
+ * The <b>maximum</b> number of grid columns; default {@code 2}. Must be {@code >= 1}.
+ *
+ * <p>
+ * The rendered count steps down with the width of the detail panel, so this is a cap rather than a fixed
+ * count: a section declaring {@code 4} renders four columns only where the panel is wide enough for them,
+ * and one column at the narrowest step. The framework's ladder tops out at four, so a larger value renders
+ * as four.
+ */
public int columns = 2;
+ /**
+ * How this section's fields arrange their label and value. <jk>null</jk> means {@link FieldLayout#INLINE}.
+ */
+ public FieldLayout layout;
+
+ /**
+ * Optional count rendered after this section's tab label, as a distinct element.
+ *
+ * <p>
+ * <jk>null</jk> renders no suffix; <c>0</c> renders, because "checked, none" is information rather than an
+ * empty state. Painted with {@code textContent} into its own element by the client strip builder, so it is
+ * never folded into the section heading — a single-section detail renders no strip at all and would
+ * otherwise read {@code "Suspensions (0)"} as its heading.
+ *
+ * <p>
+ * Definition data, not payload: it never enters the expand-GET envelope.
+ */
+ public Integer count;
+
/** The field slots, in display order. */
public List<DetailField> fields;
@@ -74,9 +101,10 @@ public class DetailSection {
}
/**
- * Sets the CSS-grid column count.
+ * Sets the maximum CSS-grid column count.
*
- * @param value The column count. Must be {@code >= 1} (enforced by {@link RowDetailDef#validate(List)}).
+ * @param value The column cap. Must be {@code >= 1} (enforced by {@link RowDetailDef#validate(List)}).
+ * Values above four render as four.
* @return This object.
*/
public DetailSection columns(int value) {
@@ -84,6 +112,29 @@ public class DetailSection {
return this;
}
+ /**
+ * Sets how this section's fields arrange their label and value.
+ *
+ * @param value The arrangement. May be <jk>null</jk> ({@link FieldLayout#INLINE}).
+ * @return This object.
+ */
+ public DetailSection layout(FieldLayout value) {
+ layout = value;
+ return this;
+ }
+
+ /**
+ * Sets the optional count rendered after this section's tab label.
+ *
+ * @param value The count. May be <jk>null</jk> (no suffix). Numbers only — a general-purpose text
+ * badge is a separate feature.
+ * @return This object.
+ */
+ public DetailSection count(Integer value) {
+ count = value;
+ return this;
+ }
+
/**
* Sets the field slots.
*
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/FieldLayout.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/FieldLayout.java
new file mode 100644
index 0000000000..1254044baf
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/FieldLayout.java
@@ -0,0 +1,40 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+/**
+ * How a {@link DetailSection}'s fields arrange their label and value.
+ *
+ * <p>
+ * A closed vocabulary the framework translates to a class on the fields grid. It is deliberately not a CSS
+ * value passthrough, and it is deliberately per-section rather than per-field: there is one grid per section, so
+ * the arrangement is a property of the grid and {@code emitDetailField} keeps emitting the same title-div plus
+ * value-div for every field whichever value is chosen.
+ *
+ * @since 10.0.0
+ */
+public enum FieldLayout {
+
+ /**
+ * Label and value side by side, the label on a bounded fraction of the field block and top-aligned so a
+ * wrapped two-line label does not drag the value's baseline down. The default.
+ */
+ INLINE,
+
+ /** Label above value. An explicit author opt-in, useful for a one-column section of long values. */
+ STACKED
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/FieldSpan.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/FieldSpan.java
new file mode 100644
index 0000000000..5651988105
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/FieldSpan.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+/**
+ * How many of a {@link DetailSection}'s grid columns one {@link DetailField} occupies.
+ *
+ * <p>
+ * A span is a <b>maximum</b>, exactly as {@link DetailSection#columns} is: it clamps downward as the grid steps
+ * down, and it never creates a column beyond the rendered count. At one column {@link #FULL} and {@link #ONE}
+ * render identically.
+ *
+ * <p>
+ * A closed vocabulary the framework translates to a class, never a CSS value an author supplies.
+ *
+ * @since 10.0.0
+ */
+public enum FieldSpan {
+
+ /** One column. The default. */
+ ONE,
+
+ /** Every column the grid is currently rendering. */
+ FULL
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewTable.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewTable.java
index ef9c6d3bd2..6e6d6768f8 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewTable.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewTable.java
@@ -277,6 +277,15 @@ public class ViewTable {
/** Attribute carrying {@link NestedTableDef#parentScopeParam} on the nested-table wrapper. */
public static final String NESTED_SCOPE_PARAM_ATTR = "data-juneau-nested-scope-param";
+ /**
+ * Attribute carrying a {@link DetailSection#count} on a detail {@code <section>}. Omitted when the count is
+ * <jk>null</jk>, so a section that declares none serializes exactly as it did before.
+ */
+ private static final String DETAIL_COUNT_ATTR = "data-juneau-detail-count";
+
+ /** The widest step of the fields-grid column ladder in {@code juneau-views.css}. */
+ private static final int DETAIL_MAX_COLUMNS = 4;
+
/** MIME type of the VIEW_META/bulk-actions/nested-VIEW_META sidecars. */
private static final String JSON_CONTENT_TYPE = "application/json";
@@ -875,20 +884,34 @@ public class ViewTable {
kids.add(buildFieldsGrid(s));
if (s.table != null)
kids.add(emitNestedTable(s.table, csrfToken));
- return section(kids.toArray())
+ var out = section(kids.toArray())
.attr(DETAIL_SECTION_ATTR, s.id)
.class_("juneau-view-detail-section");
+ if (s.count != null)
+ out.attr(DETAIL_COUNT_ATTR, s.count.toString());
+ return out;
}
- /** Builds a section's fields grid: one empty field slot per {@link DetailSection#fields} entry. */
+ /**
+ * Builds a section's fields grid: one empty field slot per {@link DetailSection#fields} entry.
+ *
+ * <p>
+ * The column count is a class rather than an inline {@code grid-template-columns}, because an inline style
+ * cannot be stepped down by a container query — it out-ranks every rule in the stylesheet, so a
+ * three-column section declared here would stay three columns in a 320px-wide panel. The class names the
+ * author's <b>cap</b>; {@code juneau-views.css} decides how many of those columns a given panel width can
+ * actually afford. The ladder tops out at {@value #DETAIL_MAX_COLUMNS}.
+ */
private static Div buildFieldsGrid(DetailSection s) {
var fieldSlots = new ArrayList<>();
if (s.fields != null)
for (var f : s.fields)
fieldSlots.add(emitDetailField(f));
+ var cols = Math.min(Math.max(s.columns, 1), DETAIL_MAX_COLUMNS);
+ var layout = s.layout == FieldLayout.STACKED ? "stacked" : "inline";
return div(fieldSlots.toArray())
- .class_("juneau-view-detail-fields")
- .attr("style", "grid-template-columns:repeat(" + s.columns + ",minmax(0,1fr))");
+ .class_("juneau-view-detail-fields juneau-view-detail-fields-" + layout
+ + " juneau-view-detail-fields-cols-" + cols);
}
/** Whether an {@link ActionBar} has at least one item to render (a <jk>null</jk> bar has none). */
@@ -997,9 +1020,17 @@ public class ViewTable {
}
}
+ /**
+ * Builds one empty field slot: a title div plus a value div, whatever the section's {@link FieldLayout} is
+ * — the arrangement is a property of the grid, so it is styled from the grid's class rather than
+ * changing the shape emitted here.
+ */
private static Div emitDetailField(DetailField f) {
var rendered = f.render != null;
var markdown = !rendered && f.format == DetailField.Format.MARKDOWN;
+ // A markdown field spans by default rather than by a parallel hardcoded CSS rule that happens to do the
+ // same thing by a different route -- one mechanism, one job.
+ var span = markdown || f.span == FieldSpan.FULL ? " juneau-view-detail-field-span-full" : "";
var valueSlot = div().attr(DETAIL_FIELD_ATTR, f.data);
if (rendered) {
valueSlot.attr(DETAIL_FIELD_RENDER_ATTR, f.render.id);
@@ -1016,12 +1047,12 @@ public class ViewTable {
}
var hideTitle = markdown && f.title != null && f.title.isEmpty();
if (hideTitle)
- return div(valueSlot).class_("juneau-view-detail-field juneau-view-detail-field-markdown");
+ return div(valueSlot).class_("juneau-view-detail-field juneau-view-detail-field-markdown" + span);
var label = f.title == null || f.title.isBlank() ? f.data : f.title;
return div(
div(label).class_("juneau-view-detail-field-title"),
valueSlot
- ).class_(markdown ? "juneau-view-detail-field juneau-view-detail-field-markdown" : "juneau-view-detail-field");
+ ).class_((markdown ? "juneau-view-detail-field juneau-view-detail-field-markdown" : "juneau-view-detail-field") + span);
}
private static Div emitActionBar(org.apache.juneau.rest.server.widgets.ActionBar bar, List<RowAction> rowActions) {
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.css b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.css
index ac0f23c06d..41e0ee6418 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.css
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.css
@@ -137,11 +137,12 @@
* Type steps. A label-smaller-than-value pairing is expressed as the STEP PAIR (-font-size-1 for the
* label, -font-size-3 for the value, 0.857x) rather than as a ratio token: no CSS property could consume a
* ratio, and nothing would stop a surface setting two sizes that ignore it, whereas two named steps are the
- * only two values on offer. The ratio is therefore provisional for as long as -font-size-3 is.
+ * only two values on offer. The pairing is now spent by the detail field, which fixes -font-size-3 and the
+ * ratio with it; -font-size-2 is still waiting on its own first consumer.
*/
--jc-chrome-font-size-1: 0.75rem;
--jc-chrome-font-size-2: 0.8125rem; /* PROVISIONAL - fixed when the detail action button first renders. */
- --jc-chrome-font-size-3: 0.875rem; /* PROVISIONAL - fixed when the detail field value first renders. */
+ --jc-chrome-font-size-3: 0.875rem;
--jc-chrome-line-height: 1.2; /* PROVISIONAL - fixed with --jc-chrome-font-size-2. */
/*
* Glyph sizes, one per role, stated once rather than repeated per selector. These are chrome-wide glyph
@@ -152,6 +153,24 @@
--jc-chrome-glyph-size-small: 12px;
}
+/*
+ * QUERY CONTAINERS - shared, and declared here beside the scale for the same reason the scale is: containment
+ * is a property of the shell a widget renders inside, not of the widget, and an @container block is silently
+ * wrong rather than broken when its container is not the element the author had in mind. An unnamed
+ * @container condition resolves against the NEAREST ancestor with a container-type, so every such block below
+ * is asking about one of the elements named here.
+ *
+ * A later surface that needs containment ADDS ITS SELECTOR TO THIS LIST. It does not re-declare
+ * container-type in its own rules (a second host between a query block and this one silently retargets the
+ * query), and it emphatically does not put container-type on :root - that makes the root element the container
+ * and turns every unnamed @container query in this file into a viewport media query wearing a different name,
+ * which is the exact signal the detail panel was chosen to avoid: the panel is nested in a table cell, so its
+ * width and the viewport's are unrelated.
+ */
+.juneau-view-detail-panel {
+ container-type: inline-size;
+}
+
.jc-progress {
position: relative;
display: inline-block;
@@ -519,13 +538,25 @@ table.dataTable thead .sorting_desc_disabled:after {
padding: 0.5em 0.75em;
}
+/*
+ * Header icon TILE, not a bare glyph: a square affordance sized to anchor the title rather than to match it.
+ * Shape only - the fill and the contrasting glyph colour are chrome.css's, and there is deliberately no
+ * per-RowDetailDef colour hook, which would be author-facing colour API this item is not adding.
+ */
.juneau-view-detail-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
- width: 1.25em;
- height: 1.25em;
+ width: 2em;
+ height: 2em;
+ border-radius: 6px;
+}
+
+.juneau-view-detail-icon svg {
+ width: var(--jc-chrome-glyph-size);
+ height: var(--jc-chrome-glyph-size);
+ fill: currentColor;
}
.juneau-view-detail-icon[hidden] {
@@ -547,29 +578,23 @@ table.dataTable thead .sorting_desc_disabled:after {
}
/*
- * Detail section tabs: instance-detail ribbon (light track, selected pill).
- * Overrides generic tab-mode folder tabs for this consumer only; toolbar/paging stay ribbon-mode.
- * NEUTRAL - shape only; chrome.css themes the track wash and selected accent pill.
+ * Detail section tabs deliberately have NO shape override here. They are the generic tab-mode strip above,
+ * unmodified: one widget, one treatment. The pill override that used to sit at this point re-rounded the
+ * corners the generic rules had just squared and removed the floor line they had just drawn, so the detail
+ * strip and the toolbar strip diverged for no reason an author could state, and every subsequent fix had to be
+ * written twice. Deleting it is the whole of the fix - resist adding a replacement rule here, including a
+ * "small" one. The only detail-tabs selector left in this file is the bar-slot host below, which positions a
+ * trailing region rather than repainting the strip.
*/
-.juneau-view-ribbon-group.juneau-view-detail-tabs[data-juneau-strip-mode="tab"] {
- gap: var(--jc-space-1);
- width: 100%;
- margin: 0 0 0.75em;
- padding: 0.25em;
- border-bottom: none;
-}
-.juneau-view-ribbon-group.juneau-view-detail-tabs[data-juneau-strip-mode="tab"] .juneau-view-ribbon-btn {
- margin-bottom: 0;
- border-left: 1px solid;
- border-right: 1px solid;
- border-radius: 999px;
-}
-
-.juneau-view-ribbon-group.juneau-view-detail-tabs[data-juneau-strip-mode="tab"] .juneau-view-ribbon-btn:first-child,
-.juneau-view-ribbon-group.juneau-view-detail-tabs[data-juneau-strip-mode="tab"] .juneau-view-ribbon-btn:last-child,
-.juneau-view-ribbon-group.juneau-view-detail-tabs[data-juneau-strip-mode="tab"] .juneau-view-ribbon-btn:only-child {
- border-radius: 999px;
+/*
+ * Count suffix on a detail tab (DetailSection.count). Not a strip repaint - it styles a child the client
+ * appends, and the strip itself is untouched. The gap is a MARGIN on purpose: the tab button is inline-flex,
+ * so a leading space in the markup would be collapsed away and the tab would read "Suspensions(0)".
+ */
+.juneau-view-detail-tab-count {
+ margin-left: var(--jc-space-1);
+ font-size: var(--jc-chrome-font-size-1);
}
.juneau-sym-flip-x { transform: scaleX(-1); }
@@ -1030,19 +1055,123 @@ table.dataTable thead .sorting_desc_disabled:after {
margin: 0 0 0.75em;
}
+/*
+ * FIELDS GRID. "juneau-view-detail-fields-cols-N" names the AUTHOR'S CAP (DetailSection.columns), not the
+ * rendered count: the ladder below decides how many of those N columns a given panel width can afford, and a
+ * section always starts at one. The count is a class rather than the inline grid-template-columns this file
+ * used to receive from ViewTable, because an inline style out-ranks every rule here - a 4-column section would
+ * stay 4 columns in a 320px panel and push horizontal overflow out through the host table.
+ *
+ * There is no per-section minimum column width, and there is no way to add one: a container query condition
+ * cannot read a var(), so a per-section threshold could only be produced by generating bespoke CSS per
+ * section, which is the inline-style pattern this rewrite exists to remove. The narrowest a field column
+ * reaches is an OUTPUT of the steps below.
+ */
.juneau-view-detail-fields {
display: grid;
- gap: var(--jc-space-2) var(--jc-space-3);
+ grid-template-columns: minmax(0, 1fr);
+ gap: 0 var(--jc-space-3);
}
-.juneau-view-detail-field-title {
- font-weight: 600;
+/*
+ * The ladder. Queries are unnamed and therefore resolve against .juneau-view-detail-panel, the containment
+ * host declared beside the shared scale at the top of this file. Each step names its own column count, which
+ * is what makes an integer span clampable at all (see the span rules below).
+ *
+ * ORDER IS LOAD-BEARING: a wide panel satisfies every min-width below it, so all three blocks apply at once
+ * and the LAST match wins at equal specificity. Narrowest step first, widest last. Reversing them silently
+ * pins every section to two columns at every width above 560px.
+ */
+@container (min-width: 560px) {
+ .juneau-view-detail-fields-cols-2,
+ .juneau-view-detail-fields-cols-3,
+ .juneau-view-detail-fields-cols-4 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
-.juneau-view-detail-field-markdown {
+@container (min-width: 800px) {
+ .juneau-view-detail-fields-cols-3,
+ .juneau-view-detail-fields-cols-4 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+}
+
+@container (min-width: 1040px) {
+ .juneau-view-detail-fields-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
+}
+
+/*
+ * A span is a MAXIMUM too. "1 / -1" is the only span value that is well-defined without knowing the rendered
+ * column count - it means first line to last line, so it resolves correctly at four columns and at one, where
+ * it is indistinguishable from no span at all. A markdown field carries this class from the server rather
+ * than getting its own grid-column rule; two mechanisms doing one job in one stylesheet is how the detail-tabs
+ * override started.
+ */
+.juneau-view-detail-field-span-full {
grid-column: 1 / -1;
}
+/*
+ * One field block. Block padding gives the row its breathing room; INLINE padding is deliberately ZERO,
+ * because inline padding insets the block's bottom border along with it and the separator is required to span
+ * the full column width including the label half.
+ */
+.juneau-view-detail-field {
+ padding: var(--jc-space-1) 0;
+ border-bottom: 1px solid; /* currentColor separator - themed by chrome.css */
+}
+
+/*
+ * INLINE (the default): label and value side by side. The label track is capped rather than a bare
+ * percentage - at 40% alone a full-span field gives a one-word label a track over four times a normal one's.
+ * Top-aligned so a label that wraps to two lines does not drag the value's first line down with it.
+ */
+.juneau-view-detail-fields-inline > .juneau-view-detail-field {
+ display: grid;
+ grid-template-columns: minmax(0, min(40%, 12em)) minmax(0, 1fr);
+ align-items: start;
+ column-gap: var(--jc-space-3);
+}
+
+/* STACKED: label above value. Also how a markdown field arranges itself under either layout - its value is a
+ * prose block, not a cell. */
+.juneau-view-detail-fields-stacked > .juneau-view-detail-field,
+.juneau-view-detail-fields-inline > .juneau-view-detail-field-markdown {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ align-items: start;
+}
+
+/*
+ * Label / value typography. The pairing is the shared scale's -font-size-1 and -font-size-3 steps rather than
+ * two private em values, and it INVERTS the old emphasis: the label used to be the heavier of the two, which
+ * made a panel read as a wall of bold keys. overflow-wrap on both is not polish - a single unbreakable value
+ * (a URL, a fingerprint) otherwise blows the column out and propagates horizontal overflow through the panel
+ * to the host table.
+ */
+.juneau-view-detail-field-title {
+ font-size: var(--jc-chrome-font-size-1);
+ font-weight: 400;
+ overflow-wrap: anywhere;
+}
+
+.juneau-view-detail-field-value {
+ font-size: var(--jc-chrome-font-size-3);
+ overflow-wrap: anywhere;
+ /*
+ * An empty value div contributes no line box, so an unset field would measure short and its separator
+ * would ride up out of line with its filled siblings. DERIVED from the two steps the value already
+ * spends rather than declared as a new one.
+ */
+ min-height: calc(var(--jc-chrome-line-height) * var(--jc-chrome-font-size-3));
+}
+
+/*
+ * Empty-value affordance. Generated content rather than markup, because the value div is a client-filled
+ * slot: the runtime assigns textContent (the empty string for a missing key), so anything the framework places
+ * INSIDE the div is overwritten on the first paint. ::after survives that by construction.
+ */
+.juneau-view-detail-field-value:empty::after {
+ content: "\2014";
+}
+
.juneau-view-detail-markdown {
max-width: 96ch;
}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.js b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.js
index 5d0d82e658..ac5193c4a7 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.js
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.js
@@ -1818,7 +1818,27 @@
? sec.querySelector(".juneau-view-detail-section-title") : null;
const label = titleEl?.textContent || sid;
if (titleEl) titleEl.hidden = true;
- return { id: sid, label: label, pane: sec };
+ // The server-declared count rides alongside the label rather than inside it - see paintDetailStripCounts.
+ // Read as a string and passed through untouched: "0" is a real count ("checked, none"), so the only value
+ // that means "no suffix" is the attribute being absent.
+ const count = sec.dataset.juneauDetailCount;
+ return { id: sid, label: label, pane: sec, count: count == null ? null : count };
+ }
+
+ // DETAIL-ONLY post-pass, deliberately a peer function (the same shape as relocateDetailBarSlot): the generic
+ // {id, label, pane} model has no room for a count, and it must not grow one - a count is a row-detail idea.
+ // Appending it to `label` instead would be worse than a leak: the builder paints label with textContent, so
+ // the count would land inside the button's single text node where CSS cannot reach it, and because the button
+ // is inline-flex a leading space before it collapses away and the tab reads "Suspensions(0)". Hence a real
+ // child element, spaced by margin.
+ function paintDetailStripCounts(tabs, items) {
+ for (const [i, item] of items.entries()) {
+ if (item.count == null || !tabs[i]) continue;
+ const el = document.createElement("span");
+ el.className = "juneau-view-detail-tab-count";
+ el.textContent = item.count;
+ tabs[i].btn.appendChild(el);
+ }
}
function insertDetailStrip(panel, strip) {
@@ -1852,6 +1872,7 @@
});
const strip = built.strip;
+ paintDetailStripCounts(built.tabs, items);
insertDetailStrip(panel, strip);
// Detail-caller step, deliberately a peer function (see relocateDetailBarSlot): a server-painted bar-slot
// region must follow the ribbon that was just built out from under it.
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/DetailFieldGrid_Emit_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/DetailFieldGrid_Emit_Test.java
new file mode 100644
index 0000000000..71aaa79f55
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/DetailFieldGrid_Emit_Test.java
@@ -0,0 +1,228 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static java.nio.charset.StandardCharsets.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Always-on coverage for the detail field grid's author-facing surface ({@link FieldLayout}, {@link FieldSpan},
+ * {@link DetailSection#count}) and for the stylesheet shape those classes are useless without.
+ *
+ * <p>
+ * What a JVM test can prove here is that the right class or attribute is emitted and that the rule it needs
+ * exists in the served stylesheet. What it cannot prove is that the rule then produces the intended layout - a
+ * container query and a cascade need a real engine - so the measurements live in
+ * {@link RowDetail_FieldGrid_BrowserTest} and this class deliberately stops short of asserting geometry.
+ */
+class DetailFieldGrid_Emit_Test extends TestBase {
+
+ private static String viewsCss() throws IOException {
+ try (var in = ViewsMixin.class.getResourceAsStream(ViewsMixin.VIEWS_CSS_RESOURCE)) {
+ assertNotNull(in);
+ return new String(in.readAllBytes(), UTF_8);
+ }
+ }
+
+ private static String viewsJs() throws IOException {
+ try (var in = ViewsMixin.class.getResourceAsStream(ViewsMixin.VIEWS_JS_RESOURCE)) {
+ assertNotNull(in);
+ return new String(in.readAllBytes(), UTF_8);
+ }
+ }
+
+ private static String html(DetailSection...sections) {
+ return Html.of(ViewTable.of(ViewDef.create("v")
+ .dataMode(ViewDef.DataMode.CLIENT)
+ .dataUrl("/data")
+ .columns(Column.of("id").title("Id"))
+ .details(RowDetailDef.create().endpoint("/data/{id}").sections(sections))
+ .build()));
+ }
+
+ private static DetailSection section(String id) {
+ return DetailSection.create(id, id).fields(DetailField.of(id + "-f").title("F"));
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // FieldLayout / FieldSpan - closed vocabularies the framework translates to a class
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_inlineIsTheDefault_andStackedIsAnExplicitOptIn() {
+ assertTrue(html(section("a")).contains("juneau-view-detail-fields-inline"));
+ assertTrue(html(section("a").layout(FieldLayout.INLINE)).contains("juneau-view-detail-fields-inline"));
+ var stacked = html(section("a").layout(FieldLayout.STACKED));
+ assertTrue(stacked.contains("juneau-view-detail-fields-stacked"), stacked);
+ assertFalse(stacked.contains("juneau-view-detail-fields-inline"), stacked);
+ }
+
+ @Test void a02_columnsIsACapAndIsClampedToTheLaddersWidestStep() {
+ assertTrue(html(section("a").columns(1)).contains("juneau-view-detail-fields-cols-1"));
+ assertTrue(html(section("a").columns(4)).contains("juneau-view-detail-fields-cols-4"));
+ // Above the ladder's widest step the class would name a rule that does not exist, so it clamps.
+ var over = html(section("a").columns(12));
+ assertTrue(over.contains("juneau-view-detail-fields-cols-4"), over);
+ assertFalse(over.contains("juneau-view-detail-fields-cols-12"), over);
+ }
+
+ @Test void a03_noInlineGridStyleSurvives() {
+ // The whole point of the class: an inline style out-ranks the stylesheet, so a container query could
+ // never step the grid down while one was being emitted.
+ var out = html(section("a").columns(3));
+ assertFalse(out.contains("grid-template-columns"), out);
+ assertFalse(out.contains("style=\"grid"), out);
+ }
+
+ @Test void a04_spanIsOptIn_andMarkdownSpansWithoutAskingForIt() {
+ var plain = html(DetailSection.create("s", "S").fields(DetailField.of("f").title("F")));
+ assertFalse(plain.contains("juneau-view-detail-field-span-full"), plain);
+ var spanned = html(DetailSection.create("s", "S").fields(DetailField.of("f").title("F").span(FieldSpan.FULL)));
+ assertTrue(spanned.contains("juneau-view-detail-field-span-full"), spanned);
+ var md = html(DetailSection.create("s", "S")
+ .fields(DetailField.of("f").title("F").format(DetailField.Format.MARKDOWN)));
+ assertTrue(md.contains("juneau-view-detail-field-span-full"),
+ () -> "markdown must span through the SAME mechanism, not a parallel hardcoded rule: " + md);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // DetailSection.count
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void b01_countIsOmittedWhenUnset_soASectionThatDeclaresNoneIsByteIdentical() {
+ assertFalse(html(section("a")).contains("data-juneau-detail-count"));
+ }
+
+ @Test void b02_zeroRenders_becauseCheckedNoneIsInformation() {
+ var out = html(section("a").count(0));
+ assertTrue(out.contains("data-juneau-detail-count=\"0\""), out);
+ }
+
+ @Test void b03_nonZeroRendersItsOwnValue() {
+ var out = html(section("a").count(12));
+ assertTrue(out.contains("data-juneau-detail-count=\"12\""), out);
+ }
+
+ @Test void b04_theClientReadsTheCountAndPaintsItAsItsOwnElement() throws Exception {
+ var js = viewsJs();
+ assertTrue(js.contains("sec.dataset.juneauDetailCount"),
+ () -> "buildDetailStripItem must read the server-stamped count");
+ assertTrue(js.contains("function paintDetailStripCounts(tabs, items)"), js);
+ assertTrue(js.contains("juneau-view-detail-tab-count"), js);
+ // Not folded into the label: the generic builder paints label with textContent, which would put the
+ // count inside the button's single text node where CSS cannot reach it.
+ assertTrue(js.contains("el.textContent = item.count;"), js);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // The stylesheet those classes depend on
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void c01_theQueryContainerIsThePanel_neverTheRoot() throws Exception {
+ var css = viewsCss();
+ assertTrue(css.contains("container-type: inline-size"), css);
+ var rootStart = css.indexOf(":root {");
+ assertTrue(rootStart >= 0, css);
+ var rootBlock = css.substring(rootStart, css.indexOf("\n}", rootStart));
+ assertFalse(rootBlock.contains("container-type"),
+ () -> "container-type on :root makes every unnamed @container query resolve against the viewport, "
+ + "which is the signal this panel was chosen to avoid: " + rootBlock);
+ var hostStart = css.indexOf(".juneau-view-detail-panel {");
+ var hostBlock = css.substring(hostStart, css.indexOf("}", hostStart));
+ assertTrue(hostBlock.contains("container-type: inline-size"), hostBlock);
+ }
+
+ @Test void c02_theLadderStepsUpwards_narrowestFirst() throws Exception {
+ var css = viewsCss();
+ var two = css.indexOf("@container (min-width: 560px)");
+ var three = css.indexOf("@container (min-width: 800px)");
+ var four = css.indexOf("@container (min-width: 1040px)");
+ assertTrue(two >= 0 && three >= 0 && four >= 0, css);
+ // A wide panel satisfies every step, so the last match wins. Reversed, every section would pin to two
+ // columns at every width - a silent failure that still renders a grid.
+ assertTrue(two < three && three < four,
+ () -> "container steps must appear narrowest-first or the widest step is overridden");
+ assertTrue(css.contains("repeat(4, minmax(0, 1fr))"), css);
+ }
+
+ @Test void c03_theFieldBlockCarriesASeparatorWithNoInlineInset() throws Exception {
+ var css = viewsCss();
+ var start = css.indexOf(".juneau-view-detail-field {");
+ assertTrue(start >= 0, css);
+ var rule = css.substring(start, css.indexOf("}", start));
+ assertTrue(rule.contains("border-bottom: 1px solid"), rule);
+ // Inline padding would inset the bottom border with it, and the separator is required to span the full
+ // column width including the label half.
+ assertTrue(rule.contains("padding: var(--jc-space-1) 0"), rule);
+ }
+
+ @Test void c04_anEmptyValueGetsGeneratedContent_notMarkup() throws Exception {
+ var css = viewsCss();
+ assertTrue(css.contains(".juneau-view-detail-field-value:empty::after"), css);
+ // The value div is a client-filled textContent sink, so anything placed INSIDE it is overwritten on the
+ // first paint. ::after survives that by construction.
+ assertTrue(css.contains("content: \"\\2014\""), css);
+ }
+
+ @Test void c05_theLabelValuePairSpendsTheSharedScale_andIsInverted() throws Exception {
+ var css = viewsCss();
+ var titleAt = css.indexOf(".juneau-view-detail-field-title {");
+ var title = css.substring(titleAt, css.indexOf("}", titleAt));
+ assertTrue(title.contains("var(--jc-chrome-font-size-1)"), title);
+ assertTrue(title.contains("font-weight: 400"), () -> "the label used to be the heavier of the pair: " + title);
+ var valueAt = css.indexOf(".juneau-view-detail-field-value {");
+ var value = css.substring(valueAt, css.indexOf("}", valueAt));
+ assertTrue(value.contains("var(--jc-chrome-font-size-3)"), value);
+ assertTrue(value.contains("overflow-wrap: anywhere"), value);
+ assertTrue(title.contains("overflow-wrap: anywhere"), title);
+ }
+
+ @Test void c06_fontSize3IsNoLongerProvisional_butItsSiblingsStillAre() throws Exception {
+ var css = viewsCss();
+ var line = css.lines().filter(x -> x.contains("--jc-chrome-font-size-3:")).findFirst().orElseThrow();
+ assertFalse(line.contains("PROVISIONAL"),
+ () -> "the detail field value is -font-size-3's first consumer, which fixes it: " + line);
+ // The other two markers belong to a different item and must not be swept up.
+ assertTrue(css.contains("--jc-chrome-font-size-2: 0.8125rem; /* PROVISIONAL"), css);
+ assertTrue(css.contains("--jc-chrome-line-height: 1.2; /* PROVISIONAL"), css);
+ }
+
+ @Test void c07_theDetailTabsPillOverrideIsGone_butTheBarSlotHostSurvives() throws Exception {
+ var css = viewsCss();
+ var pill = ".juneau-view-ribbon-group.juneau-view-detail-tabs[data-juneau-strip-mode=\"tab\"]";
+ assertFalse(css.contains(pill + " {"), css);
+ assertFalse(css.contains(pill + " .juneau-view-ribbon-btn"), css);
+ assertTrue(css.contains(pill + "[data-juneau-strip-trailed]"), css);
+ // The generic tab-mode shape the detail strip now inherits must still be there.
+ assertTrue(css.contains(".juneau-view-ribbon-group[data-juneau-strip-mode=\"tab\"] {"), css);
+ }
+
+ @Test void c08_theCountSuffixIsGappedByMarginNotWhitespace() throws Exception {
+ var css = viewsCss();
+ var start = css.indexOf(".juneau-view-detail-tab-count {");
+ assertTrue(start >= 0, css);
+ var rule = css.substring(start, css.indexOf("}", start));
+ // The tab button is inline-flex, so a leading space in the markup collapses and the tab would read
+ // "Suspensions(0)".
+ assertTrue(rule.contains("margin-left: var(--jc-space-1)"), rule);
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RowDetail_FieldGrid_BrowserTest.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RowDetail_FieldGrid_BrowserTest.java
new file mode 100644
index 0000000000..ea9985d033
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RowDetail_FieldGrid_BrowserTest.java
@@ -0,0 +1,251 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static java.nio.charset.StandardCharsets.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.nio.file.*;
+import java.util.*;
+import java.util.concurrent.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.condition.*;
+
+/**
+ * Opt-in Chromium layout canary for the detail field grid.
+ *
+ * <p>
+ * Every property here is one a fake DOM cannot evaluate - a container query, a cascade, a {@code ::after}, and an
+ * overflow measurement - which is why this class exists alongside the always-on emit coverage in
+ * {@link ViewTable_RowDetail_Emit_Test} rather than instead of it. The markup comes from the real
+ * {@link ViewTable} emitter and the stylesheet is the real served {@code juneau-views.css}, so neither can drift
+ * from what a consumer gets.
+ *
+ * <p>
+ * <b>The viewport is held at 1200px for the whole run; only the panel's host element is resized.</b> That is the
+ * assertion that distinguishes a container query from a media query: the panel is nested inside a table cell, so
+ * its width and the window's are unrelated, and a ladder that only responded to the window would be measuring the
+ * wrong thing while looking correct.
+ *
+ * <p>
+ * Disabled unless the {@value #GATE} system property is set (the module's {@code js-tests} Maven profile).
+ */
+@EnabledIfSystemProperty(named=RowDetail_FieldGrid_BrowserTest.GATE, matches="true",
+ disabledReason="JS-execution harness is opt-in; run with `mvn -Pjs-tests -f juneau-rest/juneau-rest-server-views/pom.xml test`")
+class RowDetail_FieldGrid_BrowserTest extends TestBase {
+
+ /** System property the {@code js-tests} profile sets to enable this class. */
+ static final String GATE = "juneau.jsTests";
+
+ private static Map<?,?> report;
+
+ private static String resource(String path) throws IOException {
+ try (var in = ViewsMixin.class.getResourceAsStream(path)) {
+ assertNotNull(in, () -> "missing classpath resource: " + path);
+ return new String(in.readAllBytes(), UTF_8);
+ }
+ }
+
+ /** A four-column INLINE section carrying a FULL-span field, an unset field, and an unbreakable value. */
+ private static ViewDef gridView() {
+ return ViewDef.create("alerts")
+ .dataMode(ViewDef.DataMode.CLIENT)
+ .dataUrl("/data/alerts")
+ .columns(Column.of("id").title("Id"))
+ .details(RowDetailDef.create()
+ .endpoint("/data/alerts/{id}")
+ .sections(DetailSection.create("grid", "Grid")
+ .columns(4)
+ .fields(
+ DetailField.of("name").title("Name"),
+ DetailField.of("owner").title("Owner"),
+ DetailField.of("region").title("Region"),
+ DetailField.of("state").title("State"),
+ DetailField.of("url").title("Runbook URL"),
+ DetailField.of("missing").title("Missing"),
+ DetailField.of("summary").title("Summary").span(FieldSpan.FULL))))
+ .build();
+ }
+
+ private static ViewDef stackedView() {
+ return ViewDef.create("notes")
+ .dataMode(ViewDef.DataMode.CLIENT)
+ .dataUrl("/data/notes")
+ .columns(Column.of("id").title("Id"))
+ .details(RowDetailDef.create()
+ .endpoint("/data/notes/{id}")
+ .sections(DetailSection.create("body", "Body")
+ .columns(1)
+ .layout(FieldLayout.STACKED)
+ .fields(DetailField.of("note").title("Note"))))
+ .build();
+ }
+
+ /** The server-painted detail template's contents - what the runtime clones into a panel div. */
+ private static String templateInner(ViewDef v) {
+ var html = Html.of(ViewTable.of(v));
+ var at = html.indexOf(ViewTable.DETAIL_TEMPLATE_ATTR);
+ assertTrue(at >= 0, html);
+ var open = html.indexOf('>', at);
+ var close = html.indexOf("</template>", open);
+ assertTrue(close > open, html);
+ return html.substring(open + 1, close);
+ }
+
+ @BeforeAll
+ static void probe() throws Exception {
+ var dir = Path.of(requiredProperty("juneau.jsTests.dir"));
+ var harness = Path.of(requiredProperty("juneau.jsTests.harness")).getParent()
+ .resolve("detail-field-grid-browser.cjs");
+
+ var fixture = "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><style>\n"
+ + resource(ViewsMixin.VIEWS_CSS_RESOURCE)
+ + "\n</style></head><body>\n"
+ + "<div id=\"host\" style=\"width:1200px\">"
+ + "<div class=\"juneau-view-detail-panel\" id=\"panel\">" + templateInner(gridView()) + "</div>"
+ + "</div>\n"
+ + "<div class=\"juneau-view-detail-panel\" id=\"stacked\" style=\"width:900px\">"
+ + templateInner(stackedView()) + "</div>\n"
+ + "</body></html>";
+ var fixtureFile = Files.createDirectories(dir.resolve("fixtures")).resolve("detail-field-grid.html");
+ Files.write(fixtureFile, fixture.getBytes(UTF_8));
+
+ report = Json.to(run(dir, harness, fixtureFile), Map.class);
+ }
+
+ private static String requiredProperty(String name) {
+ var v = System.getProperty(name);
+ assertNotNull(v, () -> "-D" + name + " not set; the js-tests profile is responsible for providing it");
+ return v;
+ }
+
+ private static String run(Path dir, Path harness, Path fixture) throws Exception {
+ var cmd = List.of(System.getProperty("juneau.jsTests.node", "node"), harness.toString(), fixture.toString());
+ var stdout = dir.resolve("detail-field-grid-browser-stdout.json");
+ var stderr = dir.resolve("detail-field-grid-browser-stderr.txt");
+ var pb = new ProcessBuilder(cmd).redirectOutput(stdout.toFile()).redirectError(stderr.toFile());
+ pb.environment().put("NODE_PATH", dir.resolve("node_modules").toString());
+ pb.environment().put("PLAYWRIGHT_BROWSERS_PATH", requiredProperty("juneau.jsTests.browsers"));
+
+ var p = pb.start();
+ if (!p.waitFor(3, TimeUnit.MINUTES)) {
+ p.destroyForcibly();
+ fail("prober did not finish within 3m; stderr:\n" + quietRead(stderr));
+ }
+ assertEquals(0, p.exitValue(), () -> "prober exited non-zero; stderr:\n" + quietRead(stderr));
+ return Files.readString(stdout);
+ }
+
+ private static String quietRead(Path p) {
+ try {
+ return Files.readString(p);
+ } catch (IOException e) {
+ return "<unreadable: " + e + ">";
+ }
+ }
+
+ private static Map<?,?> at(int hostWidth) {
+ var m = (Map<?,?>)report.get("w" + hostWidth);
+ assertNotNull(m, () -> "no measurement at host width " + hostWidth + ": " + report);
+ return m;
+ }
+
+ private static int cols(int hostWidth) {
+ return ((Number)at(hostWidth).get("cols")).intValue();
+ }
+
+ @Test void a01_pageLoadedWithNoScriptErrors() {
+ assertEquals(List.of(), report.get("jsFailures"), () -> "the fixture logged errors: " + report.get("jsFailures"));
+ }
+
+ /** The host widths G-1 measured on {@code master}, re-checked in the delivered tree. */
+ private static final List<Integer> WIDTHS = List.of(1200, 900, 700, 520, 380);
+
+ @Test void a02_columnsStepDownWithTheContainer_notTheWindow() {
+ // One section, declaring columns(4), measured at five host widths behind an unchanged 1200px window.
+ assertEquals(4, cols(1200), report::toString);
+ assertEquals(3, cols(900), report::toString);
+ assertEquals(2, cols(700), report::toString);
+ assertEquals(1, cols(520), report::toString);
+ assertEquals(1, cols(380), report::toString);
+ // columns(4) is a cap: no width may produce a fifth track, and no track may be a degenerate orphan the
+ // way an auto-fit repetition raised by a span would be.
+ for (var w : WIDTHS) {
+ assertTrue(cols(w) <= 4, () -> "a span created a column beyond the declared cap at " + w + ": " + report);
+ assertTrue(((Number)at(w).get("narrowestTrackPx")).doubleValue() > 60,
+ () -> "degenerate column track at host width " + w + ": " + report);
+ }
+ }
+
+ @Test void a03_theContainerIsThePanel_soAWideePanelIgnoresANarrowWindow() {
+ // Host pinned at 1200px, window squeezed to 400px. If container-type had been declared on :root this
+ // would report 1 and the whole ladder would be a media query in disguise.
+ assertEquals(4, ((Number)report.get("colsWithWideHostNarrowWindow")).intValue(),
+ () -> "the query container must be .juneau-view-detail-panel, not the viewport: " + report);
+ }
+
+ @Test void a04_fullSpanOccupiesEveryRenderedColumn() {
+ for (var w : WIDTHS)
+ assertEquals(Boolean.TRUE, at(w).get("spanIsFullWidth"),
+ () -> "a FULL span must reach the grid's full width at host width " + w + ": " + report);
+ // At one column FULL and ONE are the same thing, which is what makes "1 / -1" the only span value that
+ // is well-defined without knowing the rendered count.
+ assertEquals(Boolean.TRUE, at(380).get("spanEqualsOne"), report::toString);
+ assertEquals(Boolean.FALSE, at(1200).get("spanEqualsOne"), report::toString);
+ }
+
+ @Test void a05_anUnbreakableValueDoesNotOverflow_atEitherLevel() {
+ // On master this measured scrollWidth 684 against clientWidth 620 at a 700px host, and the overflow
+ // propagated out through the panel to the host table.
+ for (var w : WIDTHS) {
+ assertEquals(Boolean.TRUE, at(w).get("noOverflow"),
+ () -> "the fields grid overflowed at host width " + w + ": " + report);
+ assertEquals(Boolean.TRUE, at(w).get("noPanelOverflow"),
+ () -> "the detail panel overflowed at host width " + w + ": " + report);
+ }
+ }
+
+ @Test void a06_anUnsetValueKeepsItsRow_itsSeparatorAndAnEmDash() {
+ var m = at(900);
+ assertEquals("solid 1px", m.get("emptySeparator"), report::toString);
+ assertTrue(String.valueOf(m.get("emptyAfter")).contains("\u2014"),
+ () -> "an unset value must render the generated em-dash: " + report);
+ var empty = ((Number)m.get("emptyValueHeight")).doubleValue();
+ var filled = ((Number)m.get("filledValueHeight")).doubleValue();
+ assertTrue(empty > 0 && Math.abs(empty - filled) <= 1,
+ () -> "an unset value slot must measure the same as a filled one (" + empty + " vs " + filled
+ + "); without the derived min-height it contributes no line box and the row rides short: " + report);
+ }
+
+ @Test void a07_labelIsSmallerThanValue_andSitsBesideItUnderInline() {
+ var m = at(1200);
+ var label = ((Number)m.get("labelPx")).doubleValue();
+ var value = ((Number)m.get("valuePx")).doubleValue();
+ assertTrue(label < value, () -> "the label must be the smaller of the pair: " + report);
+ assertEquals(Boolean.TRUE, m.get("labelBesideValue"), report::toString);
+ assertEquals(Boolean.TRUE, at(380).get("labelBesideValue"),
+ () -> "a one-column grid must not break the INLINE pairing: " + report);
+ }
+
+ @Test void a08_stackedPutsTheLabelAboveTheValue() {
+ assertEquals(Boolean.TRUE, report.get("stackedLabelAboveValue"), report::toString);
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewTable_RowDetail_Emit_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewTable_RowDetail_Emit_Test.java
index 10e747b89d..70fdedced6 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewTable_RowDetail_Emit_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewTable_RowDetail_Emit_Test.java
@@ -115,9 +115,35 @@ class ViewTable_RowDetail_Emit_Test extends TestBase {
assertTrue(expanderAt >= 0 && idTitleAt > expanderAt, html);
}
- @Test void a06_gridTemplateColumns_fromValidatedColumns() {
+ /**
+ * The validated column count reaches the grid as a CLASS, not as an inline
+ * {@code grid-template-columns}. The inline style this used to assert is what made the count fixed: it
+ * out-ranks every rule in {@code juneau-views.css}, so a container query could never step the grid down and
+ * a wide section stayed wide inside a narrow panel. The class names the author's cap; the stylesheet's
+ * {@code @container} ladder decides how many of those columns a given panel width can afford.
+ */
+ @Test void a06_gridColumnCap_fromValidatedColumns_isAClassNotAnInlineStyle() {
var html = Html.of(ViewTable.of(view()));
- assertTrue(html.contains("grid-template-columns:repeat(2,minmax(0,1fr))"), html);
+ assertTrue(html.contains("juneau-view-detail-fields-cols-2"), html);
+ assertFalse(html.contains("grid-template-columns"), html);
+ // INLINE is the default arrangement and is stamped explicitly rather than left as an absent class, so the
+ // two arrangements are symmetric in the stylesheet.
+ assertTrue(html.contains("juneau-view-detail-fields-inline"), html);
+ // The cap is clamped to the widest step the ladder declares, so an over-large author value cannot emit a
+ // class with no rule behind it.
+ var wide = Html.of(ViewTable.of(ViewDef.create("wide")
+ .dataMode(DataMode.CLIENT)
+ .dataUrl("/data")
+ .columns(Column.of("id").title("Id"))
+ .details(RowDetailDef.create()
+ .endpoint("/data/{id}")
+ .sections(DetailSection.create("s", "S")
+ .columns(9)
+ .layout(FieldLayout.STACKED)
+ .fields(DetailField.of("a").title("A"))))
+ .build()));
+ assertTrue(wide.contains("juneau-view-detail-fields-cols-4"), wide);
+ assertTrue(wide.contains("juneau-view-detail-fields-stacked"), wide);
}
@Test void a07_pageTable_alsoEmitsChildTemplate() {
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsMixin_Serving_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsMixin_Serving_Test.java
index 5125435175..e8055473c7 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsMixin_Serving_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsMixin_Serving_Test.java
@@ -793,12 +793,27 @@ class ViewsMixin_Serving_Test extends TestBase {
assertTrue(body.substring(lcStart, body.indexOf("}", lcStart)).contains("border-bottom-right-radius: 0"), body);
}
- @Test void o06_viewsCss_hasDetailHeaderAndIndependentDetailTabs() throws Exception {
+ /**
+ * Detail tabs are no longer INDEPENDENT of the generic strip - they are the generic tab-mode strip,
+ * unmodified. The pill override this used to assert the existence of is what made the two diverge, and
+ * deleting it is the whole of the fix, so what is pinned here is its ABSENCE.
+ *
+ * <p>Absence is asserted against the rule headers rather than against the bare selector, because the
+ * bar-slot host rule below still legitimately starts with that same selector text and would satisfy a naive
+ * {@code contains} check - the reason the original assertion here would have stayed green through the
+ * deletion instead of failing as the change that removed it expected.
+ */
+ @Test void o06_viewsCss_hasDetailHeaderAndNoDetailTabOverride() throws Exception {
var body = cWithMixin.get(ViewsMixin.VIEWS_CSS_PATH).run().assertStatus(200).getContent().asString();
assertTrue(body.contains(".juneau-view-detail-header {"), body);
assertTrue(body.contains(".juneau-view-detail-title {"), body);
assertTrue(body.contains(".juneau-view-detail-icon {"), body);
- assertTrue(body.contains(".juneau-view-ribbon-group.juneau-view-detail-tabs[data-juneau-strip-mode=\"tab\"]"), body);
+ var pill = ".juneau-view-ribbon-group.juneau-view-detail-tabs[data-juneau-strip-mode=\"tab\"]";
+ assertFalse(body.contains(pill + " {"), body);
+ assertFalse(body.contains(pill + " .juneau-view-ribbon-btn"), body);
+ // The one detail-tabs selector that survives positions a trailing bar-slot region; it does not repaint
+ // the strip.
+ assertTrue(body.contains(pill + "[data-juneau-strip-trailed]"), body);
assertTrue(body.contains("border-radius: 999px"), body);
assertTrue(body.contains(".juneau-sym-flip-x"), body);
}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/js/detail-field-grid-browser.cjs b/juneau-rest/juneau-rest-server-views/src/test/js/detail-field-grid-browser.cjs
new file mode 100644
index 0000000000..57ccb3b175
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/js/detail-field-grid-browser.cjs
@@ -0,0 +1,172 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/*
+ * detail-field-grid-browser.cjs - opt-in Chromium layout canary for the detail field grid.
+ *
+ * Never runs in a default build. Driven by RowDetail_FieldGrid_BrowserTest under `mvn -Pjs-tests`.
+ *
+ * Usage: node detail-field-grid-browser.cjs <page.html>
+ *
+ * Everything measured here is unreachable from a fake DOM: a container query, a cascade, generated content, and
+ * an overflow measurement all require a real layout engine.
+ *
+ * THE VIEWPORT NEVER CHANGES. Only the panel's host element is resized, at a fixed 1200px viewport. That is
+ * the whole point: if `container-type` were declared on :root instead of on .juneau-view-detail-panel, every
+ * unnamed @container query would resolve against the 1200px viewport and the column count would never move. A
+ * ladder measured by shrinking the WINDOW would pass either way and would prove nothing.
+ */
+'use strict';
+
+const fs = require('node:fs');
+const path = require('node:path');
+const { chromium } = require('playwright');
+
+/** Fills the client-painted slots the way the runtime does, so the grid measures against real content. */
+const SETUP = function (longValue) {
+ const q = function (sel) { return document.querySelector(sel); };
+ q('[data-juneau-field="name"]').textContent = 'alerts-primary';
+ q('[data-juneau-field="owner"]').textContent = 'Platform';
+ q('[data-juneau-field="region"]').textContent = 'us-east-1';
+ q('[data-juneau-field="state"]').textContent = 'Active';
+ q('[data-juneau-field="url"]').textContent = longValue;
+ // "summary" is the FULL-span field and "missing" is the unset one - both stay empty on purpose.
+ return { setup: true };
+};
+
+const MEASURE = function (hostWidth) {
+ document.getElementById('host').style.width = hostWidth + 'px';
+ return { resized: hostWidth };
+};
+
+const READ = function () {
+ const grid = document.querySelector('#panel .juneau-view-detail-fields');
+ const fieldOf = function (data) {
+ return document.querySelector('[data-juneau-field="' + data + '"]').closest('.juneau-view-detail-field');
+ };
+ const panel = document.getElementById('panel');
+ const tracks = window.getComputedStyle(grid).gridTemplateColumns.trim().split(/\s+/).map(parseFloat);
+ const cols = tracks.length;
+ const gridBox = grid.getBoundingClientRect();
+ const span = fieldOf('summary').getBoundingClientRect();
+ const one = fieldOf('name').getBoundingClientRect();
+ const missing = document.querySelector('[data-juneau-field="missing"]');
+ const nameField = fieldOf('name');
+ const cs = window.getComputedStyle(fieldOf('missing'));
+ return {
+ cols: cols,
+ // A span must never CREATE a track. The auto-fit candidate this design rejected failed exactly here:
+ // it raised the repetition count to satisfy the span and left a ~25px orphan track with a field clipped
+ // inside it, so counting tracks alone would have passed it. The narrowest track is the discriminator.
+ narrowestTrackPx: Math.min.apply(null, tracks),
+ // A FULL span occupies the grid's whole content width at every step, including the one-column step,
+ // where it must be indistinguishable from an unspanned field.
+ spanIsFullWidth: Math.abs(span.width - gridBox.width) <= 1,
+ spanEqualsOne: Math.abs(span.width - one.width) <= 1,
+ // The panel is nested in a table cell; a value that cannot break must not push the grid wider than
+ // its own box, or the overflow propagates out to the host table.
+ noOverflow: grid.scrollWidth <= grid.clientWidth + 1,
+ noPanelOverflow: panel.scrollWidth <= panel.clientWidth + 1,
+ // An unset field still occupies its row and still paints its separator.
+ emptySeparator: cs.borderBottomStyle + ' ' + cs.borderBottomWidth,
+ emptyAfter: window.getComputedStyle(missing, '::after').content,
+ // An empty value div contributes no line box of its own, so without a min-height an unset field measures
+ // short of a filled one. Compared value-slot to value-slot rather than block to block: a field BLOCK is
+ // stretched by the tallest item in its grid ROW, which says nothing about the empty case.
+ emptyValueHeight: missing.getBoundingClientRect().height,
+ filledValueHeight: document.querySelector('[data-juneau-field="state"]').getBoundingClientRect().height,
+ // The label is the smaller of the pair, inverting what the panel used to do.
+ labelPx: parseFloat(window.getComputedStyle(nameField.querySelector('.juneau-view-detail-field-title')).fontSize),
+ valuePx: parseFloat(window.getComputedStyle(document.querySelector('[data-juneau-field="name"]')).fontSize),
+ // INLINE puts the label beside the value, not above it.
+ labelBesideValue: (function () {
+ const t = nameField.querySelector('.juneau-view-detail-field-title').getBoundingClientRect();
+ const v = document.querySelector('[data-juneau-field="name"]').getBoundingClientRect();
+ return t.right <= v.left + 1 && t.top < v.bottom && v.top < t.bottom;
+ })()
+ };
+};
+
+const STACKED_READ = function () {
+ const field = document.querySelector('#stacked .juneau-view-detail-field');
+ const t = field.querySelector('.juneau-view-detail-field-title').getBoundingClientRect();
+ const v = field.querySelector('.juneau-view-detail-field-value').getBoundingClientRect();
+ return { stackedLabelAboveValue: t.bottom <= v.top + 1 };
+};
+
+const VIEWPORT_ONLY_READ = function () {
+ // The container is the panel, so a panel pinned wide keeps its column count no matter how narrow the WINDOW
+ // gets. This is the assertion that fails if container-type ever migrates to :root.
+ const grid = document.querySelector('#panel .juneau-view-detail-fields');
+ return { colsWithWideHostNarrowWindow: window.getComputedStyle(grid).gridTemplateColumns.trim().split(/\s+/).length };
+};
+
+async function settle(page) {
+ await page.evaluate(() => new Promise(requestAnimationFrame));
+ await page.evaluate(() => new Promise(requestAnimationFrame));
+}
+
+(async () => {
+ const [fixture] = process.argv.slice(2);
+ if (!fixture) {
+ process.stderr.write('usage: node detail-field-grid-browser.cjs <page.html>\n');
+ process.exit(2);
+ }
+ if (!fs.existsSync(fixture))
+ throw new Error('fixture not found: ' + fixture);
+
+ const url = 'file://' + path.resolve(fixture);
+ const browser = await chromium.launch();
+ try {
+ const page = await browser.newPage({ viewport: { width: 1200, height: 900 } });
+ const failures = [];
+ page.on('pageerror', e => failures.push(String(e)));
+ page.on('console', m => { if (m.type() === 'error') failures.push(m.text()); });
+ await page.goto(url);
+ await settle(page);
+
+ const report = {};
+ report.setup = await page.evaluate(SETUP, 'https://example.invalid/'
+ + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
+ + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
+ + 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc');
+
+ // The widths G-1 measured on master, re-checked in the delivered tree.
+ for (const w of [1200, 900, 700, 520, 380]) {
+ await page.evaluate(MEASURE, w);
+ await settle(page);
+ report['w' + w] = await page.evaluate(READ);
+ }
+
+ await page.evaluate(MEASURE, 1200);
+ await settle(page);
+ Object.assign(report, await page.evaluate(STACKED_READ));
+
+ // Host stays pinned at 1200px while the window shrinks to 400px.
+ await page.setViewportSize({ width: 400, height: 900 });
+ await settle(page);
+ Object.assign(report, await page.evaluate(VIEWPORT_ONLY_READ));
+
+ report.jsFailures = failures.slice();
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
+ } finally {
+ await browser.close();
+ }
+})().catch(e => {
+ process.stderr.write(String(e?.stack || e) + '\n');
+ process.exit(1);
+});