From e73b221a8c66457ce4d40c3f23a27ce204fdd974 Mon Sep 17 00:00:00 2001 From: josh Date: Sun, 19 Apr 2026 22:51:10 -0400 Subject: [PATCH] fix(ui): fit pipeline timeline without horizontal scroll 15 nodes (3 pre-stage + 11 stage + Completed) exceeded the 1280px main container's usable width, producing a horizontal scrollbar under the pipeline on the run page. Widen main to 1440px, tighten per-node min widths, drop the scrollbar, and split camelCase labels so multi-word stages ("WaitingReboot", "SpecValidate", "CPUStress") wrap onto two lines instead of forcing node width. Co-Authored-By: Claude Opus 4.7 --- internal/web/static/app.css | 17 +++++----- internal/web/templates/pipeline.templ | 30 +++++++++++++++++- internal/web/templates/pipeline_templ.go | 40 ++++++++++++++++++++---- 3 files changed, 72 insertions(+), 15 deletions(-) diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 7a19397..fbdf3d1 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -44,7 +44,7 @@ a:hover { text-decoration: underline; } .topbar .heartbeat { color: var(--text-dim); font-family: var(--mono); font-size: 12px; } .topbar .logout-form { margin: 0; } -main { max-width: 1280px; margin: 0 auto; padding: 24px; } +main { max-width: 1440px; margin: 0 auto; padding: 24px; } button, .button, .button-secondary { appearance: none; @@ -492,16 +492,16 @@ body.bare main { max-width: none; } } /* ===== Pipeline timeline ===== - 13 nodes (3 pre-stage + 9 stage + Completed). flex:1 on every node - so they share the full width evenly; overflow-x:auto only kicks in - on very narrow viewports. */ + 15 nodes (3 pre-stage + 11 stage + Completed). flex:1 on every node + so they share the full width evenly. Multi-word labels + (stageDisplayName in pipeline.templ) wrap on two lines so nodes pack + tight without horizontal scroll. */ .pipeline { display: flex; align-items: stretch; gap: 0; width: 100%; - overflow-x: auto; - padding: 28px 12px 16px; + padding: 28px 8px 16px; background: #0b0d12; border: 1px solid var(--border); border-radius: var(--radius); @@ -512,8 +512,8 @@ body.bare main { max-width: none; } align-items: center; gap: 6px; flex: 1 1 0; - min-width: 72px; - padding: 0 4px; + min-width: 56px; + padding: 0 2px; } .stage-dot { width: 28px; @@ -543,6 +543,7 @@ body.bare main { max-width: none; } text-align: center; font-weight: 500; line-height: 1.2; + overflow-wrap: anywhere; } .stage-node-passed .stage-name { color: var(--text); } .stage-node-running .stage-name { color: var(--accent); font-weight: 600; } diff --git a/internal/web/templates/pipeline.templ b/internal/web/templates/pipeline.templ index 9a2419e..b8bae19 100644 --- a/internal/web/templates/pipeline.templ +++ b/internal/web/templates/pipeline.templ @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "strings" "time" "vetting/internal/model" @@ -250,6 +251,33 @@ func stageDuration(n PipelineNode) string { } } +// stageDisplayName turns the internal single-word state/stage identifier +// into a human-readable label by inserting spaces before interior capital +// letters. "WaitingReboot" → "Waiting Reboot", "SpecValidate" → +// "Spec Validate", "CPUStress" → "CPU Stress". The space lets the +// pipeline node wrap the label onto two lines on narrow layouts instead +// of forcing horizontal scroll — single-word names ("Inventory", +// "Completed") pass through unchanged. +func stageDisplayName(name string) string { + var b strings.Builder + b.Grow(len(name) + 2) + for i, r := range name { + if i > 0 && r >= 'A' && r <= 'Z' { + prev := rune(name[i-1]) + // Insert a space at every lower→upper boundary ("Spec|Validate") + // and at upper→upper when the next char is lower ("CPU|Stress", + // where we split before the S that starts a new word). This + // keeps acronyms like "GPU"/"PSU" intact. + if (prev >= 'a' && prev <= 'z') || + (prev >= 'A' && prev <= 'Z' && i+1 < len(name) && name[i+1] >= 'a' && name[i+1] <= 'z') { + b.WriteByte(' ') + } + } + b.WriteRune(r) + } + return b.String() +} + // stageMarker returns the single-char glyph shown in the node's dot. // Dots stay colored-via-class; the glyph is redundant-but-helpful. func stageMarker(state string) string { @@ -277,7 +305,7 @@ templ Pipeline(nodes []PipelineNode) { }
{ stageMarker(n.State) }
-
{ n.Name }
+
{ stageDisplayName(n.Name) }
{ stageDuration(n) }
} diff --git a/internal/web/templates/pipeline_templ.go b/internal/web/templates/pipeline_templ.go index f9a4ffb..bd2a183 100644 --- a/internal/web/templates/pipeline_templ.go +++ b/internal/web/templates/pipeline_templ.go @@ -12,6 +12,7 @@ import ( "bytes" "context" "fmt" + "strings" "time" "vetting/internal/model" @@ -258,6 +259,33 @@ func stageDuration(n PipelineNode) string { } } +// stageDisplayName turns the internal single-word state/stage identifier +// into a human-readable label by inserting spaces before interior capital +// letters. "WaitingReboot" → "Waiting Reboot", "SpecValidate" → +// "Spec Validate", "CPUStress" → "CPU Stress". The space lets the +// pipeline node wrap the label onto two lines on narrow layouts instead +// of forcing horizontal scroll — single-word names ("Inventory", +// "Completed") pass through unchanged. +func stageDisplayName(name string) string { + var b strings.Builder + b.Grow(len(name) + 2) + for i, r := range name { + if i > 0 && r >= 'A' && r <= 'Z' { + prev := rune(name[i-1]) + // Insert a space at every lower→upper boundary ("Spec|Validate") + // and at upper→upper when the next char is lower ("CPU|Stress", + // where we split before the S that starts a new word). This + // keeps acronyms like "GPU"/"PSU" intact. + if (prev >= 'a' && prev <= 'z') || + (prev >= 'A' && prev <= 'Z' && i+1 < len(name) && name[i+1] >= 'a' && name[i+1] <= 'z') { + b.WriteByte(' ') + } + } + b.WriteRune(r) + } + return b.String() +} + // stageMarker returns the single-char glyph shown in the node's dot. // Dots stay colored-via-class; the glyph is redundant-but-helpful. func stageMarker(state string) string { @@ -378,7 +406,7 @@ func Pipeline(nodes []PipelineNode) templ.Component { var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(stageMarker(n.State)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 279, Col: 77} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 307, Col: 77} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -389,9 +417,9 @@ func Pipeline(nodes []PipelineNode) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var9 string - templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(n.Name) + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(stageDisplayName(n.Name)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 280, Col: 36} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 308, Col: 54} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { @@ -404,7 +432,7 @@ func Pipeline(nodes []PipelineNode) templ.Component { var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(stageDuration(n)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 281, Col: 50} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 309, Col: 50} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { @@ -458,7 +486,7 @@ func PipelineSection(run *model.Run, nodes []PipelineNode) templ.Component { var templ_7745c5c3_Var12 string templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("pipeline-%d", run.ID)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 296, Col: 41} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 324, Col: 41} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { @@ -471,7 +499,7 @@ func PipelineSection(run *model.Run, nodes []PipelineNode) templ.Component { var templ_7745c5c3_Var13 string templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("pipeline-%d", run.ID)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 298, Col: 47} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 326, Col: 47} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil {