socle

the catalogue: hand-ruled listings with drawer-tab marks

commit 2f10e1d4cf993946ef7cdb083ccbd2a4f8f26666 · parent 65137bffd7 · browse this tree
Ximianth, 2026-10-26 23:20:57 UTC

Tree listings keep their tabular bones (scannability is the point) and
gain catalogue character: rows separate under a soft sibling of the rule
tile (same generator, quieter inks, new purpose seed -- existing rule
bytes stay pinned), and directory names carry a small hand-drawn
drawer-tab silhouette as the link's own background, so the mark is part
of the tap target.  The '..' row stays unmarked: it is navigation, not
contents.  Row hover switches to background-color so it layers under
the rule instead of replacing it.
diff --git a/chrome.go b/chrome.go
index f616807..27bf9b6 100644
--- a/chrome.go
+++ b/chrome.go
@@ -348,9 +348,17 @@ a.sha {
 }
 a.sha:hover { color: var(--accent); }
 
-/* Listing tables. */
-article table.tree td, article table.tree th { border: none; padding: .15rem .6rem; }
-article table.tree tbody tr:hover { background: var(--well); }
+/* The catalogue: listings keep their tabular bones; rows separate with soft
+   hand-rules (set in sketchCSS) and directory links carry the drawer-tab mark. */
+article table.tree { border-collapse: collapse; }
+article table.tree td, article table.tree th { border: none; padding: .3rem .6rem; }
+article table.tree tbody tr:hover { background-color: var(--well); }
+article td.dir > a {
+	background-repeat: no-repeat;
+	background-position: left center;
+	background-size: 24px 18px;
+	padding-left: 30px;
+}
 article th { text-align: left; background: none; }
 /* Table links skip the underline: in a column of links it is pure noise;
    the accent color and the row hover carry the affordance.  Hover restores it. */
@@ -399,6 +407,14 @@ ul.ledger li {
 	background-repeat: repeat-x;
 	background-size: auto 9px;
 }
+/* Catalogue rows separate with the rule's soft sibling; the tab mark rides the link. */
+article table.tree tbody tr {
+	background-image: %s;
+	background-position: bottom left;
+	background-repeat: repeat-x;
+	background-size: auto 8px;
+}
+article td.dir > a { background-image: %s; }
 a.sha {
 	border: 7px solid transparent;
 	border-image: %s 8 / 7px round;
@@ -428,7 +444,7 @@ nav.arc a.va:active { background-image: %s; }
 nav.arc a.vb:active { background-image: %s; }
 nav.arc a.vc:active { background-image: %s; }
 `,
-		u("flank"), u("ground"), u("plinth"), u("rule"), u("rule"), u("chip"), u("frame"), u("frame"),
+		u("flank"), u("ground"), u("plinth"), u("rule"), u("rule"), u("rulesoft"), u("tab"), u("chip"), u("frame"), u("frame"),
 		u("circle-a"), u("circle-b"), u("circle-c"),
 		u("circle-b"), u("circle-c"), u("circle-a"),
 		u("circle-c"), u("circle-a"), u("circle-b"))
diff --git a/pages.go b/pages.go
index 47f42b6..8df2df4 100644
--- a/pages.go
+++ b/pages.go
@@ -319,10 +319,13 @@ func (s *server) renderReadme(rp *repo, ref, sha string) (template.HTML, readmeI
 
 // ---- tree listing ----
 
+// The catalogue: listings keep their tabular bones, with light hand-ruled row
+// separators and a drawer-tab mark ahead of directory names (the mark is the
+// link's own background, so it is part of the tap target).
 var treeTmpl = template.Must(template.New("tree").Parse(`<h1>{{.Title}}</h1>
 <table class="tree">
 <tbody>
-{{range .Rows}}<tr><td class="name">{{if .Href}}<a href="{{.Href}}">{{.Name}}</a>{{else}}{{.Name}}{{end}}</td><td class="size">{{.Size}}</td></tr>
+{{range .Rows}}<tr><td class="name{{if .Dir}} dir{{end}}">{{if .Href}}<a href="{{.Href}}">{{.Name}}</a>{{else}}{{.Name}}{{end}}</td><td class="size">{{.Size}}</td></tr>
 {{end}}</tbody>
 </table>
 `))
@@ -331,6 +334,7 @@ type treeRow struct {
 	Name string
 	Href string // empty renders unlinked (submodules)
 	Size string // blob size in bytes; empty for non-blobs
+	Dir  bool   // directories wear the drawer-tab mark; the ".." row does not (it is nav, not contents)
 }
 
 type treeData struct {
@@ -365,6 +369,7 @@ func (s *server) serveTreeListing(w http.ResponseWriter, rp *repo, ref, sha, pth
 		case "tree":
 			row.Name += "/"
 			row.Href = treeHref(rp.name, ref, full) + "/"
+			row.Dir = true
 		case "blob":
 			row.Href = treeHref(rp.name, ref, full)
 			row.Size = humanSize(e.size)
diff --git a/sketch.go b/sketch.go
index 443bf4d..502022b 100644
--- a/sketch.go
+++ b/sketch.go
@@ -135,11 +135,13 @@ func sketchAssets(seed string) map[string][]byte {
 	m := map[string][]byte{}
 	for _, p := range sketchPalettes {
 		m["frame-"+p.scheme+".svg"] = frameSVG(seed, p)
-		m["rule-"+p.scheme+".svg"] = ruleSVG(seed, p)
+		m["rule-"+p.scheme+".svg"] = ruleSVG(seed, "rule", p, 0.7, 0.25)
+		m["rulesoft-"+p.scheme+".svg"] = ruleSVG(seed, "rulesoft", p, 0.4, 0.15)
 		m["flank-"+p.scheme+".svg"] = flankSVG(seed, p)
 		m["ground-"+p.scheme+".svg"] = groundSVG(seed, p)
 		m["plinth-"+p.scheme+".svg"] = plinthSVG(seed, p)
 		m["chip-"+p.scheme+".svg"] = chipSVG(seed, p)
+		m["tab-"+p.scheme+".svg"] = tabSVG(seed, p)
 		for _, v := range []string{"a", "b", "c"} {
 			m["circle-"+v+"-"+p.scheme+".svg"] = circleSVG(seed+"/"+v, p)
 		}
@@ -188,11 +190,11 @@ func frameSVG(seed string, p sketchPalette) []byte {
 	return []byte(sb.String())
 }
 
-// ruleSVG is a wide, thin tile holding one hand-ruled horizontal line,
-// for border-images on nav and heading underlines.  Displacement is zero at both
-// tile ends so repeat tiles cleanly.
-func ruleSVG(seed string, p sketchPalette) []byte {
-	rng := newSketchRand(seed + "/rule/" + p.scheme)
+// ruleSVG is a wide, thin tile holding one hand-ruled horizontal line, for nav and
+// heading underlines (full-strength opacities) and row separators (the soft ones).
+// Displacement is zero at both tile ends so repeat tiles cleanly.
+func ruleSVG(seed, purpose string, p sketchPalette, opacity, ghostOpacity float64) []byte {
+	rng := newSketchRand(seed + "/" + purpose + "/" + p.scheme)
 	var sb strings.Builder
 	svgOpen(&sb, 480, 12)
 	anchorRow := func(amp float64) []pt {
@@ -205,8 +207,8 @@ func ruleSVG(seed string, p sketchPalette) []byte {
 	var d1, d2 strings.Builder
 	wobblePath(&d1, rng, anchorRow(1.1), 1.1)
 	wobblePath(&d2, rng, anchorRow(1.5), 1.5)
-	svgPath(&sb, d1.String(), p.ink, 1.8, 0.7)
-	svgPath(&sb, d2.String(), p.ink, 1.3, 0.25)
+	svgPath(&sb, d1.String(), p.ink, 1.8, opacity)
+	svgPath(&sb, d2.String(), p.ink, 1.3, ghostOpacity)
 	sb.WriteString("</svg>\n")
 	return []byte(sb.String())
 }
@@ -365,3 +367,26 @@ func chipSVG(seed string, p sketchPalette) []byte {
 	sb.WriteString("</svg>\n")
 	return []byte(sb.String())
 }
+
+// tabSVG is the catalogue's drawer-tab mark: a small folder silhouette standing
+// ahead of directory names in tree listings, wordless and self-explanatory.
+func tabSVG(seed string, p sketchPalette) []byte {
+	rng := newSketchRand(seed + "/tab/" + p.scheme)
+	var sb strings.Builder
+	svgOpen(&sb, 24, 18)
+	outline := func(d *strings.Builder, amp, over float64) {
+		roughLine(d, rng, pt{2, 15}, pt{2, 4}, amp, over)
+		roughLine(d, rng, pt{2, 4}, pt{9, 4}, amp, over)
+		roughLine(d, rng, pt{9, 4}, pt{11.5, 7}, amp, over)
+		roughLine(d, rng, pt{11.5, 7}, pt{22, 7}, amp, over)
+		roughLine(d, rng, pt{22, 7}, pt{22, 15}, amp, over)
+		roughLine(d, rng, pt{22, 15}, pt{2, 15}, amp, over)
+	}
+	var d1, d2 strings.Builder
+	outline(&d1, 0.6, 1.2)
+	outline(&d2, 0.9, 1.5)
+	svgPath(&sb, d1.String(), p.ink, 1.3, 0.7)
+	svgPath(&sb, d2.String(), p.ink, 1.0, 0.26)
+	sb.WriteString("</svg>\n")
+	return []byte(sb.String())
+}
diff --git a/testdata/pages.md b/testdata/pages.md
index 3b749bb..cc90aca 100644
--- a/testdata/pages.md
+++ b/testdata/pages.md
@@ -151,9 +151,9 @@ Content-Type: text/html; charset=utf-8
 <main><article><h1>fixturama</h1>
 <table class="tree">
 <tbody>
-<tr><td class="name"><a href="/fixturama/tree/main/docs/">docs/</a></td><td class="size"></td></tr>
-<tr><td class="name"><a href="/fixturama/tree/main/src/">src/</a></td><td class="size"></td></tr>
-<tr><td class="name"><a href="/fixturama/tree/main/vendor/">vendor/</a></td><td class="size"></td></tr>
+<tr><td class="name dir"><a href="/fixturama/tree/main/docs/">docs/</a></td><td class="size"></td></tr>
+<tr><td class="name dir"><a href="/fixturama/tree/main/src/">src/</a></td><td class="size"></td></tr>
+<tr><td class="name dir"><a href="/fixturama/tree/main/vendor/">vendor/</a></td><td class="size"></td></tr>
 <tr><td class="name"><a href="/fixturama/tree/main/.gitignore">.gitignore</a></td><td class="size">7 B</td></tr>
 <tr><td class="name"><a href="/fixturama/tree/main/README.md">README.md</a></td><td class="size">49 B</td></tr>
 </tbody>
diff --git a/testdata/sketch.md b/testdata/sketch.md
index 8ee5cdf..6f4dada 100644
--- a/testdata/sketch.md
+++ b/testdata/sketch.md
@@ -66,6 +66,26 @@ Regenerate with `go test -run TestSketchSnapshots -testmark.regen` and review th
 </svg>
 ```
 
+### rulesoft-light.svg
+
+[testmark]:# (rulesoft-light.svg)
+```svg
+<svg xmlns="http://www.w3.org/2000/svg" width="480" height="12" viewBox="0 0 480 12">
+<path d="M0.0 6.0C30.9 5.7 61.9 5.9 92.8 6.0C124.1 6.0 155.4 6.6 186.7 6.4C218.8 6.1 250.9 7.1 283.0 5.9C316.6 4.9 350.3 4.9 383.9 5.8C416.0 6.0 448.0 7.0 480.0 6.0" fill="none" stroke="#24292f" stroke-width="1.8" stroke-opacity="0.40" stroke-linecap="round"/>
+<path d="M0.0 6.0C32.9 5.9 65.8 5.5 98.6 4.6C129.5 4.2 160.3 3.9 191.1 5.2C221.1 5.4 251.2 4.2 281.2 5.1C316.2 4.9 351.2 7.0 386.3 6.9C417.5 6.2 448.8 5.9 480.0 6.0" fill="none" stroke="#24292f" stroke-width="1.3" stroke-opacity="0.15" stroke-linecap="round"/>
+</svg>
+```
+
+### tab-light.svg
+
+[testmark]:# (tab-light.svg)
+```svg
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="18" viewBox="0 0 24 18">
+<path d="M2.1 15.5C1.7 11.5 2.1 7.6 2.1 3.6M0.7 4.2C3.7 4.3 6.7 4.3 9.7 3.9M8.1 3.1C9.2 4.9 10.9 6.1 12.2 7.7M10.8 7.0C14.6 7.3 18.5 6.7 22.3 7.0M22.2 6.0C21.6 9.2 22.0 12.5 22.0 15.7M23.0 15.3C15.8 15.5 8.6 15.3 1.3 14.8" fill="none" stroke="#24292f" stroke-width="1.3" stroke-opacity="0.70" stroke-linecap="round"/>
+<path d="M1.6 15.4C1.5 11.3 1.4 7.2 2.4 3.2M1.5 3.6C4.3 3.3 7.0 4.4 9.8 3.6M7.9 2.9C8.4 4.8 10.4 5.3 11.3 6.9M10.3 7.1C14.5 7.1 18.7 6.6 22.9 7.2M22.3 5.5C22.7 9.2 21.5 12.7 21.7 16.4M22.4 14.9C15.4 15.4 8.4 15.3 1.3 15.1" fill="none" stroke="#24292f" stroke-width="1.0" stroke-opacity="0.26" stroke-linecap="round"/>
+</svg>
+```
+
 ### circle-a-light.svg
 
 [testmark]:# (circle-a-light.svg)
@@ -157,6 +177,26 @@ Regenerate with `go test -run TestSketchSnapshots -testmark.regen` and review th
 </svg>
 ```
 
+### rulesoft-dark.svg
+
+[testmark]:# (rulesoft-dark.svg)
+```svg
+<svg xmlns="http://www.w3.org/2000/svg" width="480" height="12" viewBox="0 0 480 12">
+<path d="M0.0 6.0C33.9 6.6 67.9 6.1 101.8 6.2C131.3 6.6 160.9 6.5 190.4 6.2C223.6 6.0 256.8 6.2 290.1 6.5C320.2 5.8 350.2 6.6 380.3 6.6C413.6 6.2 446.8 6.7 480.0 6.0" fill="none" stroke="#e6edf3" stroke-width="1.8" stroke-opacity="0.40" stroke-linecap="round"/>
+<path d="M0.0 6.0C33.8 7.0 67.6 5.2 101.4 5.9C130.9 5.1 160.4 4.2 190.0 5.0C220.9 5.0 251.9 4.4 282.8 5.4C314.0 6.2 345.2 4.7 376.3 5.5C410.9 5.4 445.4 5.9 480.0 6.0" fill="none" stroke="#e6edf3" stroke-width="1.3" stroke-opacity="0.15" stroke-linecap="round"/>
+</svg>
+```
+
+### tab-dark.svg
+
+[testmark]:# (tab-dark.svg)
+```svg
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="18" viewBox="0 0 24 18">
+<path d="M2.1 16.3C1.8 12.0 1.3 7.7 1.7 3.3M0.7 4.2C3.8 3.8 6.9 4.8 10.0 4.2M8.2 3.2C9.8 4.2 10.6 6.0 12.0 7.3M11.3 6.8C15.0 6.5 18.6 6.9 22.3 7.0M22.2 7.0C22.7 9.9 21.9 12.8 22.1 15.7M22.3 15.1C15.2 14.8 8.1 14.5 0.9 14.7" fill="none" stroke="#e6edf3" stroke-width="1.3" stroke-opacity="0.70" stroke-linecap="round"/>
+<path d="M2.3 15.9C1.9 11.4 1.8 6.9 1.9 2.3M1.3 4.4C3.9 3.5 6.7 4.6 9.2 3.6M7.9 3.4C8.8 4.9 10.1 6.0 11.5 7.0M11.0 7.1C15.0 6.5 19.0 7.4 23.0 6.6M22.1 6.2C21.9 9.5 22.7 12.8 21.9 16.0M22.4 14.8C15.1 15.5 7.8 15.5 0.5 15.2" fill="none" stroke="#e6edf3" stroke-width="1.0" stroke-opacity="0.26" stroke-linecap="round"/>
+</svg>
+```
+
 ### circle-a-dark.svg
 
 [testmark]:# (circle-a-dark.svg)