carlos / platform Public

console: final-review fixes — tenant-filtered app rows, first-promote path, label/copy consistency

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell b1960072c285d977febae2be787bfeb5ce45d7c9 parent 3c5ee7e
9 files changed, +198 −46
  • internal/console/overview.go +5 −5
  • internal/console/overview_test.go +12 −7
  • internal/console/server.go +61 −18
  • internal/console/server_test.go +110 −6
  • internal/console/templates/app_overview.html +2 −2
  • internal/console/templates/app_releases.html +3 −3
  • internal/console/templates/appnav.html +1 −1
  • internal/console/templates/apps.html +1 −1
  • internal/console/templates/layout.html +3 −3
diff --git a/internal/console/overview.go b/internal/console/overview.go
index d87ba9467..d7738e9be 100644
--- a/internal/console/overview.go
+++ b/internal/console/overview.go
@@ -123,11 +123,11 @@ func assembleOverview(view *AppView, acct string, metrics []InstanceMetrics, ver
st.InstanceTotal++
if rt.Kind == "instance" {
st.InstanceExpected++
- }
- if reporting[rt.Host] {
- st.InstanceReporting++
- } else if rt.Hibernate {
- st.InstanceAsleep++
+ if reporting[rt.Host] {
+ st.InstanceReporting++
+ } else if rt.Hibernate {
+ st.InstanceAsleep++
+ }
}
}
// Routes and Domains are parallel slices — Assemble appends one of each
diff --git a/internal/console/overview_test.go b/internal/console/overview_test.go
index 0fe40cfcc..4ada7994e 100644
--- a/internal/console/overview_test.go
+++ b/internal/console/overview_test.go
@@ -79,13 +79,13 @@ func TestOverviewUnreadableStableIsUnknownNotOffline(t *testing.T) {
// and another account's same-named routes count for nothing at all.
func TestOverviewSumsOnlyReportingInstances(t *testing.T) {
view := &AppView{App: "kass", Routes: []RouteView{
- {Host: "a", Account: "bab"},
- {Host: "b", Account: "bab"},
- {Host: "c", Account: "bab"},
- {Host: "d", Account: "bab", Hibernate: true},
- {Host: "e", Account: "bab"},
+ {Host: "a", Account: "bab", Kind: "instance"},
+ {Host: "b", Account: "bab", Kind: "instance"},
+ {Host: "c", Account: "bab", Kind: "instance"},
+ {Host: "d", Account: "bab", Kind: "instance", Hibernate: true},
+ {Host: "e", Account: "bab", Kind: "instance"},
// Another account's same-named app: not this dashboard's instance.
- {Host: "theirs", Account: "cdef"},
+ {Host: "theirs", Account: "cdef", Kind: "instance"},
}}
metrics := []InstanceMetrics{
{Host: "a", Box: "box-1", CPUFrac: f64(0.25), MemBytes: i64(100)},
@@ -118,7 +118,7 @@ func TestOverviewSumsOnlyReportingInstances(t *testing.T) {
// Nobody reported: nil, not zero. A zero here would render as "0%" and
// claim an idle app where there is simply no reading.
- quietView := &AppView{App: "kass", Routes: []RouteView{{Host: "a", Account: "bab"}, {Host: "b", Account: "bab"}}}
+ quietView := &AppView{App: "kass", Routes: []RouteView{{Host: "a", Account: "bab", Kind: "instance"}, {Host: "b", Account: "bab", Kind: "instance"}}}
quiet := assembleOverview(quietView, "bab", []InstanceMetrics{{Host: "a"}, {Host: "b"}}, nil)
if quiet.CPUSum != nil || quiet.MemSum != nil {
t.Fatalf("sums = %v %v with nothing reporting, want nil so the tiles show the em-dash",
@@ -337,6 +337,11 @@ func TestOverviewPageRendersTiles(t *testing.T) {
if !strings.Contains(body, "<code>v1</code>") {
t.Fatalf("the headline does not show what stable serves:\n%s", body)
}
+ // The headline also carries provenance — when it went live, restored here
+ // after Task 3's rewrite dropped the assertion.
+ if !strings.Contains(body, "published") {
+ t.Fatalf("the headline is missing its provenance (published ...):\n%s", body)
+ }
// The fixture's two domains need attention, which outranks everything
// else in healthVerdict — the headline verdict this page exists to show.
if !strings.Contains(body, "Needs attention") {
diff --git a/internal/console/server.go b/internal/console/server.go
index 329c49c1e..4a9f9226a 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -453,9 +453,14 @@ type page struct {
// ReleaseSummary is the Releases tab's lede strip: what is live and
// what is next, folded from Detail.Channels once the tab has them.
ReleaseSummary ReleaseSummary
- Instances *InstanceReport // app page: the app's self-report, nil when unpolled
- Events []Event // app page: recent console actions, newest first
- CanPromote bool // a signing key is configured on this host
+ // PromoteChannels is the per-row promote select's option list: the
+ // ladder rungs (edge, beta, stable) so a fresh app with zero channels
+ // still has somewhere to promote its first release, unioned with any
+ // channels (including canary/*) the app already has.
+ PromoteChannels []string
+ Instances *InstanceReport // app page: the app's self-report, nil when unpolled
+ Events []Event // app page: recent console actions, newest first
+ CanPromote bool // a signing key is configured on this host
// App page: how a canary channel gets a URL, rendered as commands the
// reader can actually run from where they are reading. Computed
@@ -1300,7 +1305,7 @@ func (s *Server) handleAccountHome(w http.ResponseWriter, r *http.Request) {
return
}
for _, v := range views {
- p.Apps = append(p.Apps, summarize(v))
+ p.Apps = append(p.Apps, summarize(v, acct.Sqid))
}
}
// The Trash link, and only when there is something in it. Best-effort: a
@@ -1510,6 +1515,7 @@ func (s *Server) handleAppReleases(w http.ResponseWriter, r *http.Request) {
return
}
p.ReleaseSummary = assembleReleaseSummary(p.Detail.Channels)
+ p.PromoteChannels = promoteChannelOptions(p.Detail.Channels)
versions, err := Versions(s.cfg.Releases, acct.Sqid, p.AppName)
if err != nil {
log.Printf("console: versions %s: %v", p.AppName, err)
@@ -1547,22 +1553,22 @@ func (s *Server) handleAppReleases(w http.ResponseWriter, r *http.Request) {
// The "how do I give this a URL" copy — not shared chrome (it's computed
// per app page, not carried by appPage), but the same fill the Instances
// tab's empty state shows: attachHint below.
- p.AttachCmd, p.AttachEnable = s.attachHint(r.Context(), acct.Sqid, p.AppName)
+ p.AttachCmd, p.AttachEnable = s.attachHint(r.Context(), acct.Sqid, p.AppName, "canary/<name>")
s.render(w, http.StatusOK, "app_releases", p)
}
// attachHint is the "give this channel a URL" advice shown on both the
-// Releases tab (the canary form's copy) and the Instances tab (the empty
-// state, before anything is attached) — one bucket read and one command
-// shape, so a reader who bounces between tabs sees the same door named both
-// times. Bounded and best-effort like every other store read behind an app
-// page: an unreadable instances manifest costs the concrete host shape,
-// never the page.
-func (s *Server) attachHint(ctx context.Context, sqid, app string) (cmd, enable string) {
+// Releases tab (the canary form's copy, channel "canary/<name>") and the
+// Instances tab's empty state (channel "stable" — the Running tab's empty
+// state should not be steering members onto a canary) — one bucket read and
+// one command shape per caller. Bounded and best-effort like every other
+// store read behind an app page: an unreadable instances manifest costs the
+// concrete host shape, never the page.
+func (s *Server) attachHint(ctx context.Context, sqid, app, channel string) (cmd, enable string) {
mctx, mcancel := context.WithTimeout(ctx, instanceStoreTimeout)
defer mcancel()
domain, absent := s.instanceDomainFor(mctx, sqid, app)
- return attachCommands(app, "canary/<name>", domain, s.cfg.AppsDomain, sqid, absent)
+ return attachCommands(app, channel, domain, s.cfg.AppsDomain, sqid, absent)
}
// handleAppInstances is what the app is actually running: one row per
@@ -1577,7 +1583,9 @@ func (s *Server) handleAppInstances(w http.ResponseWriter, r *http.Request) {
p.Instances = s.instances.Report(r.Context(), p.Detail.Routes)
p.AppMetrics = s.appResourceMetrics(r.Context(), p.Detail, acct)
p.InstanceRows = assembleInstanceRows(p.Detail, acct, p.AppMetrics)
- p.AttachCmd, p.AttachEnable = s.attachHint(r.Context(), acct.Sqid, p.AppName)
+ if len(p.InstanceRows) == 0 {
+ p.AttachCmd, p.AttachEnable = s.attachHint(r.Context(), acct.Sqid, p.AppName, "stable")
+ }
s.render(w, http.StatusOK, "app_instances", p)
}
@@ -2429,11 +2437,46 @@ func (s *Server) handleRetireCanary(w http.ResponseWriter, r *http.Request) {
back("ok", "Retired canary "+name+" — its channel pointer is gone; shipped releases are untouched.")
}
-// summarize folds one AppView into its "your apps" table row.
-func summarize(v AppView) appRow {
- row := appRow{App: v.App, URL: apexHost(v.Routes)}
- attention := false
+// promoteChannelOptions is the per-row promote select's option list: the
+// ladder rungs first (so a fresh app with zero channels — nothing in
+// existing — still has somewhere to promote its first release), then any of
+// the app's existing channels not already a rung (canary/* and the like),
+// in their existing order. Ladder rungs are included whether or not the app
+// has them yet — promoting onto a rung that doesn't exist is exactly how it
+// comes to exist.
+func promoteChannelOptions(existing []ChannelView) []string {
+ rungs := []string{release.RungEdge, release.RungBeta, release.RungStable}
+ seen := make(map[string]bool, len(rungs))
+ options := make([]string, 0, len(rungs)+len(existing))
+ for _, r := range rungs {
+ options = append(options, r)
+ seen[r] = true
+ }
+ for _, ch := range existing {
+ if seen[ch.Name] {
+ continue
+ }
+ seen[ch.Name] = true
+ options = append(options, ch.Name)
+ }
+ return options
+}
+
+// summarize folds one AppView into its "your apps" table row. Routes can
+// legitimately belong to another account (RouteView.Account's doc comment)
+// so both the apex-host pick and the failed-route scan are filtered to the
+// viewing account first — otherwise a foreign route can pick this app's URL
+// or trip its "needs attention" verdict.
+func summarize(v AppView, acct string) appRow {
+ var mine []RouteView
for _, rt := range v.Routes {
+ if rt.Account == acct {
+ mine = append(mine, rt)
+ }
+ }
+ row := appRow{App: v.App, URL: apexHost(mine)}
+ attention := false
+ for _, rt := range mine {
if rt.Status == "failed" {
attention = true
}
diff --git a/internal/console/server_test.go b/internal/console/server_test.go
index 14635cf41..71ad872ed 100644
--- a/internal/console/server_test.go
+++ b/internal/console/server_test.go
@@ -550,7 +550,7 @@ func TestReleasesTab(t *testing.T) {
t.Fatalf("GET releases tab = %d", status)
}
for _, want := range []string{
- `aria-current="page">Releases</a>`,
+ `aria-current="page">Versions</a>`,
"<h2>Channels</h2>", "<h2>Canaries</h2>", "All versions (",
} {
if !strings.Contains(body, want) {
@@ -568,6 +568,92 @@ func TestReleasesTab(t *testing.T) {
assertNonMember404(t, fx, "/apps/kass/releases")
}
+// TestReleaseSummaryStripRenders pins the Make-live strip's form (M6, final
+// review): an owner with beta ahead of stable gets a real promote form
+// (channel=stable, version=beta's), a non-owner member sees no form at all —
+// just the "Only an account owner can make it live." note, since the fixture
+// is keyed and CanPromote is true either way.
+func TestReleaseSummaryStripRenders(t *testing.T) {
+ fx := newConsoleFixture(t)
+ shipVersion(t, fx, "kass", "v3")
+ seedChannel(t, fx, "kass", "beta", "v3")
+
+ c := signInPaul(t, fx)
+ _, _, body := get(t, c, fx.u("/apps/kass/releases"))
+ // The hidden channel=stable input is unique to the make-live strip's form
+ // (the per-row promote forms elsewhere on this tab use a <select>, not a
+ // hidden "stable" input), so its presence pins that specific form.
+ if !strings.Contains(body, `<input type="hidden" name="channel" value="stable">`) {
+ t.Fatal("owner view missing the make-live promote form's channel=stable input")
+ }
+ if !strings.Contains(body, `<input type="hidden" name="version" value="v3">`) {
+ t.Fatalf("make-live form missing its version=v3 hidden input:\n%s", body)
+ }
+ if !strings.Contains(body, `<input type="hidden" name="csrf" value="`) {
+ t.Fatal("make-live form missing its csrf input")
+ }
+ if strings.Contains(body, "Only an account owner can make it live.") {
+ t.Fatal("owner view should not show the owner-only note")
+ }
+
+ if err := fx.st.AddMember(OpsAccountID, "kim@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ fx.setUser("kim@keymail.dev")
+ member := newBrowser(t)
+ signUp(t, fx, member, "code-kim-release-strip")
+ _, _, mBody := get(t, member, fx.u("/apps/kass/releases"))
+ if strings.Contains(mBody, `<input type="hidden" name="channel" value="stable">`) {
+ t.Fatal("member view rendered the owner-only make-live form")
+ }
+ if !strings.Contains(mBody, "Only an account owner can make it live.") {
+ t.Fatal("member view missing the owner-only note")
+ }
+}
+
+// TestFirstPromoteChannelOptions (I4, final review): a fresh app has shipped
+// a release but has zero channels yet — the per-row promote select must
+// still offer the ladder rungs, so the first promotion has somewhere to go.
+func TestFirstPromoteChannelOptions(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "freshapp", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ shipVersion(t, fx, "freshapp", "v1")
+
+ c := signInPaul(t, fx)
+ _, _, body := get(t, c, fx.u("/apps/freshapp/releases"))
+ want := `<option value="edge">edge</option><option value="beta">beta</option><option value="stable">stable</option>`
+ if !strings.Contains(body, want) {
+ t.Fatalf("promote select missing the ladder-rung options in order:\n%s", body)
+ }
+ // The now-true copy this union restores: still says "ship a release, then
+ // promote it below" even though there IS a release, because the promote
+ // control the reader needs is right there in the versions table.
+ if !strings.Contains(body, "No channels yet") {
+ t.Fatal("zero-channel app missing the empty Channels-table copy")
+ }
+}
+
+// TestInstancesEmptyStateHintsStable (I2, final review): the Running tab's
+// empty state must steer a member at "give this a URL" onto stable, not
+// canary/<name> — a canary is a private test version, the wrong first door
+// for "how do I run this at all".
+func TestInstancesEmptyStateHintsStable(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "routeless", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ c := signInPaul(t, fx)
+ _, _, body := get(t, c, fx.u("/apps/routeless/instances"))
+ if !strings.Contains(body, "-channel stable") {
+ t.Fatalf("routeless app's Running empty state missing -channel stable:\n%s", body)
+ }
+ if strings.Contains(body, "canary/") {
+ t.Fatalf("routeless app's Running empty state still points at a canary channel:\n%s", body)
+ }
+}
+
func TestInstancesTab(t *testing.T) {
fx := newConsoleFixture(t)
c := signInPaul(t, fx)
@@ -1207,7 +1293,7 @@ func TestPromotionsDisabledWithoutKey(t *testing.T) {
csrf := sessionToken(t, fx, c)
_, _, body := get(t, c, fx.u("/apps/kass"))
- if !strings.Contains(body, "Deploys are temporarily unavailable on this console.") {
+ if !strings.Contains(body, "Deploys are unavailable on this console — it has no signing key.") {
t.Fatal("keyless console is missing the disabled note")
}
// The Domains section is a read — keyless (read-only) mode still renders it.
@@ -2333,7 +2419,7 @@ func TestSummarizeVerdictAndURL(t *testing.T) {
{Name: "stable", Version: "d388035", PromotedAt: now.Add(-2 * time.Hour)},
{Name: "canary/rc-1", Version: "aaa1111"},
}}
- row := summarize(v)
+ row := summarize(v, "bab")
if row.URL != "kass.example.com" {
t.Errorf("URL = %q, want the apex host kass.example.com", row.URL)
}
@@ -2345,20 +2431,38 @@ func TestSummarizeVerdictAndURL(t *testing.T) {
}
v.Routes[0].Status = "failed"
- row = summarize(v)
+ row = summarize(v, "bab")
if row.Verdict != "attention" || row.VerdictLabel != "Needs attention" {
t.Errorf("failed route: verdict = %q/%q, want attention/Needs attention", row.Verdict, row.VerdictLabel)
}
- empty := summarize(AppView{App: "new"})
+ empty := summarize(AppView{App: "new"}, "bab")
if empty.Verdict != "notdeployed" || empty.VerdictLabel != "Not deployed yet" {
t.Errorf("no stable: verdict = %q/%q, want notdeployed/Not deployed yet", empty.Verdict, empty.VerdictLabel)
}
- bad := summarize(AppView{App: "kass", Channels: []ChannelView{{Name: "stable", Err: "unreadable"}}})
+ bad := summarize(AppView{App: "kass", Channels: []ChannelView{{Name: "stable", Err: "unreadable"}}}, "bab")
if bad.Verdict != "unknown" || bad.VerdictLabel != "Can't tell right now" {
t.Errorf("stable err: verdict = %q/%q, want unknown/Can't tell right now", bad.Verdict, bad.VerdictLabel)
}
+
+ // A foreign account's route must not draw this row's URL or trip its
+ // verdict — draws the tenant boundary summarize is supposed to enforce.
+ foreign := AppView{App: "kass",
+ Routes: []RouteView{
+ {Host: "kass.bab.oncarlos.com", Account: "bab", Kind: "instance", Status: "ready"},
+ {Host: "a.a", Account: "other", Kind: "instance", Status: "failed"},
+ },
+ Channels: []ChannelView{
+ {Name: "stable", Version: "d388035", PromotedAt: now.Add(-2 * time.Hour)},
+ }}
+ row = summarize(foreign, "bab")
+ if row.Verdict != "ok" || row.VerdictLabel != "Deployed" {
+ t.Errorf("foreign failed route leaked into verdict: %q/%q, want ok/Deployed", row.Verdict, row.VerdictLabel)
+ }
+ if row.URL != "kass.bab.oncarlos.com" {
+ t.Errorf("URL = %q, want the own-account host kass.bab.oncarlos.com, not the foreign apex-ier a.a", row.URL)
+ }
}
func TestNormalizeHost(t *testing.T) {
diff --git a/internal/console/templates/app_overview.html b/internal/console/templates/app_overview.html
index 8091f6edd..de5a45ea1 100644
--- a/internal/console/templates/app_overview.html
+++ b/internal/console/templates/app_overview.html
@@ -30,7 +30,7 @@
<a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/instances">
<h2>Machines</h2>
<p class="tile-value">{{.InstanceTotal}}</p>
- <p class="tile-detail">{{if eq .InstanceTotal 0}}none yet{{else if .MetricsUnavailable}}no data right now{{else if eq .InstanceExpected 0}}served by the platform{{else}}{{.InstanceReporting}} of {{.InstanceExpected}} responding{{if .InstanceAsleep}} · {{.InstanceAsleep}} asleep{{end}}{{end}}</p>
+ <p class="tile-detail">{{if eq .InstanceTotal 0}}none yet{{else if .MetricsUnavailable}}no data right now{{else if eq .InstanceExpected 0}}served by the platform{{else}}{{.InstanceReporting}} of {{.InstanceExpected}} processes responding{{if .InstanceAsleep}} · {{.InstanceAsleep}} asleep{{end}}{{end}}</p>
</a>
<a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/instances">
<h2>Resources</h2>
@@ -69,5 +69,5 @@
</ul>
<p class="small"><a href="{{.Nav}}/apps/{{.AppName}}/activity">All activity</a></p>
{{end}}
-{{if not .CanPromote}}<p class="small">Deploys are temporarily unavailable on this console.</p>{{end}}
+{{if not .CanPromote}}<p class="small">Deploys are unavailable on this console — it has no signing key.</p>{{end}}
{{template "bottom" .}}{{end}}
diff --git a/internal/console/templates/app_releases.html b/internal/console/templates/app_releases.html
index 80152ffc6..589f324fa 100644
--- a/internal/console/templates/app_releases.html
+++ b/internal/console/templates/app_releases.html
@@ -31,7 +31,7 @@
<input type="hidden" name="back" value="releases">
<button type="submit" class="btn">Make {{.NextVersion}} live</button>
</form>
- {{else}}
+ {{else if $.CanPromote}}
<p class="small">Only an account owner can make it live.</p>
{{end}}
{{end}}
@@ -62,7 +62,7 @@
<input type="hidden" name="version" value="{{.Version}}">
<input type="hidden" name="back" value="releases">
<select name="channel" aria-label="Channel to promote {{.Version}} to">
- {{range $.Detail.Channels}}<option value="{{.Name}}">{{.Name}}</option>{{end}}
+ {{range $.PromoteChannels}}<option value="{{.}}">{{.}}</option>{{end}}
</select>
<button type="submit" class="btn-sm">Promote</button>
</form>
@@ -171,5 +171,5 @@ carlos ship -app {{.Detail.App}} -kind static -version v1 &lt;site-dir&gt;</code
{{end}}
{{end}}
</details>
-{{if not .CanPromote}}<p class="small">Promotions disabled: no signing key on this host.</p>{{end}}
+{{if not .CanPromote}}<p class="small">Deploys are unavailable on this console — it has no signing key.</p>{{end}}
{{template "bottom" .}}{{end}}
diff --git a/internal/console/templates/appnav.html b/internal/console/templates/appnav.html
index ada05a6a0..d6696818c 100644
--- a/internal/console/templates/appnav.html
+++ b/internal/console/templates/appnav.html
@@ -5,7 +5,7 @@
</div>
<nav class="tabs" aria-label="App sections">
<a href="{{.Nav}}/apps/{{.AppName}}"{{if eq .ActiveTab "overview"}} aria-current="page"{{end}}>Overview</a>
- <a href="{{.Nav}}/apps/{{.AppName}}/releases"{{if eq .ActiveTab "releases"}} aria-current="page"{{end}}>Releases</a>
+ <a href="{{.Nav}}/apps/{{.AppName}}/releases"{{if eq .ActiveTab "releases"}} aria-current="page"{{end}}>Versions</a>
<a href="{{.Nav}}/apps/{{.AppName}}/instances"{{if eq .ActiveTab "instances"}} aria-current="page"{{end}}>Running</a>
<a href="{{.Nav}}/apps/{{.AppName}}/activity"{{if eq .ActiveTab "activity"}} aria-current="page"{{end}}>Activity</a>
<a href="{{.Nav}}/apps/{{.AppName}}/settings"{{if eq .ActiveTab "settings"}} aria-current="page"{{end}}>Settings</a>
diff --git a/internal/console/templates/apps.html b/internal/console/templates/apps.html
index ee13be58e..2189f45a4 100644
--- a/internal/console/templates/apps.html
+++ b/internal/console/templates/apps.html
@@ -22,7 +22,7 @@
<p>The account <strong>{{.Account.Name}}</strong> is ready. No apps live here yet — claim a name below and it's yours.</p>
{{end}}
{{if .TrashN}}<p class="small"><a href="{{.Nav}}/trash">Trash ({{.TrashN}})</a> — deleted apps,
- restorable for 30 days.</p>{{end}}
+ restorable for {{trashCooloffDays}} days.</p>{{end}}
<p><a class="btn" href="{{.Nav}}/apps/new">Create app</a></p>
{{template "bottom" .}}{{end}}
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html
index e64cf6af6..820d2c96a 100644
--- a/internal/console/templates/layout.html
+++ b/internal/console/templates/layout.html
@@ -9,7 +9,7 @@
--bg: #ffffff; --ink: #201d29; --muted: #6f6480;
--accent: #5b3e96; --accent-strong: #4c3380;
--accent-soft: rgba(91, 62, 150, 0.09);
- --rule: #eae7f0; --bad: #b3403a; --warn: #a6790f;
+ --rule: #eae7f0; --bad: #b3403a; --warn: #a6790f; --good: #2f7a4f;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--sans: ui-sans-serif, system-ui, -apple-system, sans-serif;
}
@@ -18,7 +18,7 @@
--bg: #16141c; --ink: #f0eef6; --muted: #a99ebd;
--accent: #b394f2; --accent-strong: #c9b2f7;
--accent-soft: rgba(179, 148, 242, 0.12);
- --rule: #2c2836; --bad: #e07b76; --warn: #d9a94a;
+ --rule: #2c2836; --bad: #e07b76; --warn: #d9a94a; --good: #5fae7f;
}
}
* { margin: 0; padding: 0; box-sizing: border-box; }
@@ -181,7 +181,7 @@
row a reader must not skim past — in the warn tone, not .chip-prod's
red: a silent instance is a problem, not the unreviewed-build outage.
ok/notdeployed/unknown are bordered facts. */
- .chip-health-ok { border: 1px solid #2f7a4f; color: #2f7a4f; }
+ .chip-health-ok { border: 1px solid var(--good); color: var(--good); }
.chip-health-attention { background: var(--warn, #a6790f); color: var(--bg); }
.chip-health-notdeployed { border: 1px solid var(--rule); color: var(--muted); }
.chip-health-unknown { border: 1px solid var(--warn, #a6790f); color: var(--warn, #a6790f); }