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 Unverified
SHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivAThe key is unknown
12 files · +626 -365
M internal/adapters/web/components.templ +18 -15
| @@ -54,14 +54,18 @@ templ HomeRowView(r HomeRow) { | |||
| 54 | 54 | <p class="repo-desc mute">no description</p> | |
| 55 | 55 | } | |
| 56 | 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 | 58 | for _, t := range r.Topics { | |
| 59 | 59 | <li>{ t }</li> | |
| 60 | 60 | } | |
| 61 | 61 | </ul> | |
| 62 | + | } else { | |
| 63 | + | <div class="repo-topics" aria-hidden="true"></div> | |
| 62 | 64 | } | |
| 63 | 65 | if r.Stats != nil && r.Stats.Pulse != nil { | |
| 64 | 66 | @pulse(r.Stats.Pulse) | |
| 67 | + | } else { | |
| 68 | + | <div class="repo-pulse" aria-hidden="true"></div> | |
| 65 | 69 | } | |
| 66 | 70 | </div> | |
| 67 | 71 | <div class="repo-head"> | |
| @@ -69,16 +73,6 @@ templ HomeRowView(r HomeRow) { | |||
| 69 | 73 | if s := r.Stats; s != nil { | |
| 70 | 74 | <div><dt>commits</dt><dd>{ s.Commits }</dd></div> | |
| 71 | 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 | 77 | <div> | |
| 84 | 78 | <dt>last activity</dt> | |
| @@ -88,6 +82,16 @@ templ HomeRowView(r HomeRow) { | |||
| 88 | 82 | <dd class="mute">never</dd> | |
| 89 | 83 | } | |
| 90 | 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 | 95 | </dl> | |
| 92 | 96 | if r.CI != nil { | |
| 93 | 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 | 125 | // 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. | |
| 124 | 129 | templ pulse(h *Heartbeat) { | |
| 125 | 130 | <figure class="repo-pulse" data-live?={ h.Live }> | |
| 126 | 131 | <svg viewBox="0 0 600 64" role="img" aria-label={ h.Label } focusable="false"> | |
| @@ -133,10 +138,8 @@ templ pulse(h *Heartbeat) { | |||
| 133 | 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 | 139 | <path class="pulse-area" d={ h.Area } fill={ "url(#" + h.GradID + ")" }></path> | |
| 135 | 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 | 141 | <circle class="pulse-dot" cx={ h.X } cy={ h.Y } r="3.5"></circle> | |
| 138 | 142 | </svg> | |
| 139 | - | <figcaption class="pulse-cap" aria-hidden="true"><span>90 days ago</span><span>today</span></figcaption> | |
| 140 | 143 | </figure> | |
| 141 | 144 | } | |
| 142 | 145 | ||
| @@ -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 fading | 125 | // 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 | 126 | + | // 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) { | |||
| 80 | 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 | 81 | assert.Contains(t, out, "<dt>last activity</dt>") | |
| 82 | 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 | 84 | assert.Contains(t, out, `role="img" aria-label="Activity: 0 commits in the last 90 days"`) | |
| 85 | 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 | 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 | 99 | // Past the cap of the walk, the figures are lower bounds. | |
| 89 | 100 | 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 @@ | |||
| 20 | 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 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. */ | |
| 26 | 28 | @layer tokens { | |
| 27 | 29 | :root { | |
| 28 | 30 | color-scheme: light dark; | |
| @@ -46,9 +48,10 @@ | |||
| 46 | 48 | --focus: light-dark(#000000, #ffffff); | |
| 47 | 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 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. */ | |
| 51 | 53 | --teal: light-dark(#2f6b6b, #7db5b5); | |
| 54 | + | --accent: var(--teal); /* the one accent of the interface */ | |
| 52 | 55 | --teal-2: light-dark(#355f5f, #5a8585); | |
| 53 | 56 | --red: light-dark(#a8404b, #d96c75); | |
| 54 | 57 | --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 apart | 23 | + | /* 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: each | 24 | + | 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 and | 51 | + | /* 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 @@ | |||
| 42 | 42 | small { font-size: var(--fs-s); } | |
| 43 | 43 | code, pre, kbd, samp { font-family: var(--mono); } | |
| 44 | 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 | 46 | hr { border: 0; border-top: 1px solid var(--border); margin: var(--s5) 0; } | |
| 47 | 47 | ::selection { background: var(--ink-hi); color: var(--on-ink); } | |
| 48 | 48 | ||
| @@ -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 @@ | |||
| 99 | 99 | & a:hover { color: var(--ink-hi); } | |
| 100 | 100 | & a:hover::after { background: var(--border-strong); } | |
| 101 | 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 | 103 | & kbd { opacity: .55; } | |
| 104 | 104 | & a:hover kbd, & a:focus-visible kbd, & a[aria-current] kbd { opacity: 1; } | |
| 105 | 105 | } | |
| @@ -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 @@ | |||
| 40 | 40 | font-family: var(--mono); font-size: var(--fs-s); text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; | |
| 41 | 41 | } | |
| 42 | 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 | 44 | & .mark { display: inline-block; width: 1ch; color: var(--teal); } | |
| 45 | 45 | } | |
| 46 | 46 | ||
| @@ -50,7 +50,7 @@ | |||
| 50 | 50 | text-decoration: none; color: var(--ink); | |
| 51 | 51 | } | |
| 52 | 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 | 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 | 55 | & .k { flex: none; font-size: var(--fs-xs); color: var(--ink-mute); } | |
| 56 | 56 | & .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 @@ | |||
| 16 | 16 | & tbody tr:first-child td { border-top: 0; } | |
| 17 | 17 | & tbody tr:hover { background: var(--bg-raised); } | |
| 18 | 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 | 20 | & tbody tr[hidden] { display: none; } | |
| 21 | 21 | & td > a { text-decoration: none; } | |
| 22 | 22 | & td > a:first-child { color: var(--ink-hi); } | |
| @@ -53,41 +53,53 @@ | |||
| 53 | 53 | padding: var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); | |
| 54 | 54 | } | |
| 55 | 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 | 57 | & .repo[hidden] { display: none; } | |
| 58 | 58 | & .repo-main { grid-area: main; display: flex; flex-direction: column; gap: var(--s2); min-width: 0; } | |
| 59 | 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 | 60 | & .repo-name a { color: var(--ink-hi); text-decoration: none; } | |
| 61 | 61 | & .repo:hover .repo-name a, & .repo[data-cursor] .repo-name a { text-decoration: underline; } | |
| 62 | 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 | 65 | & .repo-desc { | |
| 64 | - | color: var(--ink-dim); | |
| 66 | + | color: var(--ink-dim); min-height: calc(2 * var(--lh)); | |
| 65 | 67 | display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; | |
| 66 | 68 | } | |
| 67 | 69 | & .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 | + | } | |
| 70 | 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 | 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 | 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 | 93 | & .repo-pulse svg { display: block; width: 100%; height: auto; overflow: visible; } | |
| 77 | 94 | & .pulse-grid { fill: none; stroke: var(--border); stroke-width: 1; stroke-dasharray: 2 4; } | |
| 78 | 95 | & .pulse-stop-top { stop-color: var(--teal); stop-opacity: .32; } | |
| 79 | 96 | & .pulse-stop-bottom { stop-color: var(--teal); stop-opacity: 0; } | |
| 80 | 97 | & .pulse-line { fill: none; stroke: var(--teal); stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; } | |
| 81 | 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 | 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 | 100 | & .repo-pulse:not([data-live]) .pulse-line { stroke: var(--ink-mute); } | |
| 86 | 101 | & .repo-pulse:not([data-live]) .pulse-dot { fill: var(--ink-mute); } | |
| 87 | 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 | 103 | /* the right column: figures, then the CI state, over the commits */ | |
| 92 | 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 | 105 | /* the figures: a small label over a mono value, side by side */ | |
| @@ -105,7 +117,10 @@ | |||
| 105 | 117 | } | |
| 106 | 118 | & .repo-ci:hover { color: var(--ink-hi); text-decoration: underline; } | |
| 107 | 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 | 124 | & p.repo-commits { font-style: italic; } | |
| 110 | 125 | & .repo-commits li { display: flex; align-items: baseline; gap: var(--s2); min-width: 0; padding: 2px 0; } | |
| 111 | 126 | & .rc-sha { flex: none; font-family: var(--mono); color: var(--ink-dim); text-decoration: none; } | |
| @@ -117,12 +132,9 @@ | |||
| 117 | 132 | @media (max-width: 720px) { | |
| 118 | 133 | & .repo { grid-template-columns: minmax(0, 1fr); grid-template-areas: "main" "head" "recent"; grid-template-rows: none; } | |
| 119 | 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 | 138 | .list.passkeys { | |
| 127 | 139 | & .c-name { width: auto; font-weight: 600; } | |
| 128 | 140 | & .c-when { width: 120px; font-size: var(--fs-s); } | |
| @@ -204,7 +216,7 @@ | |||
| 204 | 216 | min-height: var(--rowh); padding-right: var(--s4); font-size: var(--fs-s); | |
| 205 | 217 | } | |
| 206 | 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 | 220 | & > li:focus-visible { outline-offset: -2px; border-radius: 0; } | |
| 209 | 221 | & .t-main { display: flex; align-items: stretch; min-width: 0; min-height: var(--rowh); padding-left: var(--s3); } | |
| 210 | 222 | & .t-twig { | |
| @@ -233,7 +245,7 @@ | |||
| 233 | 245 | width: 16px; height: 16px; margin: -8px 0 0 -8px; border: 0; border-radius: 0; background: var(--ink-dim); | |
| 234 | 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 | 249 | & > li[data-kind="link"] .t-node::after { border-radius: 1px; transform: rotate(45deg); } | |
| 238 | 250 | & > li[data-kind="module"] .t-node::after { border-radius: 1px; } | |
| 239 | 251 | & > 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 @@ | |||
| 28 | 28 | & ol { list-style: decimal; } | |
| 29 | 29 | & li + li { margin-top: var(--s1); } | |
| 30 | 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 | 33 | & li > ul, & li > ol, & li > p { margin-top: var(--s1); } | |
| 32 | 34 | & pre { | |
| 33 | 35 | padding: var(--s3) var(--s4); overflow-x: auto; | |
| @@ -38,7 +40,7 @@ | |||
| 38 | 40 | padding: 1px 5px; font-size: .88em; overflow-wrap: anywhere; | |
| 39 | 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 | 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 | 45 | & th, & td { padding: 6px var(--s3); white-space: nowrap; vertical-align: top; border-top: 1px solid var(--border); } | |
| 44 | 46 | & 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 @@ | |||
| 12 | 12 | // 5. A handler that fails is shown in the hint badge. | |
| 13 | 13 | // 6. The lines a diff hides load in place (ermiteGaps; e and E). | |
| 14 | 14 | // 7. The service worker (ermiteInstall). | |
| 15 | + | // 8. Chip lists kept on one line, with a … chip (ermiteFit). | |
| 15 | 16 | (() => { | |
| 16 | 17 | // Marks the page as scripted: what does nothing without scripts (the filters, | |
| 17 | 18 | // the "go to" and help buttons) is hidden until this class is set (see the | |
| @@ -155,7 +156,12 @@ | |||
| 155 | 156 | const sel = input.dataset.filter || input.dataset.pick; | |
| 156 | 157 | if (!sel) return []; | |
| 157 | 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 | 165 | if (input.dataset.groups) { | |
| 160 | 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 | 732 | // 5. A failure of a handler or of a request (htmx logs it to the console | |
| 645 | 733 | // already) is shown, so that a key that does nothing is not a mystery. | |
| 646 | 734 | addEventListener('htmx:error', (e) => { | |
| @@ -712,6 +800,8 @@ window.ermiteLive = (() => { | |||
| 712 | 800 | document.getElementById('main')?.focus({ preventScroll: true }); | |
| 713 | 801 | }; | |
| 714 | 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 | 805 | return { | |
| 716 | 806 | // lost is htmx:sse:close and htmx:sse:error: "live updates paused" shows | |
| 717 | 807 | // only if the stream is still down LOST_AFTER later. | |
| @@ -741,7 +831,7 @@ window.ermiteLive = (() => { | |||
| 741 | 831 | } | |
| 742 | 832 | if (!old) { | |
| 743 | 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 | 835 | continue; | |
| 746 | 836 | } | |
| 747 | 837 | if (old.hasAttribute('data-slot')) { | |
| @@ -752,6 +842,7 @@ window.ermiteLive = (() => { | |||
| 752 | 842 | if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', ''); | |
| 753 | 843 | old.replaceWith(el); | |
| 754 | 844 | window.htmx?.process(el); | |
| 845 | + | fitIn(el); | |
| 755 | 846 | if (had) refocus(el, had); | |
| 756 | 847 | } | |
| 757 | 848 | // 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 the | 18 | // 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 console | 732 | // 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" shows | 806 | // 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 and | 848 | // 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 @@ | |||
| 72 | 72 | <a href="/demo">demo</a> | |
| 73 | 73 | </h2> | |
| 74 | 74 | <p class="repo-desc">A demo repository</p> | |
| 75 | + | <div class="repo-topics" aria-hidden="true"> | |
| 76 | + | </div> | |
| 75 | 77 | <figure class="repo-pulse"> | |
| 76 | 78 | <svg viewBox="0 0 600 64" role="img" aria-label="Activity: 0 commits in the last 90 days" focusable="false"> | |
| 77 | 79 | <defs> | |
| @@ -88,15 +90,9 @@ | |||
| 88 | 90 | </path> | |
| 89 | 91 | <path class="pulse-line" d="M0 58 C98.3 58 491.7 58 590 58" vector-effect="non-scaling-stroke"> | |
| 90 | 92 | </path> | |
| 91 | - | <circle class="pulse-ring" cx="590" cy="58" r="4"> | |
| 92 | - | </circle> | |
| 93 | 93 | <circle class="pulse-dot" cx="590" cy="58" r="3.5"> | |
| 94 | 94 | </circle> | |
| 95 | 95 | </svg> | |
| 96 | - | <figcaption class="pulse-cap" aria-hidden="true"> | |
| 97 | - | <span>90 days ago</span> | |
| 98 | - | <span>today</span> | |
| 99 | - | </figcaption> | |
| 100 | 96 | </figure> | |
| 101 | 97 | </div> | |
| 102 | 98 | <div class="repo-head"> | |
| @@ -109,18 +105,18 @@ | |||
| 109 | 105 | <dt>last 30 days</dt> | |
| 110 | 106 | <dd>0</dd> | |
| 111 | 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 | 108 | <div> | |
| 119 | 109 | <dt>last activity</dt> | |
| 120 | 110 | <dd> | |
| 121 | 111 | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 122 | 112 | </dd> | |
| 123 | 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 | 120 | </dl> | |
| 125 | 121 | </div> | |
| 126 | 122 | <ul class="repo-commits" aria-label="Recent commits of demo"> | |
| @@ -142,6 +138,10 @@ | |||
| 142 | 138 | <a href="/quiet">quiet</a> | |
| 143 | 139 | </h2> | |
| 144 | 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 | 145 | </div> | |
| 146 | 146 | <div class="repo-head"> | |
| 147 | 147 | <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"> | ||