diff --git a/internal/lua/billing_ui_test.go b/internal/lua/billing_ui_test.go
index 4f73af1..a9b49f0 100644
--- a/internal/lua/billing_ui_test.go
+++ b/internal/lua/billing_ui_test.go
@@ -339,3 +339,78 @@ func TestBillingTableHeaderMatchesRowColumns(t *testing.T) {
"table is misaligned from the first differing column on", th, td)
}
}
+
+// TestRuleTableLocksColumnWidthsInAColgroup guards the layout class of bug that
+// only a screenshot caught: two adjacent headers rendered on top of each other,
+// the URL input squeezed to "https:", and the per-model × button landing 78px
+// past its cell onto the neighbouring Delete button.
+//
+// The measurement that found each one:
+// - table-layout:fixed with widths on
→ 954px divided into 9 equal
+// 106px columns, every declared width discarded.
+// - widths on
WITHOUT fixed → browser sizes from content and ignores
+// them; the 70px currency column still collapsed to 48px (24px of input).
+// - fixed +
widths → they take effect, but the model-price cell's width
+// then depended on WHICH RULE was widest (352px vs 206px).
+//
+// The fix is all three together: fixed layout, a colgroup, and flex children
+// that may shrink.
+func TestRuleTableLocksColumnWidthsInAColgroup(t *testing.T) {
+ page, _ := billingUI(t)
+ js := strings.Join(extractScripts(page), "\n")
+ // Match the ATTRIBUTE, not the bare string: a substring check on
+ // "table-layout:fixed" is satisfied by the explanatory comment that sits
+ // three lines above the tag, so removing the attribute from the table still
+ // passed (mutation-verified). Require it inside a style='...' literal.
+ if !strings.Contains(js, `font-size:12px;table-layout:fixed'`) {
+ t.Error("the rule table must stay table-layout:fixed — with auto layout " +
+ "the
widths below are ignored and columns size from content")
+ }
+ colStart := strings.Index(js, "
")
+ if colStart < 0 {
+ t.Fatal("the rule table declares no
; per-column widths on " +
+ "
or
are advisory and get recomputed from content")
+ }
+ // The colgroup is built from a JS array of pixel widths, so assert on the
+ // array, not on literal "
"`) {
+ t.Error("colgroup entries are no longer emitted as
")
+ }
+ // Every price-row INPUT must be allowed to shrink, or the row's fixed widths
+ // sum past the cell and the × button lands on the next column. The × button
+ // is the opposite: it must NOT shrink (flex:0 0 auto), because a squashed
+ // delete button is unclickable.
+ for _, needle := range []string{"m-name", "m-p", "m-c"} {
+ i := strings.Index(js, "class='"+needle+"'")
+ if i < 0 {
+ i = strings.Index(js, "class='ghost small "+needle+"'")
+ }
+ if i < 0 {
+ t.Errorf("price row lost the %s input/button", needle)
+ continue
+ }
+ window := js[i : i+220]
+ if !strings.Contains(window, "flex:") {
+ t.Errorf("%s has no flex sizing; the row is 80+96+96+38 ≈ 330px wide "+
+ "against a 262px cell and overflows onto the next column", needle)
+ }
+ if !strings.Contains(window, "min-width:0") {
+ t.Errorf("%s lacks min-width:0; a flex item will not shrink below its "+
+ "content width, which is how the × button ended up 78px past its cell", needle)
+ }
+ }
+ i := strings.Index(js, "class='ghost small m-del'")
+ if i < 0 {
+ t.Fatal("the per-model delete button is gone")
+ }
+ if !strings.Contains(js[i:i+120], "flex:0 0 auto") {
+ t.Error("the per-model × button must be flex:0 0 auto — it is a fixed-size " +
+ "control and must never be squeezed by the flexible inputs")
+ }
+}
diff --git a/internal/lua/plugins/billing.lua b/internal/lua/plugins/billing.lua
index 0c70ae7..61cede8 100644
--- a/internal/lua/plugins/billing.lua
+++ b/internal/lua/plugins/billing.lua
@@ -1039,15 +1039,21 @@ plugin.ui = {
// ---- row rendering -------------------------------------------------------
function modelBlock(models, T) {
var keys = Object.keys(models || {});
+ // Every child must be allowed to SHRINK: the price cell is 262px wide and
+ // the four parts (name + prompt + completion + delete) used to be
+ // 80+96+96+38 plus gaps = ~330px, so the × button overflowed the cell by
+ // 78px and landed on top of the neighbouring column's Delete button.
+ // flex:1 1 0 with min-width:0 lets the inputs share the cell instead of
+ // pushing past it; the row wraps rather than overlapping.
var rows = keys.map(function (m) {
var t = models[m] || {};
- return "
";
}
@@ -1247,11 +1253,43 @@ plugin.ui = {
"" +
"" +
"" +
+ // Widths live on
AND the layout is fixed — the two go together.
+ //
+ // Measured: with table-layout:fixed and widths on the
s, the 954px
+ // container was divided into 9 equal 106px columns and every per-column
+ // width was discarded, so two adjacent headers sat on top of each other
+ // and the URL input was squeezed to "https:". With auto layout the
+ // browser ignores the
widths entirely and sizes from content, so
+ // the 70px currency column still collapsed to 48px (24px of input —
+ // too narrow to show "USD"). Under fixed layout the FIRST ROW's widths
+ // win, so the
widths below are the ones that take effect, and the
+ // total (1046px) exceeds the container on purpose: the wrapper scrolls.
"
" +
+ // colgroup, not th widths: under table-layout:fixed the first row's
+ // widths win, and the model-price cell holds a variable-width list of
+ // price rows whose content width varies per rule. Measuring that cell
+ // gave 352px on one rule and 206px on another — the whole row's
+ // layout then depended on WHICH RULE happened to be widest. A colgroup
+ // fixes the nine columns once and keeps every row identical.
+ "
" +
+ // The last column holds a 「删除 / Delete」 button: 58px wrapped it
+ // to two lines and the button grew taller than the row. 74px fits it
+ // on one line; the peak-multiplier column gives up the difference
+ // (it only ever holds a single digit such as "2").
+ [214, 80, 62, 70, 66, 74, 62, 262, 74]
+ .map(function (w) { return "
"; }).join("") +
+ "
" +
"
" +
- "
" + esc(T.url) + "
" + esc(T.mode) + "
" + esc(T.currency) + "
" +
- "
" + esc(T.monthly) + "
" + esc(T.peakMult) + "
" + esc(T.peakHours) + "
" +
- "
" + esc(T.peakDays) + "
" + esc(T.models) + "
" +
+ [[T.url], [T.mode], [T.currency], [T.monthly], [T.peakMult],
+ [T.peakHours], [T.peakDays], [T.models], [""]]
+ .map(function (c) {
+ // nowrap + ellipsis: the bilingual headers ("峰段星期 (UTC 1=周一)")
+ // are far wider than their column, and without this they spilled
+ // into the neighbouring header. The full text stays in title.
+ return "