socle

the language meter stands on the repo home

commit 86d336b1486bd91ef7b30d4d38a70a5ce1f80d7d · parent 370d8f1c9f · browse this tree
Ximianth, 2026-10-26 23:03:42 UTC

A segmented bar in a rough box between the plaque and the clone line: top
languages by bytes at the shown commit, the tail (and any share that rounds
below a visible 0.1%) folded into a neutral "other".  Linguist's conventional
hues color the segments and the legend's swatches -- swatches only, never text
contrast; names and percentages stay ink, museum-label sized.  A faint inset
ring keeps pale swatches from dissolving into the paper in either scheme.
diff --git a/chrome.go b/chrome.go
index c291707..51a9152 100644
--- a/chrome.go
+++ b/chrome.go
@@ -200,6 +200,47 @@ article .tagline {
 article p.clone, article #branches { text-align: center; margin-left: auto; margin-right: auto; }
 article p.clone.boxed { display: block; width: fit-content; }
 
+/* The language meter: composition by bytes at the shown commit, in its rough box
+   between the plaque and the clone line.  Colors are linguist's conventions, used as
+   swatches only; names and percentages stay ink.  The faint inset ring keeps pale
+   swatches from dissolving into the paper (or the dark ground). */
+article .langmeter {
+	display: block;
+	width: fit-content;
+	max-width: 100%;
+	margin: 0 auto 1.4rem;
+}
+.langmeter .langbar {
+	display: flex;
+	gap: 3px;
+	width: 28rem;
+	max-width: 100%;
+	height: 12px;
+}
+.langmeter .langbar .seg {
+	flex-basis: 0;
+	min-width: 2px;
+	box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ink) 25%, transparent);
+}
+.langmeter .langchips {
+	margin: .6rem 0 0;
+	font-size: .78rem;
+	letter-spacing: .08em;
+	color: var(--ink-soft);
+	text-align: center;
+}
+.langmeter .chip { white-space: nowrap; }
+.langmeter .chip + .chip { margin-left: .9rem; }
+.langmeter .swatch {
+	display: inline-block;
+	width: 10px;
+	height: 10px;
+	margin-right: .35em;
+	border-radius: 2px;
+	box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ink) 25%, transparent);
+}
+.langmeter .seg.other, .langmeter .swatch.other { background: var(--ink-soft); }
+
 /* The plaque: museum-label typography for the metadata line under an exhibit. */
 .plaque {
 	text-align: center;
diff --git a/pages.go b/pages.go
index 0396928..55f32c2 100644
--- a/pages.go
+++ b/pages.go
@@ -11,6 +11,8 @@ import (
 	"sort"
 	"strings"
 
+	"github.com/go-enry/go-enry/v2"
+
 	"vitrine/display"
 )
 
@@ -124,14 +126,15 @@ var homeTmpl = template.Must(template.New("home").Parse(`{{if .Description}}<p c
 <p class="clone" id="clone">clone: <code>git clone {{.CloneURL}}</code></p>
 {{else}}{{if .Readme}}<div class="exhibit">{{.Readme}}</div>
 <p class="plaque">{{.ReadmeName}} · {{.ReadmeSize}} · {{.Ref}} = <code>{{.ShortSha}}</code></p>
+{{end}}{{if .Langs}}<div class="langmeter boxed">
+<div class="langbar">{{range .Langs}}<span class="seg{{if not .Color}} other{{end}}" style="flex-grow:{{.Pct}}{{if .Color}};background:{{.Color}}{{end}}" title="{{.Name}} {{.Pct}}%"></span>{{end}}</div>
+<p class="langchips">{{range .Langs}}<span class="chip"><span class="swatch{{if not .Color}} other{{end}}"{{if .Color}} style="background:{{.Color}}"{{end}}></span>{{.Name}} {{.Pct}}%</span>{{end}}</p>
+</div>
 {{end}}<p class="clone boxed" id="clone">clone: <code>git clone {{.CloneURL}}</code></p>
 <p id="branches">branches: {{range $i, $b := .Branches}}{{if $i}}, {{end}}<a href="{{$b.Href}}">{{$b.Name}}</a>{{end}}{{if .Tags}}
 · tags: {{range $i, $t := .Tags}}{{if $i}}, {{end}}<a href="{{$t.Href}}">{{$t.Name}}</a>{{end}}{{end}}</p>
 {{end}}`))
 
-// The language meter will stand on the home page in a later round:
-// a segmented bar in a rough frame with a chip legend, between the plaque and the clone box.
-
 type refLink struct {
 	Name string
 	Href string
@@ -148,6 +151,46 @@ type homeData struct {
 	ReadmeSize  string // human-readable; the plaque is a label, not a listing
 	Ref         string
 	ShortSha    string
+	Langs       []langSeg
+}
+
+// meterMax caps the bar's segments; everything past it folds into "other".
+const meterMax = 8
+
+// langSeg is one segment of the language meter, as the template wants it.
+type langSeg struct {
+	Name  string
+	Pct   string // share of counted bytes, one decimal; doubles as the segment's flex-grow
+	Color string // linguist's conventional hex, a swatch only; empty wears the neutral class
+}
+
+// langSegments shapes language stats for the meter: the top languages by bytes,
+// with the tail -- and anything whose share rounds below a visible 0.1% -- folded
+// into one trailing "other" segment.  No classified bytes at all means no meter.
+func langSegments(stats []langStat) []langSeg {
+	var total int64
+	for _, st := range stats {
+		total += st.bytes
+	}
+	if total == 0 {
+		return nil
+	}
+	pct := func(bytes int64) string {
+		return fmt.Sprintf("%.1f", float64(bytes)*100/float64(total))
+	}
+	var segs []langSeg
+	var other int64
+	for i, st := range stats {
+		if i >= meterMax-1 && len(stats) > meterMax || pct(st.bytes) == "0.0" {
+			other += st.bytes
+			continue
+		}
+		segs = append(segs, langSeg{Name: st.name, Pct: pct(st.bytes), Color: enry.GetColor(st.name)})
+	}
+	if other > 0 {
+		segs = append(segs, langSeg{Name: "other", Pct: pct(other)})
+	}
+	return segs
 }
 
 func (s *server) serveHome(w http.ResponseWriter, r *http.Request, rp *repo) {
@@ -182,6 +225,12 @@ func (s *server) serveHome(w http.ResponseWriter, r *http.Request, rp *repo) {
 			return
 		}
 		d.ReadmeName, d.ReadmeSize, d.ShortSha = rm.name, humanSize(rm.size), shortSha(sha)
+		stats, err := s.langStats(rp, sha)
+		if err != nil {
+			httpError(w, err)
+			return
+		}
+		d.Langs = langSegments(stats)
 	case errors.Is(err, errNotFound):
 		d.Empty = true // an unborn HEAD: a freshly-init'd repo is fine to show, just bare
 	default:
diff --git a/pages_test.go b/pages_test.go
index 8d8ee42..0993c5b 100644
--- a/pages_test.go
+++ b/pages_test.go
@@ -88,3 +88,41 @@ func TestHumanSize(t *testing.T) {
 		}
 	}
 }
+
+// TestLangSegments pins the meter's shaping: percentages, color lookup, the fold of the
+// tail and of invisible shares into "other", and the no-classified-bytes case.
+func TestLangSegments(t *testing.T) {
+	t.Parallel()
+	t.Run("empty stats make no meter", func(t *testing.T) {
+		if segs := langSegments(nil); segs != nil {
+			t.Errorf("segments = %+v, want none", segs)
+		}
+	})
+	t.Run("plain shares with linguist colors", func(t *testing.T) {
+		segs := langSegments([]langStat{{"Go", 750}, {"Rust", 250}})
+		want := []langSeg{{"Go", "75.0", "#00ADD8"}, {"Rust", "25.0", "#dea584"}}
+		if len(segs) != len(want) || segs[0] != want[0] || segs[1] != want[1] {
+			t.Errorf("segments = %+v, want %+v", segs, want)
+		}
+	})
+	t.Run("the tail folds into other", func(t *testing.T) {
+		stats := make([]langStat, 12)
+		for i := range stats {
+			stats[i] = langStat{string(rune('A' + i)), int64(1200 - 100*i)}
+		}
+		segs := langSegments(stats)
+		if len(segs) != meterMax {
+			t.Fatalf("got %d segments (%+v), want %d", len(segs), segs, meterMax)
+		}
+		last := segs[meterMax-1]
+		if last.Name != "other" || last.Color != "" {
+			t.Errorf("last segment = %+v, want the neutral other", last)
+		}
+	})
+	t.Run("invisible shares fold into other", func(t *testing.T) {
+		segs := langSegments([]langStat{{"Go", 100000}, {"Rust", 10}})
+		if len(segs) != 2 || segs[1].Name != "other" {
+			t.Fatalf("segments = %+v, want Go and other", segs)
+		}
+	})
+}
diff --git a/testdata/pages.md b/testdata/pages.md
index fc9f989..7356d41 100644
--- a/testdata/pages.md
+++ b/testdata/pages.md
@@ -94,6 +94,10 @@ Content-Type: text/html; charset=utf-8
 <p>A repo that exists to be looked at.</p>
 </div>
 <p class="plaque">README.md · 49 B · main = <code>ced91a8519</code></p>
+<div class="langmeter boxed">
+<div class="langbar"><span class="seg" style="flex-grow:69.9;background:#00ADD8" title="Go 69.9%"></span><span class="seg" style="flex-grow:20.3;background:#dea584" title="Rust 20.3%"></span><span class="seg" style="flex-grow:9.9;background:#663399" title="CSS 9.9%"></span></div>
+<p class="langchips"><span class="chip"><span class="swatch" style="background:#00ADD8"></span>Go 69.9%</span><span class="chip"><span class="swatch" style="background:#dea584"></span>Rust 20.3%</span><span class="chip"><span class="swatch" style="background:#663399"></span>CSS 9.9%</span></p>
+</div>
 <p class="clone boxed" id="clone">clone: <code>git clone http://socle.example/fixturama</code></p>
 <p id="branches">branches: <a href="/fixturama/tree/dev/">dev</a>, <a href="/fixturama/tree/main/">main</a>
 · tags: <a href="/fixturama/tree/v1.0/">v1.0</a></p>