diff --git a/web/app.js b/web/app.js index f301ea1..e8ac4ea 100644 --- a/web/app.js +++ b/web/app.js @@ -402,7 +402,7 @@ function activityRepos() { if (!item || item.redacted) return; const key = normalRepoUrl(item.url || ""); if (!key.startsWith("https://tangled.org/") || allowed.has(key)) return; - allowed.set(key, { url: item.url, name: item.full_name || item.name || key, site: item.site }); + allowed.set(key, { url: item.url, name: item.full_name || item.name || key, project: item }); }; (data.repos || []).forEach(add); (data.projects || []).forEach(add); @@ -457,11 +457,9 @@ function fillActivityRow(row, item, feed, repos) { item.repo && typeof item.repo.url === "string" ? repos.get(normalRepoUrl(item.repo.url)) : undefined; if (repo) { const project = el("span", "activity-project"); - const link = el("a", "activity-repo", repo.name); - link.href = repo.url; - project.append(link); - const site = siteLink(repo); - if (site) project.append(site); + project.append(el("span", "activity-repo", repo.name)); + const links = projectLinks(repo.project); + if (links) project.append(links); meta.append(project); } } @@ -1020,7 +1018,7 @@ async function openDay(day) { ] .filter(Boolean) .join(" · "); - // A project's site link shows on its first row only. + // A project's links show on its first row only. const linked = new Set(); for (const commit of commits) { const project = (shard.projects || [])[commit.project] || {}; @@ -1035,10 +1033,10 @@ async function openDay(day) { const label = el("span", "day-project-name", project.name || ""); if (project.full_name) label.title = project.full_name; name.append(label); - const site = linked.has(commit.project) ? null : siteLink(project); - if (site) { + const links = linked.has(commit.project) ? null : projectLinks(project); + if (links) { linked.add(commit.project); - name.append(site); + name.append(links); } item.append(name); const sha = url ? el("a", "day-sha", short) : el("span", "day-sha", short); @@ -1170,28 +1168,65 @@ function projectIcon(project) { return icon; } -// A project's live site as a small "↗" after its name, opening in a new -// tab. The build sets `site` only for public repos with an obvious one. -function siteLink(project) { - const redacted = - !project || project.redacted || (project.repo_key || "").startsWith("github:redacted:"); - const site = redacted ? "" : project.site || ""; - if (!/^https:\/\//.test(site)) return null; - let host; +function hostOf(url) { try { - host = new URL(site).host; + return /^https?:\/\//.test(url || "") ? new URL(url).host : ""; } catch { - return null; + return ""; } - const link = el("a", "site-link", "↗"); +} + +function redactedProject(project) { + return !project || project.redacted || (project.repo_key || "").startsWith("github:redacted:"); +} + +// The project favicon, linking to the project's live site when the build +// found one. +function projectMark(project, small = false) { + const icon = projectIcon(project); + if (small) icon.classList.add("small"); + const site = redactedProject(project) ? "" : project.site || ""; + const host = site.startsWith("https://") ? hostOf(site) : ""; + if (!host) return icon; + const link = el("a", "project-site"); link.href = site; link.target = "_blank"; link.rel = "noopener"; - link.title = `${host} · live site`; - link.setAttribute("aria-label", `${host}, live site, opens in a new tab`); + link.title = host; + link.setAttribute("aria-label", host); + icon.title = ""; + link.append(icon); return link; } +// The Tangled or GitHub logo in the page's red, linking to the repository. +// `reserve` keeps an empty slot when there is none, so columns line up. +function sourceLink(project, small = false, reserve = false) { + const host = redactedProject(project) ? "" : hostOf(project.url); + if (!host) { + if (!reserve) return null; + const empty = el("span", "source-link empty"); + empty.setAttribute("aria-hidden", "true"); + return empty; + } + const forge = host === "github.com" ? "GitHub" : "Tangled"; + const link = el("a", `source-link source-${forge.toLowerCase()}${small ? " small" : ""}`); + link.href = project.url; + link.title = `${project.full_name || project.name} on ${forge}`; + link.setAttribute("aria-label", link.title); + return link; +} + +// Both links for a line of text that has no favicon of its own: the site +// (as the favicon) when there is one, then the repository. +function projectLinks(project) { + const links = el("span", "project-links"); + if (!redactedProject(project) && project.site) links.append(projectMark(project, true)); + const source = sourceLink(project, true); + if (source) links.append(source); + return links.childElementCount ? links : null; +} + // The site lists the busiest projects of every period; this ranks them // for the page's period and keeps as many as the site ranks per period. const PROJECTS_SHOWN = 5; @@ -1215,28 +1250,21 @@ function renderProjects() { const item = el("li", "project-row"); const rank = el("span", "project-rank", String(index + 1).padStart(2, "0")); - const icon = projectIcon(project); + const icon = el("span", "project-icons"); + icon.append(projectMark(project)); + const source = sourceLink(project, false, true); + if (source) icon.append(source); const body = el("div", "project-body"); - const name = project.url - ? el("a", "project-name") - : el("span", "project-name"); - if (project.url) name.href = project.url; + const name = el("span", "project-name"); name.append(projectLabel(project.full_name || project.name)); name.title = project.full_name || project.name; - const line = el("span", "project-line"); - line.append(name); - const site = siteLink(project); - if (site) { - line.classList.add("has-site"); - line.append(site); - } const meta = el( "span", "project-meta", project.redacted ? "redacted repo" : project.knot || "source", ); - body.append(line, meta); + body.append(name, meta); const commits = count(project); const total = el("span", "project-count", fmt(commits)); @@ -1442,8 +1470,8 @@ function renderLegend() { const swatch = el("span", "swatch"); swatch.style.background = seriesColor(series.color); item.append(swatch, el("span", "", series.label)); - const site = series.project && siteLink(series.project); - if (site) item.append(site); + const links = series.project && projectLinks(series.project); + if (links) item.append(links); list.append(item); } if (!state.data.burn) return; @@ -2380,13 +2408,12 @@ function renderTimelines() { const owner = lane.project.redacted ? "redacted" : (lane.project.full_name || "").split("/")[0]; const meta = el("span", "timeline-meta", `${owner ? `${owner} · ` : ""}${fmt(lane.total)}`); const text = el("span", "timeline-text"); - const line = el("span", "timeline-line"); - line.append(name); - const site = siteLink(lane.project); - if (site) line.append(site); - text.append(line, meta); + text.append(name, meta); label.title = lane.project.full_name || lane.project.name; - label.append(projectIcon(lane.project), text); + label.append(projectMark(lane.project)); + const source = sourceLink(lane.project, false, true); + if (source) label.append(source); + label.append(text); const svg = make("svg", { class: "timeline-plot", "aria-hidden": "true" }); row.append(label, svg); const peak = busiestWeek(view, lane); @@ -2603,18 +2630,13 @@ function renderRepos() { const max = Math.max(...repos.map((repo) => repo.mine), 1); for (const repo of repos) { const row = el("div", "repo-row"); - const name = repo.url ? el("a", "repo-name") : el("span", "repo-name"); - if (repo.url) name.href = repo.url; - name.append( - projectIcon(repo), - el("span", "repo-label", repo.full_name || repo.name), - ); + const name = el("span", "repo-name"); + name.append(projectMark(repo)); + const source = sourceLink(repo, false, true); + if (source) name.append(source); + name.append(el("span", "repo-label", repo.full_name || repo.name)); name.title = repo.full_name || repo.name; - const head = el("span", "repo-head"); - head.append(name); - const site = siteLink(repo); - if (site) head.append(site); - row.append(head, el("span", "repo-count", fmt(repo.mine))); + row.append(name, el("span", "repo-count", fmt(repo.mine))); row.append( el( "span", diff --git a/web/style.css b/web/style.css index 2522de3..59f3779 100644 --- a/web/style.css +++ b/web/style.css @@ -622,7 +622,7 @@ h2::before { .project-row { display: grid; - grid-template-columns: 18px 20px auto minmax(0, 1fr); + grid-template-columns: 18px auto auto minmax(0, 1fr); grid-template-areas: "rank body body body" ". icon count spark"; @@ -640,11 +640,13 @@ h2::before { text-align: right; } -.project-row > .project-icon { grid-area: icon; width: 20px; height: 20px; } +.project-row > .project-icons { grid-area: icon; display: flex; align-items: center; gap: 6px; } +.project-row .project-icon { width: 20px; height: 20px; } .project-row .project-favicon { width: 16px; height: 16px; } .project-icon { display: grid; + box-sizing: border-box; place-items: center; width: 26px; height: 26px; @@ -696,35 +698,40 @@ h2::before { .project-label { min-width: 0; } -/* A project's live site: a small arrow after its name, wherever it is named. - In the sidebar the name's last part reserves the arrow's room and the arrow - sits in it, so a wrapping name never leaves the arrow on a line alone. */ -.project-line { display: block; min-width: 0; line-height: 1.25; } -.project-line > .project-name { display: inline; } -.project-line.has-site .project-label-part:last-child { box-sizing: border-box; padding-right: 21px; } -.project-line > .site-link { margin-left: -16px; vertical-align: -2px; } +/* A project's links: the favicon opens its live site, and the forge logo + beside it, in the page's red, opens the repository. The logo's slot is the + favicon's size, and is kept empty on rows without one, so both line up. */ +.project-links { display: inline-flex; flex: none; align-items: center; gap: 4px; vertical-align: middle; } +.project-site { display: inline-flex; flex: none; border-radius: 6px; } +.project-icon.small { width: 14px; height: 14px; border-radius: 3px; font-size: 7px; } +.project-icon.small .project-favicon { width: 12px; height: 12px; } -.site-link { +.source-link { display: inline-grid; flex: none; place-items: center; - width: 16px; - height: 16px; - border: 1px solid var(--border-strong); + width: 20px; + height: 20px; border-radius: 4px; - color: var(--accent); - font-family: system-ui, sans-serif; - font-size: 11px; - font-weight: 700; - line-height: 1; - text-decoration: none; } -.site-link:hover { - border-color: var(--accent); - box-shadow: 0 0 8px var(--accent-dim); +/* The logo is masked on a pseudo-element so the focus ring isn't clipped. */ +.source-link::before { + width: 16px; + height: 16px; + background: var(--accent); + content: ""; + -webkit-mask: var(--glyph) center / contain no-repeat; + mask: var(--glyph) center / contain no-repeat; } +.source-link.small { width: 14px; height: 14px; border-radius: 3px; } +.source-link.small::before { width: 12px; height: 12px; } +.source-link.empty::before { content: none; } +.source-link:hover::before { background: var(--accent-soft); } +.source-tangled { --glyph: url("icons/tangled.svg"); } +.source-github { --glyph: url("icons/github.svg"); } + .project-label-part { display: inline-block; max-width: 100%; @@ -749,6 +756,7 @@ h2::before { .project-count { grid-area: count; display: inline-flex; + min-width: 5ch; align-items: center; gap: 4px; color: var(--ink); @@ -1138,13 +1146,6 @@ main > .half { grid-column: auto; min-width: 0; } font-weight: 550; } -.repo-head { - display: flex; - align-items: center; - gap: 5px; - min-width: 0; -} - .repo-name .repo-label { overflow-wrap: anywhere; white-space: normal; @@ -1279,9 +1280,8 @@ main > .half { grid-column: auto; min-width: 0; } .activity-meta > * + *::before { content: "· "; } .activity-agent { color: var(--ink-2); } -.activity-repo { color: var(--accent); } +.activity-repo { color: var(--ink-2); } .activity-project { display: inline-flex; align-items: center; gap: 4px; } -.activity-project > .site-link { width: 13px; height: 13px; font-size: 9px; } .activity-age, .activity-live { @@ -1959,7 +1959,6 @@ h2::before { content: none; } .timeline-label .project-icon { width: 20px; height: 20px; } .timeline-label .project-favicon { width: 16px; height: 16px; } .timeline-text { display: grid; gap: 2px; min-width: 0; line-height: 1.25; } -.timeline-line { display: flex; align-items: center; gap: 5px; min-width: 0; } .timeline-name { overflow: hidden;