Referer badge: translucent theme-aware pill, compact UTM summary with click-to-copy

This commit is contained in:
2026-09-18 18:52:35 +00:00
parent 867132f26b
commit 2be3b32586
4 changed files with 122 additions and 44 deletions
+11 -2
View File
@@ -482,10 +482,19 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c
} }
/* The referer badge outgrows the 8rem trail-link cap (it carries the UTM /* The referer badge outgrows the 8rem trail-link cap (it carries the UTM
summary too); keep the inline-flex layout from the component. */ summary too); keep the inline-flex layout from the component. The
.visit-table .trail a.referer-badge { generic trail-link rule above would otherwise clip the badge (overflow:
hidden, hiding the absolutely positioned favicon) and cap its inner
link — the link is display: contents, so its parts lay out as badge
flex items. */
.visit-table .trail .referer-badge {
display: inline-flex; display: inline-flex;
max-width: 100%; max-width: 100%;
overflow: visible;
}
.visit-table .trail .referer-badge a.badge-link {
display: contents;
} }
/* Same flag chip as the visitor cells (VisitorCell.vue); the flags here /* Same flag chip as the visitor cells (VisitorCell.vue); the flags here
+75 -30
View File
@@ -1,11 +1,15 @@
<script setup> <script setup>
// Referer + UTM as one badge in the analytics visit/crawler tables: the // Referer + UTM as one badge in the analytics visit/crawler tables: the
// referer's favicon flush on the left, its host as the link text, then the // referer's favicon flush on the left, its host as the link text, then the
// UTM summary smaller/muted inside the same badge. The whole badge links to // UTM summary smaller/muted inside the same badge. Only the favicon and
// the referer origin (external, new tab) and carries a single one-fact- // host are the link (external, new tab) — everything else, padding and
// per-line tooltip (badge.title: origin, then each utm pair) — no titles on // UTM text included, copies the full utm tag list to the clipboard. The
// the inner elements. Referers are external, so there is no close event. // link is display: contents so its parts lay out as badge flex items.
// The badge carries a single one-fact-per-line tooltip (badge.title:
// origin, then each utm pair) — no titles on the inner elements.
// Referers are external, so there is no close event.
import { computed } from 'vue' import { computed } from 'vue'
import { copyList } from './analytics/format.js'
const props = defineProps({ const props = defineProps({
badge: { type: Object, required: true }, badge: { type: Object, required: true },
@@ -16,53 +20,94 @@ const favicon = computed(() => (props.badge.origin ? props.favicons?.[props.badg
</script> </script>
<template> <template>
<a class="referer-badge" <span class="referer-badge"
:href="badge.href || undefined" :class="{ 'with-icon': favicon, copyable: badge.utm }"
:title="badge.title" :title="badge.title"
:target="badge.href ? '_blank' : undefined" @click="badge.utm && copyList(badge.utmCopy, $event)">
:rel="badge.href ? 'noopener' : undefined"> <a v-if="badge.href" class="badge-link" :href="badge.href"
<img v-if="favicon" class="badge-favicon" :src="favicon" alt="" /> target="_blank" rel="noopener" @click.stop>
<span v-if="badge.label">{{ badge.label }}</span> <img v-if="favicon" class="badge-favicon" :src="favicon" alt="" />
<span v-if="badge.label">{{ badge.label }}</span>
</a>
<template v-else>
<img v-if="favicon" class="badge-favicon" :src="favicon" alt="" />
<span v-if="badge.label">{{ badge.label }}</span>
</template>
<small v-if="badge.utm" class="small">{{ badge.utm }}</small> <small v-if="badge.utm" class="small">{{ badge.utm }}</small>
</a> </span>
</template> </template>
<style scoped> <style scoped>
/* Browser-chrome chip on a fixed neutral palette (--badge-* in /* Browser-chrome chip on a translucent neutral wash (--badge-* in
pagerite.css, deliberately unthemed): black-on-transparent and pagerite.css, deliberately unthemed): black-on-transparent and
white-on-transparent favicons both stay legible on it, and the text is white-on-transparent favicons both stay legible on it. Colors go on the
always dark regardless of the theme's link/text colors. Colors go on the inner elements, so the theme's link color rules cannot cascade in. The
inner elements, so the theme's a / a:hover color rules (which target the padding is matched by negative margins so the chip's content stays
anchor) cannot cascade in. */ exactly where the bare text would sit without the badge — except on the
right, which keeps a small positive margin so the next trail item does
not abut the chip. */
.referer-badge { .referer-badge {
position: relative;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.35em; gap: 0.35em;
padding-right: 0.45em; /* Fixed line-height: the bar height is then exactly 1.2em + padding =
border-radius: 0.25rem; 1.6em, so the icon below can be sized to match precisely (an
absolutely positioned replaced element cannot derive its height from
top/bottom offsets — its aspect ratio wins and bottom is dropped). */
line-height: 1.2;
padding: 0.2em 0.5em;
margin: -0.2em 0.25em -0.2em -0.2em;
/* Fully rounded: the bar is exactly 1.6em tall, so a 0.8em radius makes
both ends semicircles — a pill, with a full circle around the favicon
on the left. */
border-radius: 0.8em;
background: var(--badge-bg); background: var(--badge-bg);
color: var(--badge-text); color: var(--badge-text);
white-space: nowrap; white-space: nowrap;
overflow: hidden;
} }
/* Flush with the badge's top/left/bottom edges: a full-height square (the /* Room for the absolutely positioned icon (its 1.6em width plus the gap). */
badge has no padding on those sides), corners clipped by the badge's .referer-badge.with-icon {
overflow: hidden border-radius. */ padding-left: 1.95em;
}
/* Exactly the bar's height (1.6em, see line-height above), flush to the
top/left/bottom borders. The badge does not clip it (no overflow:
hidden): the icon may stick out past the bar's rounded corners.
Absolute on purpose: an in-flow image is the flex
container's first item and would supply the badge's baseline (an image's
baseline is its bottom edge), pushing the badge text above the baseline
of the trail items that follow. Out of flow, the badge's baseline comes
from its text, so baselines match. */
.badge-favicon { .badge-favicon {
width: 1.5em; position: absolute;
height: 1.5em; top: 0;
left: 0;
width: 1.6em;
height: 1.6em;
object-fit: cover; object-fit: cover;
flex: none;
} }
.referer-badge > span, /* The link is display: contents: the favicon and label lay out as flex
.referer-badge > small { items of the badge itself, and only their actual boxes are clickable. */
.badge-link {
display: contents;
}
/* Click-to-copy affordance on everything outside the link (the UTM text
and the surrounding padding). */
.referer-badge.copyable {
cursor: pointer;
}
.referer-badge span,
.referer-badge small {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.referer-badge > span { color: var(--badge-text); } .referer-badge span { color: var(--badge-text); }
.referer-badge > small { color: var(--badge-muted); } .referer-badge small { color: var(--badge-muted); }
</style> </style>
+29 -6
View File
@@ -180,22 +180,45 @@ function stepOf(path, titles) {
/** /**
* Badge data combining a visit's/crawler's external referer origin with the * Badge data combining a visit's/crawler's external referer origin with the
* visit's UTM tags: the origin as the badge link/label (the favicon is * visit's UTM tags: the origin as the badge link/label (the favicon is
* looked up by origin in the component), the known UTM values as a short * looked up by origin in the component), a compact UTM summary (the few
* inline summary, and a one-fact-per-line tooltip — the full origin URL on * most informative values) as ``utm`` with the full ``utm_*=value`` list
* the first line, then every ``utm_*=value`` pair. Null when there is no * as ``utmCopy`` for click-to-copy, and a one-fact-per-line tooltip — the
* external referer and no UTM tag (a plain direct visit). * full origin URL on the first line, then every ``utm_*=value`` pair.
* Null when there is no external referer and no UTM tag (a plain direct
* visit).
*/ */
function refererBadgeOf(referer, titles, utmTags = {}) { function refererBadgeOf(referer, titles, utmTags = {}) {
const step = stepOf(referer, titles) const step = stepOf(referer, titles)
const external = step?.external ? step : null const external = step?.external ? step : null
const known = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'] // Compact UTM summary, in display order source, campaign, content, term:
const utm = known.map((k) => utmTags[k]).filter(Boolean).join(' · ') // source only when no referer is known (it just repeats where the visitor
// came from), content only as a stand-in when there is no term. The
// remaining tags (medium and any nonstandard utm_*) fill in only when
// fewer than three of these more useful items exist — and never when a
// term is present (the term alone says enough). The tooltip keeps
// every tag, one pair per line.
const useful = []
if (utmTags.utm_source && !referer) useful.push(utmTags.utm_source)
if (utmTags.utm_campaign) useful.push(utmTags.utm_campaign)
if (utmTags.utm_content && !utmTags.utm_term) useful.push(utmTags.utm_content)
if (utmTags.utm_term) useful.push(utmTags.utm_term)
const rest = useful.length < 3 && !utmTags.utm_term
? Object.keys(utmTags)
.filter((k) => !['utm_source', 'utm_campaign', 'utm_content', 'utm_term'].includes(k))
.map((k) => utmTags[k])
.filter(Boolean)
: []
const utm = [...useful, ...rest].join(' · ')
if (!external && !utm) return null if (!external && !utm) return null
const utmCopy = Object.entries(utmTags)
.map(([k, value]) => `${k}=${value}`)
.join('\n')
return { return {
href: external?.origin || '', href: external?.origin || '',
label: external?.slug || '', label: external?.slug || '',
origin: external?.origin || '', origin: external?.origin || '',
utm, utm,
utmCopy,
title: [ title: [
...(external ? [external.origin] : []), ...(external ? [external.origin] : []),
...Object.entries(utmTags).map(([k, value]) => `${k}=${value}`), ...Object.entries(utmTags).map(([k, value]) => `${k}=${value}`),
+7 -6
View File
@@ -26,14 +26,15 @@
/* Selection fill for page text and the CodeMirror editors; themes /* Selection fill for page text and the CodeMirror editors; themes
override when the accent tint clashes with accent-colored text. */ override when the accent tint clashes with accent-colored text. */
--selection-bg: color-mix(var(--accent) 30%, transparent); --selection-bg: color-mix(var(--accent) 30%, transparent);
/* Referer-badge chip in the analytics viewer: a fixed neutral palette, /* Referer-badge chip in the analytics viewer: a translucent neutral wash,
deliberately NOT themed — the chip sits behind transparent favicons, so deliberately NOT themed — the chip sits behind transparent favicons, so
black-on-transparent and white-on-transparent glyphs must both stay black-on-transparent and white-on-transparent glyphs must both stay
legible on every theme (dark greys kill black glyphs, pure white kills legible on every theme (a slight whitening keeps black glyphs readable
white ones); its text likewise stays dark on any theme. */ on dark bars without a glaring solid-white chip). Being translucent, it
--badge-bg: #c9d1d9; takes the page's tone, so its text follows the theme's colors. */
--badge-text: #1f2328; --badge-bg: #aaaaaa44;
--badge-muted: #59636e; --badge-text: var(--text);
--badge-muted: var(--muted);
/* Code highlighting palette, consumed by pygments.css: complete light and /* Code highlighting palette, consumed by pygments.css: complete light and
dark sets (background included), resolved by light-dark() from the dark sets (background included), resolved by light-dark() from the
used color-scheme. A theme picks a set simply by declaring used color-scheme. A theme picks a set simply by declaring