diff --git a/app.css b/app.css index d16f3f0..caf778a 100644 --- a/app.css +++ b/app.css @@ -33,8 +33,20 @@ * { box-sizing: border-box; } html, body { margin: 0; } body { background: var(--ground); color: var(--ink); font-family: var(--sans); font-size: 14px; line-height: 1.45; } -a { color: var(--link); } -a:hover { color: var(--link-hover); } +a { color: var(--link); text-decoration-color: rgba(28, 92, 171, .35); text-underline-offset: 3px; } +a:hover { color: var(--link-hover); text-decoration-color: currentColor; } +/* Links to another page (go, back) are ink with an arrow that nudges on + hover; outside links (ext) keep the link colour and get ↗. */ +a.go, a.back { color: var(--ink); font-size: 13px; font-weight: 500; text-decoration: none; white-space: nowrap; } +a.go:hover, a.back:hover { color: var(--link); } +a.go .ar { margin-left: 4px; } +a.back .ar { margin-right: 4px; } +a.ext .ar { margin-left: 2px; font-size: .8em; } +.ar { display: inline-block; transition: transform .15s; } +a.go:hover .ar { transform: translateX(3px); } +a.back:hover .ar { transform: translateX(-3px); } +a.ext:hover .ar { transform: translate(2px, -2px); } +@media (prefers-reduced-motion: reduce) { .ar { transition: none; } } :focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; } [hidden] { display: none !important; } .mono { font-family: var(--mono); font-size: 13px; } @@ -86,7 +98,7 @@ a.brand, a.brand:hover { color: #fff; text-decoration: none; } .titleline { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; } h1 { margin: 0; font-size: 26px; font-weight: 600; letter-spacing: -0.01em; overflow-wrap: anywhere; } .sub { margin: 4px 0 0; color: var(--ink-2); } -.back { font-size: 13px; margin-bottom: -8px; } +.back { margin-bottom: -8px; align-self: flex-start; } /* cards */ .card { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 20px; min-width: 0; } @@ -151,7 +163,7 @@ tr:last-child td { border-bottom: 0; } .num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } td .note { font-size: 12px; color: var(--ink-3); } a.pname { color: var(--ink); font-weight: 600; text-decoration: none; } -a.pname:hover, a.pname:focus-visible { color: var(--ink); text-decoration: underline; text-underline-offset: 3px; } +a.pname:hover { color: var(--link); } .empty { padding: 24px 12px; margin: 0; text-align: center; color: var(--ink-3); } /* forms */ @@ -334,8 +346,8 @@ dialog::backdrop { background: rgba(22, 23, 26, .55); } .setupbox { width: 100%; max-width: 400px; display: flex; flex-direction: column; gap: 24px; margin: auto 0; } .setupbox h1 { font-size: 22px; } .setupbox p { margin: 0; color: #c9c9c3; } -.setupbox a { color: #9cc3f5; } -.setupbox a:hover { color: #fff; } +.setupbox a { color: #9cc3f5; text-decoration-color: rgba(156, 195, 245, .4); } +.setupbox a:hover { color: #fff; text-decoration-color: currentColor; } .setupbox .center, .setupbox.center { text-align: center; display: flex; flex-direction: column; align-items: center; gap: 8px; } .setupbox.center { gap: 20px; } .setupbox .ghost { opacity: .45; } diff --git a/app.js b/app.js index df7dcd6..5000c7c 100644 --- a/app.js +++ b/app.js @@ -169,6 +169,14 @@ const badge = (st) => h('span', { class: 'badge' }, h('span', { class: st.dot }), st.label); + // go links to another page of the app; back returns to one. Their arrows + // nudge on hover. ext opens an outside page in a new tab, marked with ↗. + const arrow = (c) => h('span', { class: 'ar', 'aria-hidden': 'true' }, c); + const go = (href, text) => h('a', { class: 'go', href }, text, arrow('→')); + const back = (href, text) => h('a', { class: 'back', href }, arrow('←'), text); + const ext = (href, text) => h('a', { class: 'ext', href, target: '_blank', rel: 'noopener' }, text, arrow('↗'), h('span', { class: 'sr' }, ' (opens in a new tab)')); + const peerLink = (p) => h('a', { class: 'pname', href: '#/peers/' + p.id }, p.name); + // svg builds an SVG element; attrs are set as attributes. function svg(tag, attrs, ...kids) { const el = document.createElementNS('http://www.w3.org/2000/svg', tag); @@ -1086,17 +1094,17 @@ h('div', { class: 'cols' }, h('section', { class: 'card flush' }, - h('div', { class: 'cardhead' }, h('h2', null, 'Peers'), h('a', { href: '#/peers' }, 'All peers')), + h('div', { class: 'cardhead' }, h('h2', null, 'Peers'), go('#/peers', 'All peers')), top.length ? h('div', { class: 'tbl' }, h('table', { class: 'narrow' }, h('thead', null, h('tr', null, h('th', null, 'Name'), h('th', null, 'Status'), h('th', { class: 'num' }, 'Download, 24 h'), h('th', { class: 'num' }, 'Upload, 24 h'))), h('tbody', null, top.map((p) => h('tr', null, - h('td', null, h('a', { href: '#/peers/' + p.id }, p.name)), + h('td', null, peerLink(p)), h('td', null, badge(peerState(p))), h('td', { class: 'num' }, fmtBytes(p.stats.down24h)), h('td', { class: 'num' }, fmtBytes(p.stats.up24h))))))) - : h('p', { class: 'empty' }, 'No peers yet. ', h('a', { href: '#/peers/new' }, 'Add the first one'))), + : h('p', { class: 'empty' }, 'No peers yet. ', go('#/peers/new', 'Add the first one'))), logs ? h('section', { class: 'card' }, - h('div', { class: 'cardhead' }, h('h2', null, 'Recent activity'), h('a', { href: '#/log' }, 'Log')), + h('div', { class: 'cardhead' }, h('h2', null, 'Recent activity'), go('#/log', 'Log')), logs.lines.length ? h('div', null, logs.lines.map((l) => h('div', { class: 'ev' }, h('time', { datetime: l.time }, fmtWhen(l.time)), h('span', null, describeAudit(l))))) : h('p', { class: 'empty' }, 'No changes yet.')) : null)); @@ -1294,7 +1302,7 @@ h('tbody', null, online.map(({ p, r, hist }) => { const idle = r[0] + r[1] < IDLE_BPS; return h('tr', { class: idle ? 'idle' : null }, - h('td', null, h('a', { href: '#/peers/' + p.id }, p.name), idle ? h('span', { class: 'tag plain' }, 'idle') : null), + h('td', null, peerLink(p), idle ? h('span', { class: 'tag plain' }, 'idle') : null), h('td', null, rateSpark(hist)), h('td', { class: 'num' }, rate(r[0], idle)), h('td', { class: 'num' }, rate(r[1], idle)), @@ -1424,7 +1432,7 @@ return hit && keep; }); tbody.replaceChildren(...sorted(rows).map((p) => h('tr', null, - h('td', null, h('a', { class: 'pname', href: '#/peers/' + p.id }, p.name), p.note ? h('div', { class: 'note' }, p.note) : null), + h('td', null, peerLink(p), p.note ? h('div', { class: 'note' }, p.note) : null), h('td', { class: 'mono' }, p.ipv4), h('td', null, badge(peerState(p))), h('td', { class: 'mono muted' }, p.stats.endpoint || '–', @@ -1579,7 +1587,7 @@ drawPreview(); fill(wrap, - h('a', { class: 'back', href: '#/peers' }, '← Peers'), + back('#/peers', 'Peers'), h('div', null, h('h1', null, 'Add peer'), h('p', { class: 'sub' }, 'Assigns the next free address and adds the peer to ' + srv.interface + ' without a restart.')), h('div', { class: 'split' }, form, h('aside', { class: 'card aside', 'aria-labelledby': 'pv' }, @@ -1736,7 +1744,7 @@ }; fill(wrap, - h('a', { class: 'back', href: '#/peers' }, '← Peers'), + back('#/peers', 'Peers'), h('div', { class: 'head' }, h('div', null, h('div', { class: 'titleline' }, h('h1', null, p.name), badge(st.key === 'online' ? { ...st, label: 'Online · handshake ' + ago(p.stats.lastHandshake) } : st)), @@ -1787,7 +1795,7 @@ : h('p', { class: 'empty' }, 'No connections recorded yet.'), sessions.length > SHORT ? h('div', { style: { margin: '8px 12px 0' } }, sessMore) : null, h('p', { class: 'hint', style: { margin: '4px 12px 12px' } }, 'Country and network: ', - h('a', { href: 'https://db-ip.com', target: '_blank', rel: 'noopener' }, 'IP Geolocation by DB-IP'), + ext('https://db-ip.com', 'IP Geolocation by DB-IP'), '. Kept as long as the daily traffic history.')), h('form', { class: 'card', onSubmit: save }, @@ -2117,7 +2125,7 @@ notes ? h('div', { class: 'upnotes' }, h('div', { class: 'hd' }, h('strong', null, 'What\'s new in ' + rel.version), h('span', { class: 'muted' }, 'Released ' + fmtDate(rel.published) + ' · from ' + srcName()), - h('a', { href: rel.url, target: '_blank', rel: 'noopener' }, 'Full notes on ' + srcName())), + ext(rel.url, 'Full notes on ' + srcName())), /security/i.test(notes.summary) ? h('p', { class: 'notice' }, 'Includes security fixes.') : null, notes.summary ? h('p', null, mdInline(notes.summary)) : null, notes.items.length ? h('ul', null, notes.items.map((t) => h('li', null, mdInline(t)))) : null) : null, @@ -2125,7 +2133,7 @@ h('div', { class: 'hd' }, h('strong', null, 'Update this server'), h('span', { class: 'muted' }, 'Run on the server. VPN connections stay up.')), h('pre', { class: 'code' }, cmds), h('div', null, h('button', { type: 'button', class: 'btn small', onClick: () => copy(cmds) }, 'Copy commands'))) : null, - st.available && !st.file ? h('p', null, 'No release file is built for this platform. ', h('a', { href: rel.url, target: '_blank', rel: 'noopener' }, 'See the release')) : null, + st.available && !st.file ? h('p', null, 'No release file is built for this platform. ', ext(rel.url, 'See the release')) : null, h('fieldset', { class: 'section' }, h('legend', { class: 'legend' }, 'Release source'), h('div', { class: 'grid' }, SOURCES.map(([k, name, where]) => h('label', { class: 'opt' }, h('input', { type: 'radio', name: 'upsrc', value: k, checked: st.source === k, onChange: () => save({ source: k }) }), diff --git a/setup.js b/setup.js index 3dffb30..a548ffd 100644 --- a/setup.js +++ b/setup.js @@ -20,6 +20,10 @@ } // Same drawing as favicon.svg. + // ext opens an outside page in a new tab, marked with ↗ as in the app. + const ext = (href, text) => h('a', { class: 'ext', href, target: '_blank', rel: 'noopener' }, text, + h('span', { class: 'ar', 'aria-hidden': 'true' }, '↗'), h('span', { class: 'sr' }, ' (opens in a new tab)')); + function logo(size, plain) { const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); for (const [k, v] of Object.entries({ width: size, height: size, viewBox: '0 0 64 64', 'aria-hidden': 'true' })) s.setAttribute(k, v); @@ -108,9 +112,9 @@ h('div', { class: 'notice' }, 'Save it now. This page can\'t be opened again: the private key exists only here and isn\'t stored anywhere.'), h('ol', { class: 'steps' }, step(1, 'Install WireGuard', - h('p', null, h('a', { href: 'https://apps.apple.com/app/wireguard/id1441195209', rel: 'noopener' }, 'App Store'), ' · ', - h('a', { href: 'https://play.google.com/store/apps/details?id=com.wireguard.android', rel: 'noopener' }, 'Google Play'), ' · ', - h('a', { href: 'https://www.wireguard.com/install/', rel: 'noopener' }, 'Other systems'))), + h('p', null, ext('https://apps.apple.com/app/wireguard/id1441195209', 'App Store'), ' · ', + ext('https://play.google.com/store/apps/details?id=com.wireguard.android', 'Google Play'), ' · ', + ext('https://www.wireguard.com/install/', 'Other systems'))), step(2, 'Add the profile', h('button', { type: 'button', class: 'btn primary', onClick: download }, 'Download ' + file), h('p', null, 'Open the downloaded file with WireGuard, or in WireGuard tap + and choose “Create from file”.')),