detached 79e542a

feat(web): even home cards, one-line topics, teal accent (#66)

commit
79e542a4b7a364f1bbadb0b42151ab0ab4d836b4
author
brice · · 2026-10-18 14:22 UTC
committer
bnema · · 2026-10-18 14:22 UTC
parent
166837a
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
12 files · +626 -365
M internal/adapters/web/components.templ +18 -15
@@ -54,14 +54,18 @@ templ HomeRowView(r HomeRow) {
5454 <p class="repo-desc mute">no description</p>
5555 }
5656 if len(r.Topics) > 0 {
57- <ul class="repo-topics" aria-label={ "Topics of " + r.Name }>
57+ <ul class="repo-topics" aria-label={ "Topics of " + r.Name } data-fit>
5858 for _, t := range r.Topics {
5959 <li>{ t }</li>
6060 }
6161 </ul>
62+ } else {
63+ <div class="repo-topics" aria-hidden="true"></div>
6264 }
6365 if r.Stats != nil && r.Stats.Pulse != nil {
6466 @pulse(r.Stats.Pulse)
67+ } else {
68+ <div class="repo-pulse" aria-hidden="true"></div>
6569 }
6670 </div>
6771 <div class="repo-head">
@@ -69,16 +73,6 @@ templ HomeRowView(r HomeRow) {
6973 if s := r.Stats; s != nil {
7074 <div><dt>commits</dt><dd>{ s.Commits }</dd></div>
7175 <div><dt>last 30 days</dt><dd>{ s.Recent }</dd></div>
72- if s.Tag != "" {
73- <div class="rs-tag">
74- <dt>latest tag</dt>
75- if s.TagHref != "" {
76- <dd><a href={ templ.SafeURL(s.TagHref) } title={ s.Tag + " · made " + s.TagWhen }>{ s.Tag }</a></dd>
77- } else {
78- <dd title={ s.Tag + " · made " + s.TagWhen }>{ s.Tag }</dd>
79- }
80- </div>
81- }
8276 }
8377 <div>
8478 <dt>last activity</dt>
@@ -88,6 +82,16 @@ templ HomeRowView(r HomeRow) {
8882 <dd class="mute">never</dd>
8983 }
9084 </div>
85+ if s := r.Stats; s != nil && s.Tag != "" {
86+ <div class="rs-tag">
87+ <dt>last tag</dt>
88+ if s.TagHref != "" {
89+ <dd><a href={ templ.SafeURL(s.TagHref) } title={ s.Tag + " · made " + s.TagWhen }>{ s.Tag }</a></dd>
90+ } else {
91+ <dd title={ s.Tag + " · made " + s.TagWhen }>{ s.Tag }</dd>
92+ }
93+ </div>
94+ }
9195 </dl>
9296 if r.CI != nil {
9397 <a class="repo-ci" href={ templ.SafeURL(r.CI.Href) } title={ r.CI.Title }>
@@ -119,8 +123,9 @@ templ HomeRowView(r HomeRow) {
119123 }
120124
121125 // pulse is the activity curve of a home row: a smooth line over a fading
122-// area on a faint grid, and a dot at today that beats while the repository
123-// is active. The SVG scales evenly with its column, so the dot stays round.
126+// area on a faint grid, and a dot at today, in colour while the repository
127+// is active. Nothing moves: the page stays idle. The SVG scales evenly with
128+// its column, so the dot stays round.
124129 templ pulse(h *Heartbeat) {
125130 <figure class="repo-pulse" data-live?={ h.Live }>
126131 <svg viewBox="0 0 600 64" role="img" aria-label={ h.Label } focusable="false">
@@ -133,10 +138,8 @@ templ pulse(h *Heartbeat) {
133138 <path class="pulse-grid" d="M0 21.5 H600 M0 42.5 H600 M0 63.5 H600" vector-effect="non-scaling-stroke"></path>
134139 <path class="pulse-area" d={ h.Area } fill={ "url(#" + h.GradID + ")" }></path>
135140 <path class="pulse-line" d={ h.Line } vector-effect="non-scaling-stroke"></path>
136- <circle class="pulse-ring" cx={ h.X } cy={ h.Y } r="4"></circle>
137141 <circle class="pulse-dot" cx={ h.X } cy={ h.Y } r="3.5"></circle>
138142 </svg>
139- <figcaption class="pulse-cap" aria-hidden="true"><span>90 days ago</span><span>today</span></figcaption>
140143 </figure>
141144 }
142145
@@ -54,14 +54,18 @@ templ HomeRowView(r HomeRow) {
54 <p class="repo-desc mute">no description</p>54 <p class="repo-desc mute">no description</p>
55 }55 }
56 if len(r.Topics) > 0 {56 if len(r.Topics) > 0 {
57- <ul class="repo-topics" aria-label={ "Topics of " + r.Name }>57+ <ul class="repo-topics" aria-label={ "Topics of " + r.Name } data-fit>
58 for _, t := range r.Topics {58 for _, t := range r.Topics {
59 <li>{ t }</li>59 <li>{ t }</li>
60 }60 }
61 </ul>61 </ul>
62+ } else {
63+ <div class="repo-topics" aria-hidden="true"></div>
62 }64 }
63 if r.Stats != nil && r.Stats.Pulse != nil {65 if r.Stats != nil && r.Stats.Pulse != nil {
64 @pulse(r.Stats.Pulse)66 @pulse(r.Stats.Pulse)
67+ } else {
68+ <div class="repo-pulse" aria-hidden="true"></div>
65 }69 }
66 </div>70 </div>
67 <div class="repo-head">71 <div class="repo-head">
@@ -69,16 +73,6 @@ templ HomeRowView(r HomeRow) {
69 if s := r.Stats; s != nil {73 if s := r.Stats; s != nil {
70 <div><dt>commits</dt><dd>{ s.Commits }</dd></div>74 <div><dt>commits</dt><dd>{ s.Commits }</dd></div>
71 <div><dt>last 30 days</dt><dd>{ s.Recent }</dd></div>75 <div><dt>last 30 days</dt><dd>{ s.Recent }</dd></div>
72- if s.Tag != "" {
73- <div class="rs-tag">
74- <dt>latest tag</dt>
75- if s.TagHref != "" {
76- <dd><a href={ templ.SafeURL(s.TagHref) } title={ s.Tag + " · made " + s.TagWhen }>{ s.Tag }</a></dd>
77- } else {
78- <dd title={ s.Tag + " · made " + s.TagWhen }>{ s.Tag }</dd>
79- }
80- </div>
81- }
82 }76 }
83 <div>77 <div>
84 <dt>last activity</dt>78 <dt>last activity</dt>
@@ -88,6 +82,16 @@ templ HomeRowView(r HomeRow) {
88 <dd class="mute">never</dd>82 <dd class="mute">never</dd>
89 }83 }
90 </div>84 </div>
85+ if s := r.Stats; s != nil && s.Tag != "" {
86+ <div class="rs-tag">
87+ <dt>last tag</dt>
88+ if s.TagHref != "" {
89+ <dd><a href={ templ.SafeURL(s.TagHref) } title={ s.Tag + " · made " + s.TagWhen }>{ s.Tag }</a></dd>
90+ } else {
91+ <dd title={ s.Tag + " · made " + s.TagWhen }>{ s.Tag }</dd>
92+ }
93+ </div>
94+ }
91 </dl>95 </dl>
92 if r.CI != nil {96 if r.CI != nil {
93 <a class="repo-ci" href={ templ.SafeURL(r.CI.Href) } title={ r.CI.Title }>97 <a class="repo-ci" href={ templ.SafeURL(r.CI.Href) } title={ r.CI.Title }>
@@ -119,8 +123,9 @@ templ HomeRowView(r HomeRow) {
119 }123 }
120 124
121 // pulse is the activity curve of a home row: a smooth line over a fading125 // pulse is the activity curve of a home row: a smooth line over a fading
122-// area on a faint grid, and a dot at today that beats while the repository126+// area on a faint grid, and a dot at today, in colour while the repository
123-// is active. The SVG scales evenly with its column, so the dot stays round.127+// is active. Nothing moves: the page stays idle. The SVG scales evenly with
128+// its column, so the dot stays round.
124 templ pulse(h *Heartbeat) {129 templ pulse(h *Heartbeat) {
125 <figure class="repo-pulse" data-live?={ h.Live }>130 <figure class="repo-pulse" data-live?={ h.Live }>
126 <svg viewBox="0 0 600 64" role="img" aria-label={ h.Label } focusable="false">131 <svg viewBox="0 0 600 64" role="img" aria-label={ h.Label } focusable="false">
@@ -133,10 +138,8 @@ templ pulse(h *Heartbeat) {
133 <path class="pulse-grid" d="M0 21.5 H600 M0 42.5 H600 M0 63.5 H600" vector-effect="non-scaling-stroke"></path>138 <path class="pulse-grid" d="M0 21.5 H600 M0 42.5 H600 M0 63.5 H600" vector-effect="non-scaling-stroke"></path>
134 <path class="pulse-area" d={ h.Area } fill={ "url(#" + h.GradID + ")" }></path>139 <path class="pulse-area" d={ h.Area } fill={ "url(#" + h.GradID + ")" }></path>
135 <path class="pulse-line" d={ h.Line } vector-effect="non-scaling-stroke"></path>140 <path class="pulse-line" d={ h.Line } vector-effect="non-scaling-stroke"></path>
136- <circle class="pulse-ring" cx={ h.X } cy={ h.Y } r="4"></circle>
137 <circle class="pulse-dot" cx={ h.X } cy={ h.Y } r="3.5"></circle>141 <circle class="pulse-dot" cx={ h.X } cy={ h.Y } r="3.5"></circle>
138 </svg>142 </svg>
139- <figcaption class="pulse-cap" aria-hidden="true"><span>90 days ago</span><span>today</span></figcaption>
140 </figure>143 </figure>
141 }144 }
142 145
M internal/adapters/web/components_templ.go +295 -306 generated file: collapsed
M internal/adapters/web/home_test.go +12 -1
@@ -80,10 +80,21 @@ func TestHomeRowFigures(t *testing.T) {
8080 assert.Contains(t, out, `<a href="/proj/tree/`+head.String()+`" title="v1.2.0 · made 3h ago">v1.2.0</a>`, "by its commit: a branch may share its name")
8181 assert.Contains(t, out, "<dt>last activity</dt>")
8282 assert.NotContains(t, out, "i-branch", "the default branch is not shown")
83- assert.Contains(t, out, `<ul class="repo-topics" aria-label="Topics of proj"><li>go</li><li>git</li></ul>`)
83+ assert.Contains(t, out, `<ul class="repo-topics" aria-label="Topics of proj" data-fit><li>go</li><li>git</li></ul>`)
8484 assert.Contains(t, out, `role="img" aria-label="Activity: 0 commits in the last 90 days"`)
8585 assert.Contains(t, out, `<linearGradient id="hb-proj"`)
86+ assert.NotContains(t, out, "pulse-ring", "nothing moves")
87+ assert.NotContains(t, out, "90 days ago")
8688 assert.NotContains(t, out, "pushed")
89+ // The tag comes last, after the last activity.
90+ assert.Regexp(t, `<dt>last activity</dt>.*<dt>last tag</dt>`, out)
91+
92+ // Without topics nor figures, the row keeps their room: every card has the
93+ // same height.
94+ bare := renderRow(t, HomeRow{ID: "repo-bare", Name: "bare", Href: "/bare"})
95+ assert.Contains(t, bare, `<div class="repo-topics" aria-hidden="true"></div>`)
96+ assert.Contains(t, bare, `<div class="repo-pulse" aria-hidden="true"></div>`)
97+ assert.NotContains(t, bare, "last tag")
8798
8899 // Past the cap of the walk, the figures are lower bounds.
89100 browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo, head, now).Return(ports.RepoStats{
@@ -80,10 +80,21 @@ func TestHomeRowFigures(t *testing.T) {
80 assert.Contains(t, out, `<a href="/proj/tree/`+head.String()+`" title="v1.2.0 · made 3h ago">v1.2.0</a>`, "by its commit: a branch may share its name")80 assert.Contains(t, out, `<a href="/proj/tree/`+head.String()+`" title="v1.2.0 · made 3h ago">v1.2.0</a>`, "by its commit: a branch may share its name")
81 assert.Contains(t, out, "<dt>last activity</dt>")81 assert.Contains(t, out, "<dt>last activity</dt>")
82 assert.NotContains(t, out, "i-branch", "the default branch is not shown")82 assert.NotContains(t, out, "i-branch", "the default branch is not shown")
83- assert.Contains(t, out, `<ul class="repo-topics" aria-label="Topics of proj"><li>go</li><li>git</li></ul>`)83+ assert.Contains(t, out, `<ul class="repo-topics" aria-label="Topics of proj" data-fit><li>go</li><li>git</li></ul>`)
84 assert.Contains(t, out, `role="img" aria-label="Activity: 0 commits in the last 90 days"`)84 assert.Contains(t, out, `role="img" aria-label="Activity: 0 commits in the last 90 days"`)
85 assert.Contains(t, out, `<linearGradient id="hb-proj"`)85 assert.Contains(t, out, `<linearGradient id="hb-proj"`)
86+ assert.NotContains(t, out, "pulse-ring", "nothing moves")
87+ assert.NotContains(t, out, "90 days ago")
86 assert.NotContains(t, out, "pushed")88 assert.NotContains(t, out, "pushed")
89+ // The tag comes last, after the last activity.
90+ assert.Regexp(t, `<dt>last activity</dt>.*<dt>last tag</dt>`, out)
91+
92+ // Without topics nor figures, the row keeps their room: every card has the
93+ // same height.
94+ bare := renderRow(t, HomeRow{ID: "repo-bare", Name: "bare", Href: "/bare"})
95+ assert.Contains(t, bare, `<div class="repo-topics" aria-hidden="true"></div>`)
96+ assert.Contains(t, bare, `<div class="repo-pulse" aria-hidden="true"></div>`)
97+ assert.NotContains(t, bare, "last tag")
87 98
88 // Past the cap of the walk, the figures are lower bounds.99 // Past the cap of the walk, the figures are lower bounds.
89 browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo, head, now).Return(ports.RepoStats{100 browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo, head, now).Return(ports.RepoStats{
A internal/adapters/web/jstest/fit.test.mjs +150 -0
@@ -0,0 +1,150 @@
1+// Tests of the one-line chip lists of static/js/ermite.js (window.ermiteFit).
2+// Only that block is evaluated, on a list whose layout is computed here:
3+// chips flow left to right in a row of a given width and wrap, as flexbox
4+// does. Run with
5+// node --test 'internal/adapters/web/jstest/*.test.mjs'
6+import assert from 'node:assert/strict';
7+import { readFileSync } from 'node:fs';
8+import { test } from 'node:test';
9+
10+const src = readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8');
11+const start = src.indexOf(' window.ermiteFit = ');
12+const block = src.slice(start, src.indexOf('})();', start) + 5);
13+
14+const GAP = 6;
15+const CHAR = 8;
16+const PAD = 20;
17+
18+// A row of chips of the given width (ul.width, which a test may change); a
19+// chip is as wide as its text. Only what the block uses is there.
20+function list(width, names) {
21+ const attrs = {};
22+ const ul = {
23+ width,
24+ children: [],
25+ hasAttribute: (k) => k in attrs,
26+ setAttribute: (k, v) => { attrs[k] = v; },
27+ querySelector(sel) {
28+ assert.equal(sel, ':scope > li.more');
29+ return this.children.find((c) => c.className === 'more') || null;
30+ },
31+ append(li) { li.parent = this; this.children.push(li); },
32+ };
33+ const layout = () => {
34+ let x = 0, y = 0;
35+ for (const c of ul.children) {
36+ if (c.hidden) { c.top = 0; continue; }
37+ const w = c.textContent.length * CHAR + PAD;
38+ if (!('data-open' in attrs) && x > 0 && x + w > ul.width) { x = 0; y += 28; }
39+ c.top = y;
40+ x += w + GAP;
41+ }
42+ };
43+ const el = (tag, text = '') => {
44+ const e = {
45+ tag, className: '', hidden: false, title: '', attrs: {}, kids: [], parent: null, focused: false,
46+ get textContent() { return this.kids.length ? this.kids.map((k) => k.textContent).join('') : text; },
47+ set textContent(v) { text = v; },
48+ get offsetTop() { layout(); return this.top; },
49+ setAttribute(k, v) { this.attrs[k] = v; },
50+ get firstElementChild() { return this.kids[0] || null; },
51+ append(k) { k.parent = this; this.kids.push(k); },
52+ remove() { ul.children.splice(ul.children.indexOf(this), 1); },
53+ focus() { this.focused = true; globalThis.document.activeElement = this; },
54+ closest(sel) {
55+ if (sel === '[data-fit]') return ul;
56+ if (sel === 'li') return tag === 'li' ? this : this.parent;
57+ return null;
58+ },
59+ };
60+ return e;
61+ };
62+ for (const n of names) ul.append(el('li', n));
63+ globalThis.document = { createElement: (tag) => el(tag), activeElement: null };
64+ return ul;
65+}
66+
67+function load() {
68+ globalThis.window = {};
69+ new Function(block)();
70+ return globalThis.window.ermiteFit;
71+}
72+
73+const shown = (ul) => ul.children.filter((c) => !c.hidden).map((c) => c.textContent);
74+const moreBtn = (ul) => ul.children.find((c) => c.className === 'more')?.kids[0];
75+
76+test('a list that fits is left alone', () => {
77+ const fit = load();
78+ const ul = list(400, ['go', 'git', 'forge']);
79+ fit.fit(ul);
80+ assert.deepEqual(shown(ul), ['go', 'git', 'forge']);
81+});
82+
83+test('the chips past the line give way to a … button that names them', () => {
84+ const fit = load();
85+ // each chip is 6 chars: 68px, 74px with the gap. In 380px, 5 fit (364px)
86+ // but not with the 28px … chip after them: the 5th makes way.
87+ const ul = list(380, ['aaaaaa', 'bbbbbb', 'cccccc', 'dddddd', 'eeeeee', 'ffffff', 'gggggg']);
88+ fit.fit(ul);
89+ assert.deepEqual(shown(ul), ['aaaaaa', 'bbbbbb', 'cccccc', 'dddddd', '…']);
90+ const btn = moreBtn(ul);
91+ assert.equal(btn.tag, 'button');
92+ assert.equal(btn.type, 'button');
93+ assert.equal(btn.title, 'eeeeee, ffffff, gggggg');
94+ assert.equal(btn.attrs['aria-label'], 'show 3 more: eeeeee, ffffff, gggggg');
95+ for (const c of ul.children.filter((c) => !c.hidden)) assert.equal(c.offsetTop, 0, 'all on the first line');
96+
97+ // In 400px, the 5th chip and the … chip both fit.
98+ const roomy = list(400, ['aaaaaa', 'bbbbbb', 'cccccc', 'dddddd', 'eeeeee', 'ffffff']);
99+ fit.fit(roomy);
100+ assert.deepEqual(shown(roomy), ['aaaaaa', 'bbbbbb', 'cccccc', 'dddddd', 'eeeeee', '…']);
101+});
102+
103+test('fitting again after a resize shows what fits now, with one … button', () => {
104+ const fit = load();
105+ const names = ['aaaaaa', 'bbbbbb', 'cccccc', 'dddddd', 'eeeeee', 'ffffff', 'gggggg'];
106+ const ul = list(380, names);
107+ fit.fit(ul);
108+ fit.fit(ul);
109+ assert.equal(ul.children.filter((c) => c.className === 'more').length, 1, 'idempotent');
110+
111+ ul.width = 2000; // wider: everything shows, no … button
112+ fit.fit(ul);
113+ assert.deepEqual(shown(ul), names);
114+ assert.equal(moreBtn(ul), undefined);
115+
116+ ul.width = 250; // narrower again: fewer chips, the label follows
117+ fit.fit(ul);
118+ assert.deepEqual(shown(ul), ['aaaaaa', 'bbbbbb', 'cccccc', '…']);
119+ assert.equal(moreBtn(ul).attrs['aria-label'], 'show 4 more: dddddd, eeeeee, ffffff, gggggg');
120+});
121+
122+test('a refit keeps the … button, and its focus', () => {
123+ const fit = load();
124+ const names = ['aaaaaa', 'bbbbbb', 'cccccc', 'dddddd', 'eeeeee', 'ffffff', 'gggggg'];
125+ const ul = list(380, names);
126+ fit.fit(ul);
127+ const btn = moreBtn(ul);
128+ btn.focus();
129+ ul.width = 250;
130+ fit.fit(ul);
131+ assert.equal(moreBtn(ul), btn, 'the same button');
132+ assert.equal(document.activeElement, btn);
133+ ul.width = 2000; // all fit: the focus goes to the last chip
134+ fit.fit(ul);
135+ assert.equal(document.activeElement.textContent, 'gggggg');
136+});
137+
138+test('pressing … shows every chip and moves the focus to the first one it showed', () => {
139+ const fit = load();
140+ const names = ['aaaaaa', 'bbbbbb', 'cccccc', 'dddddd', 'eeeeee', 'ffffff', 'gggggg'];
141+ const ul = list(380, names);
142+ fit.fit(ul);
143+ fit.open(moreBtn(ul));
144+ assert.deepEqual(shown(ul), names, 'all shown, no … left');
145+ const e = ul.children.find((c) => c.textContent === 'eeeeee');
146+ assert.ok(e.focused);
147+ assert.equal(e.attrs.tabindex, '-1');
148+ fit.fit(ul); // a resize keeps it open
149+ assert.deepEqual(shown(ul), names);
150+});
@@ -0,0 +1,150 @@
1+// Tests of the one-line chip lists of static/js/ermite.js (window.ermiteFit).
2+// Only that block is evaluated, on a list whose layout is computed here:
3+// chips flow left to right in a row of a given width and wrap, as flexbox
4+// does. Run with
5+// node --test 'internal/adapters/web/jstest/*.test.mjs'
6+import assert from 'node:assert/strict';
7+import { readFileSync } from 'node:fs';
8+import { test } from 'node:test';
9+
10+const src = readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8');
11+const start = src.indexOf(' window.ermiteFit = ');
12+const block = src.slice(start, src.indexOf('})();', start) + 5);
13+
14+const GAP = 6;
15+const CHAR = 8;
16+const PAD = 20;
17+
18+// A row of chips of the given width (ul.width, which a test may change); a
19+// chip is as wide as its text. Only what the block uses is there.
20+function list(width, names) {
21+ const attrs = {};
22+ const ul = {
23+ width,
24+ children: [],
25+ hasAttribute: (k) => k in attrs,
26+ setAttribute: (k, v) => { attrs[k] = v; },
27+ querySelector(sel) {
28+ assert.equal(sel, ':scope > li.more');
29+ return this.children.find((c) => c.className === 'more') || null;
30+ },
31+ append(li) { li.parent = this; this.children.push(li); },
32+ };
33+ const layout = () => {
34+ let x = 0, y = 0;
35+ for (const c of ul.children) {
36+ if (c.hidden) { c.top = 0; continue; }
37+ const w = c.textContent.length * CHAR + PAD;
38+ if (!('data-open' in attrs) && x > 0 && x + w > ul.width) { x = 0; y += 28; }
39+ c.top = y;
40+ x += w + GAP;
41+ }
42+ };
43+ const el = (tag, text = '') => {
44+ const e = {
45+ tag, className: '', hidden: false, title: '', attrs: {}, kids: [], parent: null, focused: false,
46+ get textContent() { return this.kids.length ? this.kids.map((k) => k.textContent).join('') : text; },
47+ set textContent(v) { text = v; },
48+ get offsetTop() { layout(); return this.top; },
49+ setAttribute(k, v) { this.attrs[k] = v; },
50+ get firstElementChild() { return this.kids[0] || null; },
51+ append(k) { k.parent = this; this.kids.push(k); },
52+ remove() { ul.children.splice(ul.children.indexOf(this), 1); },
53+ focus() { this.focused = true; globalThis.document.activeElement = this; },
54+ closest(sel) {
55+ if (sel === '[data-fit]') return ul;
56+ if (sel === 'li') return tag === 'li' ? this : this.parent;
57+ return null;
58+ },
59+ };
60+ return e;
61+ };
62+ for (const n of names) ul.append(el('li', n));
63+ globalThis.document = { createElement: (tag) => el(tag), activeElement: null };
64+ return ul;
65+}
66+
67+function load() {
68+ globalThis.window = {};
69+ new Function(block)();
70+ return globalThis.window.ermiteFit;
71+}
72+
73+const shown = (ul) => ul.children.filter((c) => !c.hidden).map((c) => c.textContent);
74+const moreBtn = (ul) => ul.children.find((c) => c.className === 'more')?.kids[0];
75+
76+test('a list that fits is left alone', () => {
77+ const fit = load();
78+ const ul = list(400, ['go', 'git', 'forge']);
79+ fit.fit(ul);
80+ assert.deepEqual(shown(ul), ['go', 'git', 'forge']);
81+});
82+
83+test('the chips past the line give way to a … button that names them', () => {
84+ const fit = load();
85+ // each chip is 6 chars: 68px, 74px with the gap. In 380px, 5 fit (364px)
86+ // but not with the 28px … chip after them: the 5th makes way.
87+ const ul = list(380, ['aaaaaa', 'bbbbbb', 'cccccc', 'dddddd', 'eeeeee', 'ffffff', 'gggggg']);
88+ fit.fit(ul);
89+ assert.deepEqual(shown(ul), ['aaaaaa', 'bbbbbb', 'cccccc', 'dddddd', '…']);
90+ const btn = moreBtn(ul);
91+ assert.equal(btn.tag, 'button');
92+ assert.equal(btn.type, 'button');
93+ assert.equal(btn.title, 'eeeeee, ffffff, gggggg');
94+ assert.equal(btn.attrs['aria-label'], 'show 3 more: eeeeee, ffffff, gggggg');
95+ for (const c of ul.children.filter((c) => !c.hidden)) assert.equal(c.offsetTop, 0, 'all on the first line');
96+
97+ // In 400px, the 5th chip and the … chip both fit.
98+ const roomy = list(400, ['aaaaaa', 'bbbbbb', 'cccccc', 'dddddd', 'eeeeee', 'ffffff']);
99+ fit.fit(roomy);
100+ assert.deepEqual(shown(roomy), ['aaaaaa', 'bbbbbb', 'cccccc', 'dddddd', 'eeeeee', '…']);
101+});
102+
103+test('fitting again after a resize shows what fits now, with one … button', () => {
104+ const fit = load();
105+ const names = ['aaaaaa', 'bbbbbb', 'cccccc', 'dddddd', 'eeeeee', 'ffffff', 'gggggg'];
106+ const ul = list(380, names);
107+ fit.fit(ul);
108+ fit.fit(ul);
109+ assert.equal(ul.children.filter((c) => c.className === 'more').length, 1, 'idempotent');
110+
111+ ul.width = 2000; // wider: everything shows, no … button
112+ fit.fit(ul);
113+ assert.deepEqual(shown(ul), names);
114+ assert.equal(moreBtn(ul), undefined);
115+
116+ ul.width = 250; // narrower again: fewer chips, the label follows
117+ fit.fit(ul);
118+ assert.deepEqual(shown(ul), ['aaaaaa', 'bbbbbb', 'cccccc', '…']);
119+ assert.equal(moreBtn(ul).attrs['aria-label'], 'show 4 more: dddddd, eeeeee, ffffff, gggggg');
120+});
121+
122+test('a refit keeps the … button, and its focus', () => {
123+ const fit = load();
124+ const names = ['aaaaaa', 'bbbbbb', 'cccccc', 'dddddd', 'eeeeee', 'ffffff', 'gggggg'];
125+ const ul = list(380, names);
126+ fit.fit(ul);
127+ const btn = moreBtn(ul);
128+ btn.focus();
129+ ul.width = 250;
130+ fit.fit(ul);
131+ assert.equal(moreBtn(ul), btn, 'the same button');
132+ assert.equal(document.activeElement, btn);
133+ ul.width = 2000; // all fit: the focus goes to the last chip
134+ fit.fit(ul);
135+ assert.equal(document.activeElement.textContent, 'gggggg');
136+});
137+
138+test('pressing … shows every chip and moves the focus to the first one it showed', () => {
139+ const fit = load();
140+ const names = ['aaaaaa', 'bbbbbb', 'cccccc', 'dddddd', 'eeeeee', 'ffffff', 'gggggg'];
141+ const ul = list(380, names);
142+ fit.fit(ul);
143+ fit.open(moreBtn(ul));
144+ assert.deepEqual(shown(ul), names, 'all shown, no … left');
145+ const e = ul.children.find((c) => c.textContent === 'eeeeee');
146+ assert.ok(e.focused);
147+ assert.equal(e.attrs.tabindex, '-1');
148+ fit.fit(ul); // a resize keeps it open
149+ assert.deepEqual(shown(ul), names);
150+});
M internal/adapters/web/static/css/00-tokens.css +8 -5
@@ -20,9 +20,11 @@
2020 font-weight: 200 800; font-style: normal; font-display: swap;
2121 }
2222
23-/* Palette: grays only, in four surface levels, so that blocks read apart
24- without hue. Dark: each level is lighter than the one below it; light: each
25- is whiter. Hue is kept for code, diffs and verdicts. */
23+/* Palette: grays, in four surface levels, so that blocks read apart without
24+ hue. Dark: each level is lighter than the one below it; light: each is
25+ whiter. Hue is kept for code, diffs and verdicts, and one accent, the teal
26+ of Despair, marks where you are and what is alive: the current tab, the row
27+ cursor, links in prose, open folders. */
2628 @layer tokens {
2729 :root {
2830 color-scheme: light dark;
@@ -46,9 +48,10 @@
4648 --focus: light-dark(#000000, #ffffff);
4749 --on-ink: light-dark(#ffffff, #050505); /* text on a filled ink button */
4850
49- /* Colour, sparingly, from the owner's Despair theme: only code, diffs and
50- verdicts carry hue. Light values are the same hues darkened to AA. */
51+ /* Colour, sparingly, from the owner's Despair theme. Light values are the
52+ same hues darkened to AA. */
5153 --teal: light-dark(#2f6b6b, #7db5b5);
54+ --accent: var(--teal); /* the one accent of the interface */
5255 --teal-2: light-dark(#355f5f, #5a8585);
5356 --red: light-dark(#a8404b, #d96c75);
5457 --rose: light-dark(#6b6570, #a7a2a9);
@@ -20,9 +20,11 @@
20 font-weight: 200 800; font-style: normal; font-display: swap;20 font-weight: 200 800; font-style: normal; font-display: swap;
21 }21 }
22 22
23-/* Palette: grays only, in four surface levels, so that blocks read apart23+/* Palette: grays, in four surface levels, so that blocks read apart without
24- without hue. Dark: each level is lighter than the one below it; light: each24+ hue. Dark: each level is lighter than the one below it; light: each is
25- is whiter. Hue is kept for code, diffs and verdicts. */25+ whiter. Hue is kept for code, diffs and verdicts, and one accent, the teal
26+ of Despair, marks where you are and what is alive: the current tab, the row
27+ cursor, links in prose, open folders. */
26 @layer tokens {28 @layer tokens {
27 :root {29 :root {
28 color-scheme: light dark;30 color-scheme: light dark;
@@ -46,9 +48,10 @@
46 --focus: light-dark(#000000, #ffffff);48 --focus: light-dark(#000000, #ffffff);
47 --on-ink: light-dark(#ffffff, #050505); /* text on a filled ink button */49 --on-ink: light-dark(#ffffff, #050505); /* text on a filled ink button */
48 50
49- /* Colour, sparingly, from the owner's Despair theme: only code, diffs and51+ /* Colour, sparingly, from the owner's Despair theme. Light values are the
50- verdicts carry hue. Light values are the same hues darkened to AA. */52+ same hues darkened to AA. */
51 --teal: light-dark(#2f6b6b, #7db5b5);53 --teal: light-dark(#2f6b6b, #7db5b5);
54+ --accent: var(--teal); /* the one accent of the interface */
52 --teal-2: light-dark(#355f5f, #5a8585);55 --teal-2: light-dark(#355f5f, #5a8585);
53 --red: light-dark(#a8404b, #d96c75);56 --red: light-dark(#a8404b, #d96c75);
54 --rose: light-dark(#6b6570, #a7a2a9);57 --rose: light-dark(#6b6570, #a7a2a9);
M internal/adapters/web/static/css/10-base.css +1 -1
@@ -42,7 +42,7 @@
4242 small { font-size: var(--fs-s); }
4343 code, pre, kbd, samp { font-family: var(--mono); }
4444 a { color: inherit; text-decoration: underline; text-decoration-color: var(--border-strong); text-underline-offset: 3px; }
45- a:hover { text-decoration-color: currentColor; color: var(--ink-hi); }
45+ a:hover { text-decoration-color: var(--accent); color: var(--ink-hi); }
4646 hr { border: 0; border-top: 1px solid var(--border); margin: var(--s5) 0; }
4747 ::selection { background: var(--ink-hi); color: var(--on-ink); }
4848
@@ -42,7 +42,7 @@
42 small { font-size: var(--fs-s); }42 small { font-size: var(--fs-s); }
43 code, pre, kbd, samp { font-family: var(--mono); }43 code, pre, kbd, samp { font-family: var(--mono); }
44 a { color: inherit; text-decoration: underline; text-decoration-color: var(--border-strong); text-underline-offset: 3px; }44 a { color: inherit; text-decoration: underline; text-decoration-color: var(--border-strong); text-underline-offset: 3px; }
45- a:hover { text-decoration-color: currentColor; color: var(--ink-hi); }45+ a:hover { text-decoration-color: var(--accent); color: var(--ink-hi); }
46 hr { border: 0; border-top: 1px solid var(--border); margin: var(--s5) 0; }46 hr { border: 0; border-top: 1px solid var(--border); margin: var(--s5) 0; }
47 ::selection { background: var(--ink-hi); color: var(--on-ink); }47 ::selection { background: var(--ink-hi); color: var(--on-ink); }
48 48
M internal/adapters/web/static/css/20-shell.css +1 -1
@@ -99,7 +99,7 @@
9999 & a:hover { color: var(--ink-hi); }
100100 & a:hover::after { background: var(--border-strong); }
101101 & a[aria-current] { color: var(--ink-hi); }
102- & a[aria-current]::after { background: var(--ink-hi); }
102+ & a[aria-current]::after { background: var(--accent); }
103103 & kbd { opacity: .55; }
104104 & a:hover kbd, & a:focus-visible kbd, & a[aria-current] kbd { opacity: 1; }
105105 }
@@ -99,7 +99,7 @@
99 & a:hover { color: var(--ink-hi); }99 & a:hover { color: var(--ink-hi); }
100 & a:hover::after { background: var(--border-strong); }100 & a:hover::after { background: var(--border-strong); }
101 & a[aria-current] { color: var(--ink-hi); }101 & a[aria-current] { color: var(--ink-hi); }
102- & a[aria-current]::after { background: var(--ink-hi); }102+ & a[aria-current]::after { background: var(--accent); }
103 & kbd { opacity: .55; }103 & kbd { opacity: .55; }
104 & a:hover kbd, & a:focus-visible kbd, & a[aria-current] kbd { opacity: 1; }104 & a:hover kbd, & a:focus-visible kbd, & a[aria-current] kbd { opacity: 1; }
105 }105 }
M internal/adapters/web/static/css/25-panel.css +2 -2
@@ -40,7 +40,7 @@
4040 font-family: var(--mono); font-size: var(--fs-s); text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
4141 }
4242 & li a:hover, & li a:focus-visible { background: var(--bg-raised); color: var(--ink-hi); }
43- & li a[aria-current] { font-weight: 700; color: var(--ink-hi); }
43+ & li a[aria-current] { font-weight: 700; color: var(--ink-hi); box-shadow: inset 2px 0 0 var(--accent); }
4444 & .mark { display: inline-block; width: 1ch; color: var(--teal); }
4545 }
4646
@@ -50,7 +50,7 @@
5050 text-decoration: none; color: var(--ink);
5151 }
5252 & li a:hover { background: var(--bg-raised); }
53- & li[data-cursor] a { background: var(--bg-strong); color: var(--ink-hi); }
53+ & li[data-cursor] a { background: var(--bg-strong); color: var(--ink-hi); box-shadow: inset 2px 0 0 var(--accent); }
5454 & .lbl { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); font-size: var(--fs-s); }
5555 & .k { flex: none; font-size: var(--fs-xs); color: var(--ink-mute); }
5656 & .dir { color: var(--ink-dim); }
@@ -40,7 +40,7 @@
40 font-family: var(--mono); font-size: var(--fs-s); text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;40 font-family: var(--mono); font-size: var(--fs-s); text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
41 }41 }
42 & li a:hover, & li a:focus-visible { background: var(--bg-raised); color: var(--ink-hi); }42 & li a:hover, & li a:focus-visible { background: var(--bg-raised); color: var(--ink-hi); }
43- & li a[aria-current] { font-weight: 700; color: var(--ink-hi); }43+ & li a[aria-current] { font-weight: 700; color: var(--ink-hi); box-shadow: inset 2px 0 0 var(--accent); }
44 & .mark { display: inline-block; width: 1ch; color: var(--teal); }44 & .mark { display: inline-block; width: 1ch; color: var(--teal); }
45 }45 }
46 46
@@ -50,7 +50,7 @@
50 text-decoration: none; color: var(--ink);50 text-decoration: none; color: var(--ink);
51 }51 }
52 & li a:hover { background: var(--bg-raised); }52 & li a:hover { background: var(--bg-raised); }
53- & li[data-cursor] a { background: var(--bg-strong); color: var(--ink-hi); }53+ & li[data-cursor] a { background: var(--bg-strong); color: var(--ink-hi); box-shadow: inset 2px 0 0 var(--accent); }
54 & .lbl { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); font-size: var(--fs-s); }54 & .lbl { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); font-size: var(--fs-s); }
55 & .k { flex: none; font-size: var(--fs-xs); color: var(--ink-mute); }55 & .k { flex: none; font-size: var(--fs-xs); color: var(--ink-mute); }
56 & .dir { color: var(--ink-dim); }56 & .dir { color: var(--ink-dim); }
M internal/adapters/web/static/css/30-list.css +31 -19
@@ -16,7 +16,7 @@
1616 & tbody tr:first-child td { border-top: 0; }
1717 & tbody tr:hover { background: var(--bg-raised); }
1818 /* the cursor: the row raised, framed by one even line */
19- & tbody tr[data-cursor] { background: var(--bg-raised); outline: 1px solid var(--ink-mute); outline-offset: -1px; }
19+ & tbody tr[data-cursor] { background: var(--bg-raised); outline: 1px solid var(--accent); outline-offset: -1px; }
2020 & tbody tr[hidden] { display: none; }
2121 & td > a { text-decoration: none; }
2222 & td > a:first-child { color: var(--ink-hi); }
@@ -53,41 +53,53 @@
5353 padding: var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r);
5454 }
5555 & .repo:hover { background: var(--bg-raised); border-color: var(--border-strong); }
56- & .repo[data-cursor] { background: var(--bg-raised); border-color: var(--ink-mute); }
56+ & .repo[data-cursor] { background: var(--bg-raised); border-color: var(--accent); }
5757 & .repo[hidden] { display: none; }
5858 & .repo-main { grid-area: main; display: flex; flex-direction: column; gap: var(--s2); min-width: 0; }
5959 & .repo-name { display: flex; align-items: center; flex-wrap: wrap; gap: var(--s1) var(--s2); font-family: var(--mono); font-size: 17px; line-height: 26px; overflow-wrap: anywhere; }
6060 & .repo-name a { color: var(--ink-hi); text-decoration: none; }
6161 & .repo:hover .repo-name a, & .repo[data-cursor] .repo-name a { text-decoration: underline; }
6262 & .repo-name .pill { margin-left: 0; }
63+ /* Every card has the same height: two lines of description, one of topics
64+ (reserved when there are none), five of commits. */
6365 & .repo-desc {
64- color: var(--ink-dim);
66+ color: var(--ink-dim); min-height: calc(2 * var(--lh));
6567 display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden;
6668 }
6769 & .repo-desc.mute { color: var(--ink-mute); font-style: italic; }
68- /* topics: small quiet chips, as on GitHub */
69- & .repo-topics { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0; list-style: none; }
70+ /* topics: small quiet chips, as on GitHub, on one line. The chips that do
71+ not fit wrap out of sight; ermite.fit (ermite.js) puts a … chip after
72+ the last one shown, the hidden topics in its title. */
73+ & .repo-topics {
74+ display: flex; flex-wrap: wrap; gap: 6px; height: 22px; overflow: hidden;
75+ margin: 0; padding: 0; list-style: none;
76+ }
7077 & .repo-topics li {
71- padding: 1px 9px; border: 1px solid var(--border-strong); border-radius: 999px;
78+ flex: none; padding: 1px 9px; border: 1px solid var(--border-strong); border-radius: 999px;
7279 font-family: var(--mono); font-size: 12px; line-height: 18px; color: var(--teal); background: var(--bg-raised);
7380 }
81+ & .repo-topics li[hidden] { display: none; }
82+ & .repo-topics li.more { padding: 0; }
83+ & .repo-topics li.more button {
84+ padding: 1px 7px; border: 0; border-radius: 999px; background: none; cursor: pointer;
85+ font: inherit; line-height: 18px; color: var(--ink-mute);
86+ }
87+ & .repo-topics li.more button:hover { color: var(--accent); }
88+ & .repo-topics[data-open] { height: auto; }
7489 /* the pulse: the commits per day over 90 days, smoothed, under the text */
75- & .repo-pulse { position: relative; margin: auto 0 0; padding-top: var(--s2); }
90+ & .repo-pulse { margin: auto 0 0; padding-top: var(--s2); }
91+ /* as tall as a drawn pulse: the padding comes on top of the ratio */
92+ & div.repo-pulse { aspect-ratio: 600 / 64; box-sizing: content-box; }
7693 & .repo-pulse svg { display: block; width: 100%; height: auto; overflow: visible; }
7794 & .pulse-grid { fill: none; stroke: var(--border); stroke-width: 1; stroke-dasharray: 2 4; }
7895 & .pulse-stop-top { stop-color: var(--teal); stop-opacity: .32; }
7996 & .pulse-stop-bottom { stop-color: var(--teal); stop-opacity: 0; }
8097 & .pulse-line { fill: none; stroke: var(--teal); stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; }
8198 & .pulse-dot { fill: var(--teal); stroke: var(--bg-surface); stroke-width: 1.5; }
82- & .pulse-ring { fill: none; stroke: var(--teal); stroke-width: 1.5; opacity: 0; transform-box: fill-box; transform-origin: center; }
8399 & .repo:hover .pulse-dot, & .repo[data-cursor] .pulse-dot { stroke: var(--bg-raised); }
84- & .pulse-cap { display: flex; justify-content: space-between; margin-top: 2px; font-size: 10px; line-height: 12px; letter-spacing: .04em; text-transform: uppercase; color: var(--ink-mute); }
85100 & .repo-pulse:not([data-live]) .pulse-line { stroke: var(--ink-mute); }
86101 & .repo-pulse:not([data-live]) .pulse-dot { fill: var(--ink-mute); }
87102 & .repo-pulse:not([data-live]) .pulse-stop-top { stop-color: var(--ink-mute); stop-opacity: .18; }
88- @media (prefers-reduced-motion: no-preference) {
89- & .repo-pulse[data-live] .pulse-ring { animation: pulse-beat 2.4s cubic-bezier(.2, .6, .3, 1) infinite; }
90- }
91103 /* the right column: figures, then the CI state, over the commits */
92104 & .repo-head { grid-area: head; display: flex; align-items: flex-start; justify-content: space-between; gap: var(--s3) var(--s5); min-width: 0; }
93105 /* the figures: a small label over a mono value, side by side */
@@ -105,7 +117,10 @@
105117 }
106118 & .repo-ci:hover { color: var(--ink-hi); text-decoration: underline; }
107119 & .repo-ci .ck { margin: 0; }
108- & .repo-commits { grid-area: recent; min-width: 0; margin: 0; padding: 0; list-style: none; font-size: var(--fs-s); }
120+ & .repo-commits {
121+ grid-area: recent; min-width: 0; margin: 0; padding: 0; list-style: none; font-size: var(--fs-s);
122+ min-height: calc(5 * (var(--lh) + 4px)); /* five rows, full or not */
123+ }
109124 & p.repo-commits { font-style: italic; }
110125 & .repo-commits li { display: flex; align-items: baseline; gap: var(--s2); min-width: 0; padding: 2px 0; }
111126 & .rc-sha { flex: none; font-family: var(--mono); color: var(--ink-dim); text-decoration: none; }
@@ -117,12 +132,9 @@
117132 @media (max-width: 720px) {
118133 & .repo { grid-template-columns: minmax(0, 1fr); grid-template-areas: "main" "head" "recent"; grid-template-rows: none; }
119134 & .repo-head { flex-wrap: wrap; }
135+ & .repo-commits { min-height: 0; }
120136 }
121137 }
122- @keyframes pulse-beat {
123- 0% { opacity: .9; transform: scale(1); }
124- 70%, 100% { opacity: 0; transform: scale(3.2); }
125- }
126138 .list.passkeys {
127139 & .c-name { width: auto; font-weight: 600; }
128140 & .c-when { width: 120px; font-size: var(--fs-s); }
@@ -204,7 +216,7 @@
204216 min-height: var(--rowh); padding-right: var(--s4); font-size: var(--fs-s);
205217 }
206218 & > li:hover, & > li[data-cursor] { background: var(--bg-raised); }
207- & > li[data-cursor] { outline: 1px solid var(--ink-mute); outline-offset: -1px; }
219+ & > li[data-cursor] { outline: 1px solid var(--accent); outline-offset: -1px; }
208220 & > li:focus-visible { outline-offset: -2px; border-radius: 0; }
209221 & .t-main { display: flex; align-items: stretch; min-width: 0; min-height: var(--rowh); padding-left: var(--s3); }
210222 & .t-twig {
@@ -233,7 +245,7 @@
233245 width: 16px; height: 16px; margin: -8px 0 0 -8px; border: 0; border-radius: 0; background: var(--ink-dim);
234246 mask: url(icons/chevron.svg) center / contain no-repeat; transform: rotate(-90deg);
235247 }
236- & > li[data-kind="dir"][aria-expanded="true"] .t-node::after { transform: none; background: var(--ink-hi); }
248+ & > li[data-kind="dir"][aria-expanded="true"] .t-node::after { transform: none; background: var(--accent); }
237249 & > li[data-kind="link"] .t-node::after { border-radius: 1px; transform: rotate(45deg); }
238250 & > li[data-kind="module"] .t-node::after { border-radius: 1px; }
239251 & > li[aria-busy="true"] .t-node { opacity: .4; }
@@ -16,7 +16,7 @@
16 & tbody tr:first-child td { border-top: 0; }16 & tbody tr:first-child td { border-top: 0; }
17 & tbody tr:hover { background: var(--bg-raised); }17 & tbody tr:hover { background: var(--bg-raised); }
18 /* the cursor: the row raised, framed by one even line */18 /* the cursor: the row raised, framed by one even line */
19- & tbody tr[data-cursor] { background: var(--bg-raised); outline: 1px solid var(--ink-mute); outline-offset: -1px; }19+ & tbody tr[data-cursor] { background: var(--bg-raised); outline: 1px solid var(--accent); outline-offset: -1px; }
20 & tbody tr[hidden] { display: none; }20 & tbody tr[hidden] { display: none; }
21 & td > a { text-decoration: none; }21 & td > a { text-decoration: none; }
22 & td > a:first-child { color: var(--ink-hi); }22 & td > a:first-child { color: var(--ink-hi); }
@@ -53,41 +53,53 @@
53 padding: var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r);53 padding: var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r);
54 }54 }
55 & .repo:hover { background: var(--bg-raised); border-color: var(--border-strong); }55 & .repo:hover { background: var(--bg-raised); border-color: var(--border-strong); }
56- & .repo[data-cursor] { background: var(--bg-raised); border-color: var(--ink-mute); }56+ & .repo[data-cursor] { background: var(--bg-raised); border-color: var(--accent); }
57 & .repo[hidden] { display: none; }57 & .repo[hidden] { display: none; }
58 & .repo-main { grid-area: main; display: flex; flex-direction: column; gap: var(--s2); min-width: 0; }58 & .repo-main { grid-area: main; display: flex; flex-direction: column; gap: var(--s2); min-width: 0; }
59 & .repo-name { display: flex; align-items: center; flex-wrap: wrap; gap: var(--s1) var(--s2); font-family: var(--mono); font-size: 17px; line-height: 26px; overflow-wrap: anywhere; }59 & .repo-name { display: flex; align-items: center; flex-wrap: wrap; gap: var(--s1) var(--s2); font-family: var(--mono); font-size: 17px; line-height: 26px; overflow-wrap: anywhere; }
60 & .repo-name a { color: var(--ink-hi); text-decoration: none; }60 & .repo-name a { color: var(--ink-hi); text-decoration: none; }
61 & .repo:hover .repo-name a, & .repo[data-cursor] .repo-name a { text-decoration: underline; }61 & .repo:hover .repo-name a, & .repo[data-cursor] .repo-name a { text-decoration: underline; }
62 & .repo-name .pill { margin-left: 0; }62 & .repo-name .pill { margin-left: 0; }
63+ /* Every card has the same height: two lines of description, one of topics
64+ (reserved when there are none), five of commits. */
63 & .repo-desc {65 & .repo-desc {
64- color: var(--ink-dim);66+ color: var(--ink-dim); min-height: calc(2 * var(--lh));
65 display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden;67 display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden;
66 }68 }
67 & .repo-desc.mute { color: var(--ink-mute); font-style: italic; }69 & .repo-desc.mute { color: var(--ink-mute); font-style: italic; }
68- /* topics: small quiet chips, as on GitHub */70+ /* topics: small quiet chips, as on GitHub, on one line. The chips that do
69- & .repo-topics { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0; list-style: none; }71+ not fit wrap out of sight; ermite.fit (ermite.js) puts a … chip after
72+ the last one shown, the hidden topics in its title. */
73+ & .repo-topics {
74+ display: flex; flex-wrap: wrap; gap: 6px; height: 22px; overflow: hidden;
75+ margin: 0; padding: 0; list-style: none;
76+ }
70 & .repo-topics li {77 & .repo-topics li {
71- padding: 1px 9px; border: 1px solid var(--border-strong); border-radius: 999px;78+ flex: none; padding: 1px 9px; border: 1px solid var(--border-strong); border-radius: 999px;
72 font-family: var(--mono); font-size: 12px; line-height: 18px; color: var(--teal); background: var(--bg-raised);79 font-family: var(--mono); font-size: 12px; line-height: 18px; color: var(--teal); background: var(--bg-raised);
73 }80 }
81+ & .repo-topics li[hidden] { display: none; }
82+ & .repo-topics li.more { padding: 0; }
83+ & .repo-topics li.more button {
84+ padding: 1px 7px; border: 0; border-radius: 999px; background: none; cursor: pointer;
85+ font: inherit; line-height: 18px; color: var(--ink-mute);
86+ }
87+ & .repo-topics li.more button:hover { color: var(--accent); }
88+ & .repo-topics[data-open] { height: auto; }
74 /* the pulse: the commits per day over 90 days, smoothed, under the text */89 /* the pulse: the commits per day over 90 days, smoothed, under the text */
75- & .repo-pulse { position: relative; margin: auto 0 0; padding-top: var(--s2); }90+ & .repo-pulse { margin: auto 0 0; padding-top: var(--s2); }
91+ /* as tall as a drawn pulse: the padding comes on top of the ratio */
92+ & div.repo-pulse { aspect-ratio: 600 / 64; box-sizing: content-box; }
76 & .repo-pulse svg { display: block; width: 100%; height: auto; overflow: visible; }93 & .repo-pulse svg { display: block; width: 100%; height: auto; overflow: visible; }
77 & .pulse-grid { fill: none; stroke: var(--border); stroke-width: 1; stroke-dasharray: 2 4; }94 & .pulse-grid { fill: none; stroke: var(--border); stroke-width: 1; stroke-dasharray: 2 4; }
78 & .pulse-stop-top { stop-color: var(--teal); stop-opacity: .32; }95 & .pulse-stop-top { stop-color: var(--teal); stop-opacity: .32; }
79 & .pulse-stop-bottom { stop-color: var(--teal); stop-opacity: 0; }96 & .pulse-stop-bottom { stop-color: var(--teal); stop-opacity: 0; }
80 & .pulse-line { fill: none; stroke: var(--teal); stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; }97 & .pulse-line { fill: none; stroke: var(--teal); stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; }
81 & .pulse-dot { fill: var(--teal); stroke: var(--bg-surface); stroke-width: 1.5; }98 & .pulse-dot { fill: var(--teal); stroke: var(--bg-surface); stroke-width: 1.5; }
82- & .pulse-ring { fill: none; stroke: var(--teal); stroke-width: 1.5; opacity: 0; transform-box: fill-box; transform-origin: center; }
83 & .repo:hover .pulse-dot, & .repo[data-cursor] .pulse-dot { stroke: var(--bg-raised); }99 & .repo:hover .pulse-dot, & .repo[data-cursor] .pulse-dot { stroke: var(--bg-raised); }
84- & .pulse-cap { display: flex; justify-content: space-between; margin-top: 2px; font-size: 10px; line-height: 12px; letter-spacing: .04em; text-transform: uppercase; color: var(--ink-mute); }
85 & .repo-pulse:not([data-live]) .pulse-line { stroke: var(--ink-mute); }100 & .repo-pulse:not([data-live]) .pulse-line { stroke: var(--ink-mute); }
86 & .repo-pulse:not([data-live]) .pulse-dot { fill: var(--ink-mute); }101 & .repo-pulse:not([data-live]) .pulse-dot { fill: var(--ink-mute); }
87 & .repo-pulse:not([data-live]) .pulse-stop-top { stop-color: var(--ink-mute); stop-opacity: .18; }102 & .repo-pulse:not([data-live]) .pulse-stop-top { stop-color: var(--ink-mute); stop-opacity: .18; }
88- @media (prefers-reduced-motion: no-preference) {
89- & .repo-pulse[data-live] .pulse-ring { animation: pulse-beat 2.4s cubic-bezier(.2, .6, .3, 1) infinite; }
90- }
91 /* the right column: figures, then the CI state, over the commits */103 /* the right column: figures, then the CI state, over the commits */
92 & .repo-head { grid-area: head; display: flex; align-items: flex-start; justify-content: space-between; gap: var(--s3) var(--s5); min-width: 0; }104 & .repo-head { grid-area: head; display: flex; align-items: flex-start; justify-content: space-between; gap: var(--s3) var(--s5); min-width: 0; }
93 /* the figures: a small label over a mono value, side by side */105 /* the figures: a small label over a mono value, side by side */
@@ -105,7 +117,10 @@
105 }117 }
106 & .repo-ci:hover { color: var(--ink-hi); text-decoration: underline; }118 & .repo-ci:hover { color: var(--ink-hi); text-decoration: underline; }
107 & .repo-ci .ck { margin: 0; }119 & .repo-ci .ck { margin: 0; }
108- & .repo-commits { grid-area: recent; min-width: 0; margin: 0; padding: 0; list-style: none; font-size: var(--fs-s); }120+ & .repo-commits {
121+ grid-area: recent; min-width: 0; margin: 0; padding: 0; list-style: none; font-size: var(--fs-s);
122+ min-height: calc(5 * (var(--lh) + 4px)); /* five rows, full or not */
123+ }
109 & p.repo-commits { font-style: italic; }124 & p.repo-commits { font-style: italic; }
110 & .repo-commits li { display: flex; align-items: baseline; gap: var(--s2); min-width: 0; padding: 2px 0; }125 & .repo-commits li { display: flex; align-items: baseline; gap: var(--s2); min-width: 0; padding: 2px 0; }
111 & .rc-sha { flex: none; font-family: var(--mono); color: var(--ink-dim); text-decoration: none; }126 & .rc-sha { flex: none; font-family: var(--mono); color: var(--ink-dim); text-decoration: none; }
@@ -117,12 +132,9 @@
117 @media (max-width: 720px) {132 @media (max-width: 720px) {
118 & .repo { grid-template-columns: minmax(0, 1fr); grid-template-areas: "main" "head" "recent"; grid-template-rows: none; }133 & .repo { grid-template-columns: minmax(0, 1fr); grid-template-areas: "main" "head" "recent"; grid-template-rows: none; }
119 & .repo-head { flex-wrap: wrap; }134 & .repo-head { flex-wrap: wrap; }
135+ & .repo-commits { min-height: 0; }
120 }136 }
121 }137 }
122- @keyframes pulse-beat {
123- 0% { opacity: .9; transform: scale(1); }
124- 70%, 100% { opacity: 0; transform: scale(3.2); }
125- }
126 .list.passkeys {138 .list.passkeys {
127 & .c-name { width: auto; font-weight: 600; }139 & .c-name { width: auto; font-weight: 600; }
128 & .c-when { width: 120px; font-size: var(--fs-s); }140 & .c-when { width: 120px; font-size: var(--fs-s); }
@@ -204,7 +216,7 @@
204 min-height: var(--rowh); padding-right: var(--s4); font-size: var(--fs-s);216 min-height: var(--rowh); padding-right: var(--s4); font-size: var(--fs-s);
205 }217 }
206 & > li:hover, & > li[data-cursor] { background: var(--bg-raised); }218 & > li:hover, & > li[data-cursor] { background: var(--bg-raised); }
207- & > li[data-cursor] { outline: 1px solid var(--ink-mute); outline-offset: -1px; }219+ & > li[data-cursor] { outline: 1px solid var(--accent); outline-offset: -1px; }
208 & > li:focus-visible { outline-offset: -2px; border-radius: 0; }220 & > li:focus-visible { outline-offset: -2px; border-radius: 0; }
209 & .t-main { display: flex; align-items: stretch; min-width: 0; min-height: var(--rowh); padding-left: var(--s3); }221 & .t-main { display: flex; align-items: stretch; min-width: 0; min-height: var(--rowh); padding-left: var(--s3); }
210 & .t-twig {222 & .t-twig {
@@ -233,7 +245,7 @@
233 width: 16px; height: 16px; margin: -8px 0 0 -8px; border: 0; border-radius: 0; background: var(--ink-dim);245 width: 16px; height: 16px; margin: -8px 0 0 -8px; border: 0; border-radius: 0; background: var(--ink-dim);
234 mask: url(icons/chevron.svg) center / contain no-repeat; transform: rotate(-90deg);246 mask: url(icons/chevron.svg) center / contain no-repeat; transform: rotate(-90deg);
235 }247 }
236- & > li[data-kind="dir"][aria-expanded="true"] .t-node::after { transform: none; background: var(--ink-hi); }248+ & > li[data-kind="dir"][aria-expanded="true"] .t-node::after { transform: none; background: var(--accent); }
237 & > li[data-kind="link"] .t-node::after { border-radius: 1px; transform: rotate(45deg); }249 & > li[data-kind="link"] .t-node::after { border-radius: 1px; transform: rotate(45deg); }
238 & > li[data-kind="module"] .t-node::after { border-radius: 1px; }250 & > li[data-kind="module"] .t-node::after { border-radius: 1px; }
239 & > li[aria-busy="true"] .t-node { opacity: .4; }251 & > li[aria-busy="true"] .t-node { opacity: .4; }
M internal/adapters/web/static/css/40-summary.css +3 -1
@@ -28,6 +28,8 @@
2828 & ol { list-style: decimal; }
2929 & li + li { margin-top: var(--s1); }
3030 & li::marker { color: var(--ink-mute); }
31+ & a { color: var(--accent); text-decoration-color: color-mix(in srgb, var(--accent) 40%, transparent); }
32+ & a:hover { color: var(--accent); text-decoration-color: currentColor; }
3133 & li > ul, & li > ol, & li > p { margin-top: var(--s1); }
3234 & pre {
3335 padding: var(--s3) var(--s4); overflow-x: auto;
@@ -38,7 +40,7 @@
3840 padding: 1px 5px; font-size: .88em; overflow-wrap: anywhere;
3941 background: var(--bg-raised); border: 1px solid var(--border); border-radius: 4px;
4042 }
41- & blockquote { padding-left: var(--s4); border-left: 3px solid var(--border-strong); color: var(--ink-dim); }
43+ & blockquote { padding-left: var(--s4); border-left: 3px solid color-mix(in srgb, var(--accent) 45%, var(--border-strong)); color: var(--ink-dim); }
4244 & table { display: block; overflow-x: auto; border: 1px solid var(--border); border-radius: var(--r-s); font-size: var(--fs-s); line-height: 20px; }
4345 & th, & td { padding: 6px var(--s3); white-space: nowrap; vertical-align: top; border-top: 1px solid var(--border); }
4446 & th { background: var(--bg-raised); border-top: 0; font-weight: 700; }
@@ -28,6 +28,8 @@
28 & ol { list-style: decimal; }28 & ol { list-style: decimal; }
29 & li + li { margin-top: var(--s1); }29 & li + li { margin-top: var(--s1); }
30 & li::marker { color: var(--ink-mute); }30 & li::marker { color: var(--ink-mute); }
31+ & a { color: var(--accent); text-decoration-color: color-mix(in srgb, var(--accent) 40%, transparent); }
32+ & a:hover { color: var(--accent); text-decoration-color: currentColor; }
31 & li > ul, & li > ol, & li > p { margin-top: var(--s1); }33 & li > ul, & li > ol, & li > p { margin-top: var(--s1); }
32 & pre {34 & pre {
33 padding: var(--s3) var(--s4); overflow-x: auto;35 padding: var(--s3) var(--s4); overflow-x: auto;
@@ -38,7 +40,7 @@
38 padding: 1px 5px; font-size: .88em; overflow-wrap: anywhere;40 padding: 1px 5px; font-size: .88em; overflow-wrap: anywhere;
39 background: var(--bg-raised); border: 1px solid var(--border); border-radius: 4px;41 background: var(--bg-raised); border: 1px solid var(--border); border-radius: 4px;
40 }42 }
41- & blockquote { padding-left: var(--s4); border-left: 3px solid var(--border-strong); color: var(--ink-dim); }43+ & blockquote { padding-left: var(--s4); border-left: 3px solid color-mix(in srgb, var(--accent) 45%, var(--border-strong)); color: var(--ink-dim); }
42 & table { display: block; overflow-x: auto; border: 1px solid var(--border); border-radius: var(--r-s); font-size: var(--fs-s); line-height: 20px; }44 & table { display: block; overflow-x: auto; border: 1px solid var(--border); border-radius: var(--r-s); font-size: var(--fs-s); line-height: 20px; }
43 & th, & td { padding: 6px var(--s3); white-space: nowrap; vertical-align: top; border-top: 1px solid var(--border); }45 & th, & td { padding: 6px var(--s3); white-space: nowrap; vertical-align: top; border-top: 1px solid var(--border); }
44 & th { background: var(--bg-raised); border-top: 0; font-weight: 700; }46 & th { background: var(--bg-raised); border-top: 0; font-weight: 700; }
M internal/adapters/web/static/js/ermite.js +93 -2
@@ -12,6 +12,7 @@
1212 // 5. A handler that fails is shown in the hint badge.
1313 // 6. The lines a diff hides load in place (ermiteGaps; e and E).
1414 // 7. The service worker (ermiteInstall).
15+// 8. Chip lists kept on one line, with a … chip (ermiteFit).
1516 (() => {
1617 // Marks the page as scripted: what does nothing without scripts (the filters,
1718 // the "go to" and help buttons) is hidden until this class is set (see the
@@ -155,7 +156,12 @@
155156 const sel = input.dataset.filter || input.dataset.pick;
156157 if (!sel) return [];
157158 const rows = $$(sel);
158- for (const r of rows) r.hidden = !r.textContent.toLowerCase().includes(t);
159+ for (const r of rows) {
160+ const was = r.hidden;
161+ r.hidden = !r.textContent.toLowerCase().includes(t);
162+ // A row shown again lays out its chips again (it had no width hidden).
163+ if (was && !r.hidden) for (const ul of $$('[data-fit]', r)) window.ermiteFit?.fit(ul);
164+ }
159165 if (input.dataset.groups) {
160166 for (const g of $$(input.dataset.groups)) g.hidden = !rows.some((r) => !r.hidden && g.contains(r));
161167 }
@@ -641,6 +647,88 @@
641647 };
642648 }
643649
650+ // 8. A list of chips on one line ([data-fit], the topics of a home row):
651+ // the chips that wrap out of sight are hidden and a "…" button after the
652+ // last one shown names them; pressing it opens the list (data-open) on as
653+ // many lines as it needs. Self-contained: jstest/fit.test.mjs evaluates
654+ // this block alone.
655+ window.ermiteFit = (() => {
656+ const fit = (ul) => {
657+ if (ul.hasAttribute('data-open')) return;
658+ // The … button is kept across fits, so that a refit (a resize) does not
659+ // take the focus from it.
660+ let more = ul.querySelector(':scope > li.more');
661+ let btn = more?.firstElementChild;
662+ const focused = !!btn && document.activeElement === btn;
663+ more?.remove();
664+ const chips = [...ul.children];
665+ for (const li of chips) li.hidden = false;
666+ const top = chips[0]?.offsetTop;
667+ if (top === undefined || chips.at(-1).offsetTop === top) {
668+ // Everything fits now: a focused … button hands the focus to the
669+ // last chip.
670+ if (focused && chips.length) {
671+ chips.at(-1).setAttribute('tabindex', '-1');
672+ chips.at(-1).focus({ preventScroll: true });
673+ }
674+ return;
675+ }
676+ if (!more) {
677+ more = document.createElement('li');
678+ more.className = 'more';
679+ btn = document.createElement('button');
680+ btn.type = 'button';
681+ btn.textContent = '…';
682+ more.append(btn);
683+ }
684+ ul.append(more);
685+ if (focused) btn.focus({ preventScroll: true });
686+ // Keep the chips of the first line, then drop from the end until the
687+ // … chip fits there too.
688+ let shown = chips.findIndex((li) => li.offsetTop !== top);
689+ for (const li of chips.slice(shown)) li.hidden = true;
690+ while (shown > 0 && more.offsetTop !== top) chips[--shown].hidden = true;
691+ const rest = chips.slice(shown).map((li) => li.textContent);
692+ btn.title = rest.join(', ');
693+ btn.setAttribute('aria-label', 'show ' + rest.length + ' more: ' + rest.join(', '));
694+ };
695+ // open shows every chip of the list of the … button pressed.
696+ const open = (btn) => {
697+ const ul = btn.closest('[data-fit]');
698+ if (!ul) return;
699+ const item = btn.closest('li');
700+ // The focus goes to the first chip that was hidden, now shown.
701+ const next = [...ul.children].find((li) => li.hidden);
702+ ul.setAttribute('data-open', '');
703+ item.remove();
704+ for (const li of ul.children) li.hidden = false;
705+ if (next) {
706+ next.setAttribute('tabindex', '-1');
707+ next.focus({ preventScroll: true });
708+ }
709+ };
710+ const all = (root = document) => {
711+ for (const ul of root.querySelectorAll('[data-fit]')) fit(ul);
712+ };
713+ return { fit, all, open };
714+ })();
715+ addEventListener('click', (e) => {
716+ const btn = e.target.closest?.('[data-fit] > li.more > button');
717+ if (btn) window.ermiteFit.open(btn);
718+ });
719+ {
720+ window.ermiteFit.all();
721+ // Widths change with the window and when the fonts arrive; one frame per
722+ // burst. Always listening: a live update can bring the first list.
723+ let pending = 0;
724+ const again = () => {
725+ if (pending) return;
726+ pending = requestAnimationFrame(() => { pending = 0; window.ermiteFit.all(); });
727+ };
728+ addEventListener('resize', again);
729+ document.fonts?.ready.then(again);
730+ }
731+
644732 // 5. A failure of a handler or of a request (htmx logs it to the console
645733 // already) is shown, so that a key that does nothing is not a mystery.
646734 addEventListener('htmx:error', (e) => {
@@ -712,6 +800,8 @@ window.ermiteLive = (() => {
712800 document.getElementById('main')?.focus({ preventScroll: true });
713801 };
714802 const setState = (s) => { const el = document.querySelector('.hint-live'); if (el) el.dataset.state = s; };
803+ // fitIn lays out the one-line chip lists of a fragment now in the page.
804+ const fitIn = (el) => { for (const ul of el.querySelectorAll('[data-fit]')) window.ermiteFit?.fit(ul); };
715805 return {
716806 // lost is htmx:sse:close and htmx:sse:error: "live updates paused" shows
717807 // only if the stream is still down LOST_AFTER later.
@@ -741,7 +831,7 @@ window.ermiteLive = (() => {
741831 }
742832 if (!old) {
743833 const rows = document.getElementById('repo-rows');
744- if (rows && el.id.startsWith('repo-')) { rows.prepend(el); window.htmx?.process(el); }
834+ if (rows && el.id.startsWith('repo-')) { rows.prepend(el); window.htmx?.process(el); fitIn(el); }
745835 continue;
746836 }
747837 if (old.hasAttribute('data-slot')) {
@@ -752,6 +842,7 @@ window.ermiteLive = (() => {
752842 if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', '');
753843 old.replaceWith(el);
754844 window.htmx?.process(el);
845+ fitIn(el);
755846 if (had) refocus(el, had);
756847 }
757848 // The rows may have changed under an active filter: apply it again and
@@ -12,6 +12,7 @@
12 // 5. A handler that fails is shown in the hint badge.12 // 5. A handler that fails is shown in the hint badge.
13 // 6. The lines a diff hides load in place (ermiteGaps; e and E).13 // 6. The lines a diff hides load in place (ermiteGaps; e and E).
14 // 7. The service worker (ermiteInstall).14 // 7. The service worker (ermiteInstall).
15+// 8. Chip lists kept on one line, with a … chip (ermiteFit).
15 (() => {16 (() => {
16 // Marks the page as scripted: what does nothing without scripts (the filters,17 // Marks the page as scripted: what does nothing without scripts (the filters,
17 // the "go to" and help buttons) is hidden until this class is set (see the18 // the "go to" and help buttons) is hidden until this class is set (see the
@@ -155,7 +156,12 @@
155 const sel = input.dataset.filter || input.dataset.pick;156 const sel = input.dataset.filter || input.dataset.pick;
156 if (!sel) return [];157 if (!sel) return [];
157 const rows = $$(sel);158 const rows = $$(sel);
158- for (const r of rows) r.hidden = !r.textContent.toLowerCase().includes(t);159+ for (const r of rows) {
160+ const was = r.hidden;
161+ r.hidden = !r.textContent.toLowerCase().includes(t);
162+ // A row shown again lays out its chips again (it had no width hidden).
163+ if (was && !r.hidden) for (const ul of $$('[data-fit]', r)) window.ermiteFit?.fit(ul);
164+ }
159 if (input.dataset.groups) {165 if (input.dataset.groups) {
160 for (const g of $$(input.dataset.groups)) g.hidden = !rows.some((r) => !r.hidden && g.contains(r));166 for (const g of $$(input.dataset.groups)) g.hidden = !rows.some((r) => !r.hidden && g.contains(r));
161 }167 }
@@ -641,6 +647,88 @@
641 };647 };
642 }648 }
643 649
650+ // 8. A list of chips on one line ([data-fit], the topics of a home row):
651+ // the chips that wrap out of sight are hidden and a "…" button after the
652+ // last one shown names them; pressing it opens the list (data-open) on as
653+ // many lines as it needs. Self-contained: jstest/fit.test.mjs evaluates
654+ // this block alone.
655+ window.ermiteFit = (() => {
656+ const fit = (ul) => {
657+ if (ul.hasAttribute('data-open')) return;
658+ // The … button is kept across fits, so that a refit (a resize) does not
659+ // take the focus from it.
660+ let more = ul.querySelector(':scope > li.more');
661+ let btn = more?.firstElementChild;
662+ const focused = !!btn && document.activeElement === btn;
663+ more?.remove();
664+ const chips = [...ul.children];
665+ for (const li of chips) li.hidden = false;
666+ const top = chips[0]?.offsetTop;
667+ if (top === undefined || chips.at(-1).offsetTop === top) {
668+ // Everything fits now: a focused … button hands the focus to the
669+ // last chip.
670+ if (focused && chips.length) {
671+ chips.at(-1).setAttribute('tabindex', '-1');
672+ chips.at(-1).focus({ preventScroll: true });
673+ }
674+ return;
675+ }
676+ if (!more) {
677+ more = document.createElement('li');
678+ more.className = 'more';
679+ btn = document.createElement('button');
680+ btn.type = 'button';
681+ btn.textContent = '…';
682+ more.append(btn);
683+ }
684+ ul.append(more);
685+ if (focused) btn.focus({ preventScroll: true });
686+ // Keep the chips of the first line, then drop from the end until the
687+ // … chip fits there too.
688+ let shown = chips.findIndex((li) => li.offsetTop !== top);
689+ for (const li of chips.slice(shown)) li.hidden = true;
690+ while (shown > 0 && more.offsetTop !== top) chips[--shown].hidden = true;
691+ const rest = chips.slice(shown).map((li) => li.textContent);
692+ btn.title = rest.join(', ');
693+ btn.setAttribute('aria-label', 'show ' + rest.length + ' more: ' + rest.join(', '));
694+ };
695+ // open shows every chip of the list of the … button pressed.
696+ const open = (btn) => {
697+ const ul = btn.closest('[data-fit]');
698+ if (!ul) return;
699+ const item = btn.closest('li');
700+ // The focus goes to the first chip that was hidden, now shown.
701+ const next = [...ul.children].find((li) => li.hidden);
702+ ul.setAttribute('data-open', '');
703+ item.remove();
704+ for (const li of ul.children) li.hidden = false;
705+ if (next) {
706+ next.setAttribute('tabindex', '-1');
707+ next.focus({ preventScroll: true });
708+ }
709+ };
710+ const all = (root = document) => {
711+ for (const ul of root.querySelectorAll('[data-fit]')) fit(ul);
712+ };
713+ return { fit, all, open };
714+ })();
715+ addEventListener('click', (e) => {
716+ const btn = e.target.closest?.('[data-fit] > li.more > button');
717+ if (btn) window.ermiteFit.open(btn);
718+ });
719+ {
720+ window.ermiteFit.all();
721+ // Widths change with the window and when the fonts arrive; one frame per
722+ // burst. Always listening: a live update can bring the first list.
723+ let pending = 0;
724+ const again = () => {
725+ if (pending) return;
726+ pending = requestAnimationFrame(() => { pending = 0; window.ermiteFit.all(); });
727+ };
728+ addEventListener('resize', again);
729+ document.fonts?.ready.then(again);
730+ }
731+
644 // 5. A failure of a handler or of a request (htmx logs it to the console732 // 5. A failure of a handler or of a request (htmx logs it to the console
645 // already) is shown, so that a key that does nothing is not a mystery.733 // already) is shown, so that a key that does nothing is not a mystery.
646 addEventListener('htmx:error', (e) => {734 addEventListener('htmx:error', (e) => {
@@ -712,6 +800,8 @@ window.ermiteLive = (() => {
712 document.getElementById('main')?.focus({ preventScroll: true });800 document.getElementById('main')?.focus({ preventScroll: true });
713 };801 };
714 const setState = (s) => { const el = document.querySelector('.hint-live'); if (el) el.dataset.state = s; };802 const setState = (s) => { const el = document.querySelector('.hint-live'); if (el) el.dataset.state = s; };
803+ // fitIn lays out the one-line chip lists of a fragment now in the page.
804+ const fitIn = (el) => { for (const ul of el.querySelectorAll('[data-fit]')) window.ermiteFit?.fit(ul); };
715 return {805 return {
716 // lost is htmx:sse:close and htmx:sse:error: "live updates paused" shows806 // lost is htmx:sse:close and htmx:sse:error: "live updates paused" shows
717 // only if the stream is still down LOST_AFTER later.807 // only if the stream is still down LOST_AFTER later.
@@ -741,7 +831,7 @@ window.ermiteLive = (() => {
741 }831 }
742 if (!old) {832 if (!old) {
743 const rows = document.getElementById('repo-rows');833 const rows = document.getElementById('repo-rows');
744- if (rows && el.id.startsWith('repo-')) { rows.prepend(el); window.htmx?.process(el); }834+ if (rows && el.id.startsWith('repo-')) { rows.prepend(el); window.htmx?.process(el); fitIn(el); }
745 continue;835 continue;
746 }836 }
747 if (old.hasAttribute('data-slot')) {837 if (old.hasAttribute('data-slot')) {
@@ -752,6 +842,7 @@ window.ermiteLive = (() => {
752 if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', '');842 if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', '');
753 old.replaceWith(el);843 old.replaceWith(el);
754 window.htmx?.process(el);844 window.htmx?.process(el);
845+ fitIn(el);
755 if (had) refocus(el, had);846 if (had) refocus(el, had);
756 }847 }
757 // The rows may have changed under an active filter: apply it again and848 // The rows may have changed under an active filter: apply it again and
M internal/app/testdata/web/home.html +12 -12
@@ -72,6 +72,8 @@
7272 <a href="/demo">demo</a>
7373 </h2>
7474 <p class="repo-desc">A demo repository</p>
75+<div class="repo-topics" aria-hidden="true">
76+</div>
7577 <figure class="repo-pulse">
7678 <svg viewBox="0 0 600 64" role="img" aria-label="Activity: 0 commits in the last 90 days" focusable="false">
7779 <defs>
@@ -88,15 +90,9 @@
8890 </path>
8991 <path class="pulse-line" d="M0 58 C98.3 58 491.7 58 590 58" vector-effect="non-scaling-stroke">
9092 </path>
91-<circle class="pulse-ring" cx="590" cy="58" r="4">
92-</circle>
9393 <circle class="pulse-dot" cx="590" cy="58" r="3.5">
9494 </circle>
9595 </svg>
96-<figcaption class="pulse-cap" aria-hidden="true">
97-<span>90 days ago</span>
98-<span>today</span>
99-</figcaption>
10096 </figure>
10197 </div>
10298 <div class="repo-head">
@@ -109,18 +105,18 @@
109105 <dt>last 30 days</dt>
110106 <dd>0</dd>
111107 </div>
112-<div class="rs-tag">
113-<dt>latest tag</dt>
114-<dd>
115-<a href="/demo/tree/SHA40" title="v0.2.0 · made AGO">v0.2.0</a>
116-</dd>
117-</div>
118108 <div>
119109 <dt>last activity</dt>
120110 <dd>
121111 <time datetime="STAMP" title="STAMP">AGO</time>
122112 </dd>
123113 </div>
114+<div class="rs-tag">
115+<dt>last tag</dt>
116+<dd>
117+<a href="/demo/tree/SHA40" title="v0.2.0 · made AGO">v0.2.0</a>
118+</dd>
119+</div>
124120 </dl>
125121 </div>
126122 <ul class="repo-commits" aria-label="Recent commits of demo">
@@ -142,6 +138,10 @@
142138 <a href="/quiet">quiet</a>
143139 </h2>
144140 <p class="repo-desc mute">no description</p>
141+<div class="repo-topics" aria-hidden="true">
142+</div>
143+<div class="repo-pulse" aria-hidden="true">
144+</div>
145145 </div>
146146 <div class="repo-head">
147147 <dl class="repo-stats" aria-label="Figures of quiet">
@@ -72,6 +72,8 @@
72 <a href="/demo">demo</a>72 <a href="/demo">demo</a>
73 </h2>73 </h2>
74 <p class="repo-desc">A demo repository</p>74 <p class="repo-desc">A demo repository</p>
75+<div class="repo-topics" aria-hidden="true">
76+</div>
75 <figure class="repo-pulse">77 <figure class="repo-pulse">
76 <svg viewBox="0 0 600 64" role="img" aria-label="Activity: 0 commits in the last 90 days" focusable="false">78 <svg viewBox="0 0 600 64" role="img" aria-label="Activity: 0 commits in the last 90 days" focusable="false">
77 <defs>79 <defs>
@@ -88,15 +90,9 @@
88 </path>90 </path>
89 <path class="pulse-line" d="M0 58 C98.3 58 491.7 58 590 58" vector-effect="non-scaling-stroke">91 <path class="pulse-line" d="M0 58 C98.3 58 491.7 58 590 58" vector-effect="non-scaling-stroke">
90 </path>92 </path>
91-<circle class="pulse-ring" cx="590" cy="58" r="4">
92-</circle>
93 <circle class="pulse-dot" cx="590" cy="58" r="3.5">93 <circle class="pulse-dot" cx="590" cy="58" r="3.5">
94 </circle>94 </circle>
95 </svg>95 </svg>
96-<figcaption class="pulse-cap" aria-hidden="true">
97-<span>90 days ago</span>
98-<span>today</span>
99-</figcaption>
100 </figure>96 </figure>
101 </div>97 </div>
102 <div class="repo-head">98 <div class="repo-head">
@@ -109,18 +105,18 @@
109 <dt>last 30 days</dt>105 <dt>last 30 days</dt>
110 <dd>0</dd>106 <dd>0</dd>
111 </div>107 </div>
112-<div class="rs-tag">
113-<dt>latest tag</dt>
114-<dd>
115-<a href="/demo/tree/SHA40" title="v0.2.0 · made AGO">v0.2.0</a>
116-</dd>
117-</div>
118 <div>108 <div>
119 <dt>last activity</dt>109 <dt>last activity</dt>
120 <dd>110 <dd>
121 <time datetime="STAMP" title="STAMP">AGO</time>111 <time datetime="STAMP" title="STAMP">AGO</time>
122 </dd>112 </dd>
123 </div>113 </div>
114+<div class="rs-tag">
115+<dt>last tag</dt>
116+<dd>
117+<a href="/demo/tree/SHA40" title="v0.2.0 · made AGO">v0.2.0</a>
118+</dd>
119+</div>
124 </dl>120 </dl>
125 </div>121 </div>
126 <ul class="repo-commits" aria-label="Recent commits of demo">122 <ul class="repo-commits" aria-label="Recent commits of demo">
@@ -142,6 +138,10 @@
142 <a href="/quiet">quiet</a>138 <a href="/quiet">quiet</a>
143 </h2>139 </h2>
144 <p class="repo-desc mute">no description</p>140 <p class="repo-desc mute">no description</p>
141+<div class="repo-topics" aria-hidden="true">
142+</div>
143+<div class="repo-pulse" aria-hidden="true">
144+</div>
145 </div>145 </div>
146 <div class="repo-head">146 <div class="repo-head">
147 <dl class="repo-stats" aria-label="Figures of quiet">147 <dl class="repo-stats" aria-label="Figures of quiet">

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 42

↑↓ moveenter openesc close