⋯ 72 lines Show the 72 lines above this hunk @@ -73,11 +73,11 @@ const compare = queryParams.compare === '1' && !isAll 73 73 let prevFrom = new Date ( new Date ( from ) . getTime ( ) - days * 864e5 ) . toISOString ( ) 74 74 75 75 const ranges = [ 76 - { key : '24h' , label : ' 24 hours ' } , 77 - { key : '7d' , label : ' 7 days ' } , 78 - { key : '30d' , label : ' 30 days ' } , 79 - { key : '90d' , label : ' 90 days ' } , 80 - { key : '1y' , label : ' 1 year ' } , 76 + { key : '24h' , label : ' 24h ' } , 77 + { key : '7d' , label : ' 7d ' } , 78 + { key : '30d' , label : ' 30d ' } , 79 + { key : '90d' , label : ' 90d ' } , 80 + { key : '1y' , label : ' 1y ' } , 81 81 { key : 'all' , label : 'All' } , 82 82 ] 83 83 ⋯ 147 lines Show the 147 lines above this hunk @@ -231,6 +231,25 @@ let live = false 231 231 / / analyticshq_token cookie , which LoginAction sets server - side on sign - in ( # 33 ) . 232 232 let user = null 233 233 let ownedSites = [ ] 234 + let activeSiteRecord = null 235 + 236 + function siteDomains ( site ) { 237 + if ( ! site ? . domains ) 238 + return [ ] 239 + if ( Array . isArray ( site . domains ) ) 240 + return site . domains . map ( String ) . filter ( Boolean ) 241 + try { 242 + const parsed = JSON . parse ( String ( site . domains ) ) 243 + return Array . isArray ( parsed ) ? parsed . map ( String ) . filter ( Boolean ) : [ ] 244 + } 245 + catch { 246 + return [ ] 247 + } 248 + } 249 + 250 + function siteLabel ( site ) { 251 + return siteDomains ( site ) [ 0 ] | | site ? . name | | site ? . id | | 'Untitled site' 252 + } 234 253 / / Read gate : the dashboard only exposes a site 's data to someone this site is 235 254 / / actually reachable by — its owner , an invited member , OR anyone holding a valid 236 255 / / ? share = token . Stays false ( → no queries , no data in the response ) for anyone else . ⋯ 68 lines Show the 68 lines above this hunk @@ -305,7 +324,7 @@ async function loadData() { 305 324 / / Mirrors routes / analytics . ts GET / api / sites deliberately : two answers to 306 325 / / "which sites can I reach" that disagree is what produced this . 307 326 ownedSites = ( await pgq ( 308 - `SELECT s.id, s.name, CASE WHEN s.owner_id = ? THEN 'owner' ELSE m.role END AS role 327 + `SELECT s.id, s.name, s.domains, CASE WHEN s.owner_id = ? THEN 'owner' ELSE m.role END AS role 309 328 FROM sites s 310 329 LEFT JOIN site_members m ON m . site_id = s . id AND m . user_id = ? 311 330 WHERE s . owner_id = ? OR m . user_id IS NOT NULL ⋯ 47 lines Show the 47 lines above this hunk @@ -359,6 +378,9 @@ async function loadData() { 359 378 } 360 379 if ( ! viewGranted ) 361 380 return 381 + activeSiteRecord = ownedSites . find ( s = > String ( s . id ) = = = String ( siteId ) ) 382 + | | ( await pgq ( `SELECT id, name, domains FROM sites WHERE id = ? LIMIT 1` , [ String ( siteId ) ] ) ) ? . [ 0 ] 383 + | | null 362 384 / / Owner ( not a share viewer ) : surface any existing share token for the link UI . 363 385 if ( ! shareMode ) 364 386 shareToken = ( await readSettings ( siteId ) ) . share_token | | '' ⋯ 653 lines Show the 653 lines above this hunk @@ -1018,7 +1040,11 @@ const ownsActive = viewGranted && !shareMode 1018 1040 / / but cannot invite or remove . The endpoints enforce this independently ; hiding 1019 1041 / / the controls only avoids offering a button that answers 403. 1020 1042 const canManageTeam = ownsActive & & ( activeRole = = = 'owner' | | activeRole = = = 'admin' ) 1021 - const showSiteSelect = ownsActive & & ownedSites . length > 1 1043 + const showSiteSelect = ownsActive & & ownedSites . length > 0 1044 + const activeSite = activeSiteRecord | | ownedSites . find ( s = > String ( s . id ) = = = String ( siteId ) ) | | null 1045 + const activeSiteName = activeSite ? . name | | 'Shared site' 1046 + const activeSiteDomain = siteDomains ( activeSite ) [ 0 ] | | '' 1047 + const activeSiteLabel = activeSiteDomain | | activeSiteName 1022 1048 1023 1049 / / The install snippets , one per framework ( app / Analytics / install . ts ) . 1024 1050 / / ⋯ 318 lines Show the 318 lines above this hunk @@ -1343,7 +1369,7 @@ async function importFromGa() { 1343 1369 1344 1370 for ( let i = 0 ; i < chunks . length ; i + + ) { 1345 1371 const [ start , end ] = chunks [ i ] 1346 - gaProgress . set ( `Importing ${start.slice(0, 4)} — period ${i + 1} of ${chunks.length}…` ) 1372 + gaProgress . set ( `Importing ${start.slice(0, 4)} , period ${i + 1} of ${chunks.length}…` ) 1347 1373 try { 1348 1374 const res = await session . authFetch ( `/api/sites/${encodeURIComponent(activeSiteId)}/import/ga4` , { 1349 1375 method : 'POST' , ⋯ 455 lines Show the 455 lines above this hunk @@ -1805,7 +1831,7 @@ async function revokeShare() { 1805 1831 / / Worth surfacing rather than swallowing : a failed revoke means the old link is 1806 1832 / / still live , and silently clearing nothing looks identical to success . 1807 1833 if ( failed ) 1808 - await stxAlert ( 'Could not revoke the share link. It may still be active — please try again.' ) 1834 + await stxAlert ( 'Could not revoke the share link. It may still be active . Please try again.' ) 1809 1835 } 1810 1836 / / - - - Team - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1811 1837 / / The lists themselves are server - rendered ( see the panel markup ) . These only ⋯ 57 lines Show the 57 lines above this hunk @@ -1869,7 +1895,7 @@ async function revokeInvite(id: string, email: string) { 1869 1895 } 1870 1896 } 1871 1897 catch { } 1872 - await stxAlert ( 'Could not revoke that invitation. It may still be usable — please try again.' ) 1898 + await stxAlert ( 'Could not revoke that invitation. It may still be usable . Please try again.' ) 1873 1899 } 1874 1900 1875 1901 async function removeMember ( userId : string , email : string ) { ⋯ 7 lines Show the 7 lines above this hunk @@ -1883,7 +1909,7 @@ async function removeMember(userId: string, email: string) { 1883 1909 } 1884 1910 } 1885 1911 catch { } 1886 - await stxAlert ( 'Could not remove that person. They may still have access — please try again.' ) 1912 + await stxAlert ( 'Could not remove that person. They may still have access . Please try again.' ) 1887 1913 } 1888 1914 1889 1915 / / Created ONCE , not per click . useTimeout starts on creation and registers an ⋯ 148 lines Show the 148 lines above this hunk @@ -2038,27 +2064,188 @@ onMount(() => { 2038 2064 . panel { background : var ( - - panel ) ; border : 1 px solid var ( - - border ) ; border - radius : 12 px ; } 2039 2065 . pill { border - radius : 999 px ; transition : color 0.15 s ease , border - color 0.15 s ease , background - color 0.15 s ease ; } 2040 2066 2041 - / * Site switcher . The native chevron sat flush against the control 's own rounded edge, 2042 - so the appearance is reset and the chevron redrawn as a background SVG with room 2043 - reserved for it ( padding - right ) . Surface , border and radius come from . panel , which is 2044 - why the select is a panel and not a pill here — 999 px is what made the native chevron 2045 - collide . None of this reaches the open dropdown : the list itself is drawn by the OS and 2046 - is not stylable , which is why the fix for it landing on top of "+ Add site" is the row 2047 - layout below , not anything here . 2048 - The chevron is a data URI , so its colour cannot inherit : it is restated per theme , 2049 - media query first so the explicit [ data - theme ] toggle wins . * / 2050 - select . panel { 2051 - - webkit - appearance : none ; - moz - appearance : none ; appearance : none ; 2052 - cursor : pointer ; transition : border - color 0.15 s ease ; 2053 - padding - right : 2 rem ; background - repeat : no - repeat ; 2054 - background - position : right 0.7 rem center ; background - size : 13 px ; 2055 - background - image : url ( "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%2398a2b3' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E" ) ; 2067 + . workspace - header { 2068 + border - bottom : 1 px solid var ( - - border ) ; 2069 + } 2070 + . workspace - primary , 2071 + . workspace - range { 2072 + display : flex ; 2073 + align - items : center ; 2074 + justify - content : space - between ; 2075 + gap : 1 rem ; 2076 + } 2077 + . workspace - primary { padding : 0 0 1.1 rem ; } 2078 + . workspace - range { padding : 0.85 rem 0 ; } 2079 + . workspace - brand , 2080 + . workspace - actions , 2081 + . range - presets , 2082 + . range - actions { 2083 + display : flex ; 2084 + align - items : center ; 2085 + gap : 0.5 rem ; 2086 + } 2087 + . workspace - brand { min - width : 0 ; gap : 0.9 rem ; } 2088 + . workspace - actions { flex - shrink : 0 ; } 2089 + . site - switcher { 2090 + min - width : 11 rem ; 2091 + max - width : 18 rem ; 2092 + height : 2.25 rem ; 2093 + padding : 0 0.75 rem ; 2094 + font - size : 0.875 rem ; 2095 + font - weight : 500 ; 2096 + } 2097 + . site - static { 2098 + max - width : 18 rem ; 2099 + overflow : hidden ; 2100 + text - overflow : ellipsis ; 2101 + white - space : nowrap ; 2102 + color : var ( - - text - 2 ) ; 2103 + font : 500 0.875 rem / 1 var ( - - sans , ui - sans - serif ) ; 2104 + } 2105 + . range - presets { 2106 + gap : 0 ; 2107 + padding : 0.2 rem ; 2108 + border : 1 px solid var ( - - border ) ; 2109 + border - radius : 10 px ; 2110 + background : var ( - - panel ) ; 2111 + } 2112 + . range - link { 2113 + min - width : 2.75 rem ; 2114 + padding : 0.48 rem 0.7 rem ; 2115 + border - radius : 7 px ; 2116 + color : var ( - - text - 2 ) ; 2117 + font : 600 0.8125 rem / 1 var ( - - sans , ui - sans - serif ) ; 2118 + text - align : center ; 2119 + text - decoration : none ; 2120 + transition : color 0.15 s ease , background - color 0.15 s ease ; 2121 + } 2122 + . range - link : hover { color : var ( - - text ) ; } 2123 + . range - link [ aria - current = "page" ] { 2124 + background : var ( - - accent ) ; 2125 + color : # fff ; 2126 + } 2127 + . range - tool { 2128 + display : inline - flex ; 2129 + align - items : center ; 2130 + justify - content : center ; 2131 + gap : 0.4 rem ; 2132 + min - height : 2.25 rem ; 2133 + padding : 0 0.72 rem ; 2134 + border : 1 px solid var ( - - border ) ; 2135 + border - radius : 9 px ; 2136 + background : var ( - - panel ) ; 2137 + color : var ( - - text - 2 ) ; 2138 + font : 600 0.8125 rem / 1 var ( - - sans , ui - sans - serif ) ; 2139 + text - decoration : none ; 2140 + cursor : pointer ; 2141 + transition : color 0.15 s ease , border - color 0.15 s ease , background - color 0.15 s ease , transform 0.15 s ease ; 2142 + } 2143 + . range - tool : hover { color : var ( - - text ) ; border - color : var ( - - accent ) ; } 2144 + . range - tool : active { transform : scale ( 0.98 ) ; } 2145 + . range - tool : focus - visible , 2146 + . range - link : focus - visible , 2147 + . chrome - btn : focus - visible , 2148 + . icon - btn : focus - visible , 2149 + select . panel : focus - visible , 2150 + input : focus - visible , 2151 + textarea : focus - visible , 2152 + summary : focus - visible { 2153 + outline : 2 px solid var ( - - accent ) ; 2154 + outline - offset : 2 px ; 2155 + } 2156 + . range - tool . is - active { color : # fff ; background : var ( - - accent ) ; border - color : var ( - - accent ) ; } 2157 + . range - tool i { width : 1 rem ; height : 1 rem ; } 2158 + . date - menu { position : relative ; } 2159 + . date - menu > summary { list - style : none ; } 2160 + . date - menu > summary : : - webkit - details - marker { display : none ; } 2161 + . date - popover { 2162 + position : absolute ; 2163 + right : 0 ; 2164 + top : calc ( 100 % + 0.5 rem ) ; 2165 + z - index : 40 ; 2166 + width : min ( 24 rem , calc ( 100 vw - 2.5 rem ) ) ; 2167 + padding : 1 rem ; 2168 + border : 1 px solid var ( - - border ) ; 2169 + border - radius : 12 px ; 2170 + background : var ( - - panel ) ; 2171 + box - shadow : 0 18 px 50 px color - mix ( in srgb , var ( - - text ) 10 % , transparent ) ; 2172 + } 2173 + . date - grid { display : grid ; grid - template - columns : 1 fr 1 fr ; gap : 0.75 rem ; } 2174 + . date - field label { 2175 + display : block ; 2176 + margin - bottom : 0.35 rem ; 2177 + color : var ( - - text - 2 ) ; 2178 + font : 600 0.75 rem / 1.2 var ( - - sans , ui - sans - serif ) ; 2056 2179 } 2057 - @ media ( prefers - color - scheme : dark ) { 2058 - select . panel { background - image : url ( "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23667085' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E" ) ; } 2180 + . date - field input { 2181 + width : 100 % ; 2182 + padding : 0.55 rem 0.65 rem ; 2183 + border : 1 px solid var ( - - border ) ; 2184 + border - radius : 8 px ; 2185 + background : var ( - - bg ) ; 2186 + color : var ( - - text ) ; 2187 + font : 500 0.8125 rem / 1 var ( - - mono , ui - monospace ) ; 2188 + } 2189 + . date - submit { width : 100 % ; margin - top : 0.75 rem ; } 2190 + . live - status { 2191 + display : inline - flex ; 2192 + align - items : center ; 2193 + gap : 0.45 rem ; 2194 + min - height : 2.25 rem ; 2195 + padding : 0 0.7 rem ; 2196 + border : 1 px solid var ( - - border ) ; 2197 + border - radius : 9 px ; 2198 + color : var ( - - text - 2 ) ; 2199 + } 2200 + . live - status strong { color : var ( - - text ) ; font - size : 0.75 rem ; } 2201 + . live - status span : last - child { font - size : 0.75 rem ; } 2202 + . site - settings - shell > summary { 2203 + display : flex ; 2204 + align - items : center ; 2205 + justify - content : space - between ; 2206 + gap : 1 rem ; 2207 + width : 100 % ; 2208 + padding : 0.9 rem 1 rem ; 2209 + border : 0 ; 2210 + border - radius : inherit ; 2211 + background : transparent ; 2212 + color : var ( - - text ) ; 2213 + cursor : pointer ; 2214 + text - align : left ; 2215 + list - style : none ; 2216 + } 2217 + . site - settings - shell > summary : : - webkit - details - marker { display : none ; } 2218 + . site - settings - title { display : flex ; align - items : center ; gap : 0.7 rem ; min - width : 0 ; } 2219 + . site - settings - title i { width : 1.1 rem ; height : 1.1 rem ; color : var ( - - text - 2 ) ; } 2220 + . site - settings - title strong { display : block ; font - size : 0.875 rem ; line - height : 1.25 ; } 2221 + . site - settings - title span { display : block ; margin - top : 0.18 rem ; color : var ( - - text - 3 ) ; font - size : 0.75 rem ; line - height : 1.35 ; } 2222 + . settings - chevron { width : 1 rem ; height : 1 rem ; flex - shrink : 0 ; color : var ( - - text - 3 ) ; transition : transform 0.15 s ease ; } 2223 + . site - settings - shell [ open ] . settings - chevron { transform : rotate ( 180 deg ) ; } 2224 + . site - settings - shell : not ( [ open ] ) ~ . site - settings - panel { display : none ; } 2225 + . site - settings - panel { margin - bottom : 0.75 rem ; } 2226 + 2227 + @ media ( max - width : 1023 px ) { 2228 + . workspace - primary , 2229 + . workspace - range { align - items : stretch ; flex - direction : column ; } 2230 + . workspace - brand , 2231 + . workspace - actions , 2232 + . range - actions { width : 100 % ; flex - wrap : wrap ; } 2233 + . workspace - actions { justify - content : flex - start ; } 2234 + . site - switcher { min - width : 0 ; max - width : none ; flex : 1 ; } 2235 + . range - presets { width : 100 % ; overflow - x : auto ; } 2236 + . range - link { flex : 1 0 auto ; } 2237 + . range - actions { justify - content : flex - start ; } 2238 + . date - menu { position : static ; } 2239 + . date - popover { left : 0 ; right : auto ; } 2240 + } 2241 + 2242 + / * Site switcher . Surface , border and radius come from . panel . The native menu remains 2243 + an operating - system control , while the surrounding shell gives it enough room to 2244 + avoid colliding with adjacent actions . * / 2245 + select . panel { 2246 + cursor : pointer ; 2247 + transition : border - color 0.15 s ease ; 2059 2248 } 2060 - : root [ data - theme = "dark" ] select . panel { background - image : url ( "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23667085' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E" ) ; } 2061 - : root [ data - theme = "light" ] select . panel { background - image : url ( "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%2398a2b3' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E" ) ; } 2062 2249 select . panel : hover { border - color : var ( - - accent ) ; } 2063 2250 2064 2251 / * Map tooltips , themed . ts - maps binds and positions these itself , but only its ⋯ 96 lines Show the 96 lines above this hunk @@ -2161,116 +2348,98 @@ select.panel:hover { border-color: var(--accent); } 2161 2348 LAST < / main > , so a page that declares its own makes the router swap the wrong span . 2162 2349 Same classes as before , one tag name different . - - } } 2163 2350 @ section ( 'content' ) 2164 - < div class = "px- 5 py- 10 md:px-10 min-h-[100dvh]" > 2165 - { { - - Account chrome ( fixed top - right ) . Owner - only ( ownsActive ) — a read - only share 2166 - viewer isn 't signed in as the owner. 2167 - 2168 - Three controls , and deliberately no plan CTA . There used to be an Upgrade 2169 - button here , which was duplication : / account already carries a Plan section 2170 - showing Free or Pro , an active badge , and the same / pricing link as either 2171 - "Upgrade to Pro" or "Manage plan" . Billing belongs with the account , not 2172 - pinned to every view of the numbers . 2173 - 2174 - The route to / account is the point of this block . That page has existed and 2175 - answered 200 all along with nothing anywhere linking to it , so the only way 2176 - in was to type the URL . 2177 - 2178 - isPro ( ) is still read further down for the team - panel paywall — dropping the 2179 - button here did not orphan it . - - } } 2180 - @ if ( ownsActive ) 2181 - < div style = "position:fixed;top:16px;right:18px;z-index:60;display:flex;gap:6px;align-items:center" > 2182 - < StxLink to = "/account" class = "chrome-btn" > Account < / StxLink > 2183 - < button type = "button" @ click = "logout" class = "chrome-btn" > Log out < / button > 2184 - < / div > 2185 - @ endif 2186 - < div class = "mx-auto max-w-6xl" > 2187 - 2188 - < header class = "flex flex-wrap gap-5 items-end justify-between mb-9" > 2189 - < div > 2190 - < h1 class = "leading-none text-2xl md:text-[28px] wordmark" > analyticshq < / h1 > 2191 - < p class = "mt-2 text-sm text-text-3" > 2192 - Site < span class = "text-text-2 mono" > { { siteId } } < / span > · { { isAll ? 'all time' : ( 'last ' + days + ' days' ) } } 2193 - < / p > 2194 - { { - - Site switcher ( server - rendered , owner - scoped — mirrors bughq ) : the 2195 - < select > hands off to switchSite , which routes to ? site = ; the caption line 2196 - above is the no - owned - sites fallback . "+ Add site" is a link to the 2197 - reactive create page , not an in - page modal . - - } } 2198 - { { - - The multi - site < select > is gated by a precomputed `showSiteSelect` flag as 2199 - its own block , and "+ Add site" always shows to the owner . This was 2200 - originally a workaround for stx miscounting @ endif on a nested @ if ; that 2201 - bug is fixed as of 0.2 . 155 ( verified on the installed version ) , so the flag 2202 - stays only because it reads better than nesting here . - - } } 2203 - { { - - One row , like bughq 's header: the switcher, "+ Add site" and the live badge 2204 - were three sibling `mt-3` blocks in a plain < div > , so each got its own line 2205 - and the three read as a ragged column . They are peers , so they share one 2206 - flex row and one size scale ( px - 3 py - 1.5 , . panel / . pill chrome from classes 2207 - rather than per - control inline style ) . The badge keeps its own wrapper : its 2208 - spans render reactive { { } } text , and stx cloaks ( display : none ) the element 2209 - whose text is reactive until hydration — folding it into the row itself 2210 - would put the switcher inside that scope . - - } } 2211 - < div class = "flex flex-wrap gap-3 items-center mt-3" > 2351 + < div class = "px- 4 py- 7 md :px-8 lg :px-10 min-h-[100dvh]" > 2352 + < div class = "mx-auto max-w-[1400px]" > 2353 + 2354 + < header class = "mb-6 workspace-header" > 2355 + < div class = "workspace-primary" > 2356 + < div class = "workspace-brand" > 2357 + < h1 class = "leading-none text-2xl md:text-[28px] wordmark" > analyticshq < / h1 > 2212 2358 @ if ( showSiteSelect ) 2213 - < select @ change = "switchSite" 2214 - aria - label = "Switch site" 2215 - class = "px-3 py-1.5 text-sm text-text-2 mono panel" > 2359 + < select @ change = "switchSite" aria - label = "Switch site" class = "panel site-switcher" > 2216 2360 @ foreach ( ownedSites as s ) 2217 - < option value = "" { { String ( s . id ) = = = String ( siteId ) ? 'selected' : '' } } > { { s . name | | s . id } } < / option > 2361 + < option value = "" { { String ( s . id ) = = = String ( siteId ) ? 'selected' : '' } } > { { siteLabel ( s ) } } < / option > 2218 2362 @ endforeach 2219 2363 < / select > 2364 + @ else 2365 + < span class = "site-static" title = "" > { { activeSiteLabel } } < / span > 2220 2366 @ endif 2367 + < / div > 2368 + 2369 + < div class = "workspace-actions" > 2370 + < div class = "live-status reveal" aria - label = "Current visitors" > 2371 + < span : class = "liveCount() ? 'live-dot' : ''" : style = "`position: relative; display: inline-block; width: 7px; height: 7px; border-radius: 999px; background: ${liveCount() ? 'var(--accent)' : 'var(--text-3)'};`" > < / span > 2372 + < strong class = "mono" > { { liveCount ( ) } } < / strong > 2373 + < span > live < / span > 2374 + < / div > 2221 2375 @ if ( ownsActive ) 2222 - < StxLink to = "/sites/new" class = " px-3 py-1.5 font-medium text-sm text-text-2 border border - border pill " > + Add site < / StxLink > 2376 + < StxLink to = "/sites/new" class = " range - tool " > 2377 + < i class = "i-hugeicons-add-01" aria - hidden = "true" > < / i > 2378 + New site 2379 + < / StxLink > 2380 + < a href = "#site-settings" data - no - router class = "range-tool" title = "Site settings" > 2381 + < i class = "i-hugeicons-settings-02" aria - hidden = "true" > < / i > 2382 + < span class = "sr-only" > Site settings < / span > 2383 + < / a > 2223 2384 @ endif 2224 - { { - - The theme toggle sits in this row rather than in a fixed bar over the 2225 - page . It used to be `position: fixed; top: 16px; right: 18px` , which 2226 - floated it above whatever was underneath - - on this page that is the 2227 - map , and the button landed on top of it . In the row it is laid out 2228 - with its peers and cannot overlap anything . 2229 - 2230 - SVG rather than the ☾ / ☀ glyphs it used to draw : those render at 2231 - whatever the emoji font decides , so the button 's height moved between 2232 - platforms while the pills beside it did not . - - } } 2233 - < button type = "button" @ click = "toggleTheme" : data - t = "theme.isDark() ? 'dark' : 'light'" 2234 - class = "icon-btn theme-btn" aria - label = "Toggle light or dark theme" title = "Toggle theme" > 2235 - < svg class = "ic-sun" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke - width = "2" stroke - linecap = "round" stroke - linejoin = "round" > < circle cx = "12" cy = "12" r = "5" / > < line x1 = "12" y1 = "1" x2 = "12" y2 = "3" / > < line x1 = "12" y1 = "21" x2 = "12" y2 = "23" / > < line x1 = "4.22" y1 = "4.22" x2 = "5.64" y2 = "5.64" / > < line x1 = "18.36" y1 = "18.36" x2 = "19.78" y2 = "19.78" / > < line x1 = "1" y1 = "12" x2 = "3" y2 = "12" / > < line x1 = "21" y1 = "12" x2 = "23" y2 = "12" / > < line x1 = "4.22" y1 = "19.78" x2 = "5.64" y2 = "18.36" / > < line x1 = "18.36" y1 = "5.64" x2 = "19.78" y2 = "4.22" / > < / svg > 2236 - < svg class = "ic-moon" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke - width = "2" stroke - linecap = "round" stroke - linejoin = "round" > < path d = "M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" / > < / svg > 2385 + < button type = "button" @ click = "toggleTheme" : data - t = "theme.isDark() ? 'dark' : 'light'" class = "range-tool theme-btn" aria - label = "Toggle light or dark theme" title = "Toggle theme" > 2386 + < i class = "i-hugeicons-sun-03 ic-sun" aria - hidden = "true" > < / i > 2387 + < i class = "i-hugeicons-moon-02 ic-moon" aria - hidden = "true" > < / i > 2388 + < / button > 2389 + @ if ( ownsActive ) 2390 + < StxLink to = "/account" class = "chrome-btn" > Account < / StxLink > 2391 + < button type = "button" @ click = "logout" class = "chrome-btn" > Log out < / button > 2392 + @ endif 2393 + < / div > 2394 + < / div > 2395 + 2396 + < div class = "workspace-range" > 2397 + < nav class = "range-presets" aria - label = "Reporting period" > 2398 + @ foreach ( ranges as r ) 2399 + < StxLink 2400 + to = "?site=&range=" 2401 + class = "range-link" 2402 + aria - current = "" 2403 + > { { r . label } } < / StxLink > 2404 + @ endforeach 2405 + < / nav > 2406 + 2407 + < div class = "range-actions" > 2408 + < details class = "date-menu" > 2409 + < summary class = "range-tool " > 2410 + < i class = "i-hugeicons-calendar-03" aria - hidden = "true" > < / i > 2411 + { { isCustom ? ( customFrom + ' to ' + customTo ) : 'Custom' } } 2412 + < / summary > 2413 + < form method = "get" class = "date-popover" aria - label = "Custom date range" > 2414 + < input type = "hidden" name = "site" value = "" > 2415 + @ if ( compare ) 2416 + < input type = "hidden" name = "compare" value = "1" > 2417 + @ endif 2418 + < div class = "date-grid" > 2419 + < div class = "date-field" > 2420 + < label for = "range-from" > From < / label > 2421 + < input id = "range-from" type = "date" name = "from" value = "" > 2422 + < / div > 2423 + < div class = "date-field" > 2424 + < label for = "range-to" > To < / label > 2425 + < input id = "range-to" type = "date" name = "to" value = "" > 2426 + < / div > 2427 + < / div > 2428 + < button type = "submit" class = "date-submit is-active range-tool" > Apply range < / button > 2429 + < / form > 2430 + < / details > 2431 + < StxLink 2432 + to = "" 2433 + title = "Overlay the previous period on the chart" 2434 + class = "range-tool " 2435 + style = "" 2436 + > Compare < / StxLink > 2437 + < button type = "button" @ click = "exportCsv" class = "range-tool" > 2438 + < i class = "i-hugeicons-download-04" aria - hidden = "true" > < / i > 2439 + Export 2237 2440 < / button > 2238 - < div class = "inline-flex gap-2 items-center px-3 py-2 border border-border pill reveal" > 2239 - < span : class = "liveCount() ? 'live-dot' : ''" : style = "`position: relative; display: inline-block; width: 7px; height: 7px; border-radius: 999px; background: ${liveCount() ? 'var(--accent)' : 'var(--text-3)'};`" > < / span > 2240 - < span class = "font-semibold text-xs mono" : style = "`color: ${liveCount() ? 'var(--text)' : 'var(--text-3)'}`" > { { liveCount ( ) } } < / span > 2241 - < span class = "text-text-3 text-xs" > current { { liveCount ( ) = = = 1 ? 'visitor' : 'visitors' } } < / span > 2242 - < / div > 2243 2441 < / div > 2244 2442 < / div > 2245 - { { - - Wraps , unlike before : the left column is now one row rather than a stack , so 2246 - the two header halves compete for the same line and this nav carries eleven 2247 - controls with long range labels ( "24 hours" , not "24h" ) . - - } } 2248 - < nav class = "flex flex-wrap gap-1.5 items-center" > 2249 - @ foreach ( ranges as r ) 2250 - < StxLink to = "?site=&range=" 2251 - class = "px-3.5 py-1.5 font-medium text-sm border pill" 2252 - style = "" > { { r . label } } < / StxLink > 2253 - @ endforeach 2254 - < form method = "get" class = "inline-flex gap-1.5 items-center" title = "Custom date range" > 2255 - < input type = "hidden" name = "site" value = "" > 2256 - @ if ( compare ) 2257 - < input type = "hidden" name = "compare" value = "1" > 2258 - @ endif 2259 - < input type = "date" name = "from" value = "" aria - label = "From date" 2260 - class = "px-2 py-1.5 text-sm border pill" style = "color: var(--text-2); border-color: var(--border); background: var(--panel);" > 2261 - < span class = "text-text-3 text-xs" > to < / span > 2262 - < input type = "date" name = "to" value = "" aria - label = "To date" 2263 - class = "px-2 py-1.5 text-sm border pill" style = "color: var(--text-2); border-color: var(--border); background: var(--panel);" > 2264 - < button type = "submit" class = "px-3.5 py-1.5 font-medium text-sm border pill" 2265 - style = "color: var(--text-2); border-color: var(--border); background: var(--panel); cursor: pointer;" > Apply < / button > 2266 - < / form > 2267 - < StxLink to = "" title = "Overlay the previous period on the chart" 2268 - class = "px-3.5 py-1.5 font-medium text-sm border pill" 2269 - style = "color: var(--text-2); border-color: var(--border)" > Compare < / StxLink > 2270 - < button type = "button" @ click = "exportCsv" 2271 - class = "px-3.5 py-1.5 font-medium text-sm border pill" 2272 - style = "color: var(--text-2); border-color: var(--border); background: var(--panel); cursor: pointer;" > Export CSV < / button > 2273 - < / nav > 2274 2443 < / header > 2275 2444 2276 2445 { { - - Active filters : click a breakdown row to add one ; each pill removes itself , ⋯ 18 lines Show the 18 lines above this hunk @@ -2295,6 +2464,39 @@ select.panel:hover { border-color: var(--accent); } 2295 2464 < / div > 2296 2465 @ endif 2297 2466 2467 + @ if ( viewGranted ) 2468 + { { - - The numbers lead the workspace . Setup and account controls stay available , 2469 + but they no longer push the report below the first viewport . - - } } 2470 + < section class = "grid grid-cols-2 lg:grid-cols-7 mb-6 border-border lg:divide-x panel reveal" aria - label = "Key metrics" > 2471 + @ foreach ( kpiCards as kpi ) 2472 + < div class = "px-5 py-5 border-border" > 2473 + < div class = "mb-2 font-medium text-text-3 text-xs tracking-wider uppercase" > { { kpi . label } } < / div > 2474 + < div class = "font-semibold text-3xl tracking-tight mono" > { { kpi . value } } < / div > 2475 + @ if ( kpi . delta ! = = null ) 2476 + < div class = "mt-1.5 font-medium text-xs mono" style = "color: " > 2477 + { { kpi . delta > = 0 ? '▲' : '▼' } } { { Math . abs ( kpi . delta ) } } % < span class = "text-text-3" > vs prev < / span > 2478 + < / div > 2479 + @ endif 2480 + < / div > 2481 + @ endforeach 2482 + < / section > 2483 + @ endif 2484 + 2485 + @ if ( ownsActive ) 2486 + < details id = "site-settings" class = "mb-3 panel reveal site-settings-shell" > 2487 + < summary > 2488 + < span class = "site-settings-title" > 2489 + < i class = "i-hugeicons-settings-02" aria - hidden = "true" > < / i > 2490 + < span > 2491 + < strong > Site settings < / strong > 2492 + < span > Tracking , team access , imports , privacy , and sharing < / span > 2493 + < / span > 2494 + < / span > 2495 + < i class = "i-hugeicons-arrow-down-01 settings-chevron" aria - hidden = "true" > < / i > 2496 + < / summary > 2497 + < / details > 2498 + @ endif 2499 + 2298 2500 { { - - Setup panel : shown to the site 's owner so they can grab the install 2299 2501 snippet for whichever framework they run . Open by default until the first 2300 2502 pageview lands . ⋯ 3 lines Show the 3 lines above this hunk @@ -2304,11 +2506,11 @@ select.panel:hover { border-color: var(--accent); } 2304 2506 installCode ( ) note in the client block . { { } } HTML - escapes the snippet so 2305 2507 the tag shows as text rather than executing . - - } } 2306 2508 @ if ( ownsActive ) 2307 - < details class = " mb-8 border-border panel reveal " { { Number ( kpis . views ) ? '' : 'open' } } > 2308 - < summary class = "flex items-center justify-between px-5 py-4 " style = "cursor: pointer " > 2509 + < section class = "border-border panel site-settings-panel " > 2510 + < div class = "flex items-center justify-between px-5 py-4" > 2309 2511 < span class = "font-semibold text-sm" > Install analyticshq on this site < / span > 2310 2512 < span class = "px-2 py-0.5 text-[11px] rounded-full mono" style = "border: 1px solid var(--border); color: var(--text-3)" > Snippet < / span > 2311 - < / summary > 2513 + < / div > 2312 2514 < div class = "pb-5 px-5" > 2313 2515 { { - - Tabs and prose are SERVER - rendered ( @ foreach , static labels , server 2314 2516 mustaches ) so this panel still says something useful before the ⋯ 28 lines Show the 28 lines above this hunk @@ -2343,7 +2545,7 @@ select.panel:hover { border-color: var(--accent); } 2343 2545 answer here and the server cannot know it . - - } } 2344 2546 < pre class = "px-4 py-3 text-xs mono" style = "white-space: pre; overflow-x: auto; border-radius: 10px; border: 1px solid var(--border); background: var(--bg); color: var(--text-2); margin: 0" > { { installCode ( ) } } < / pre > 2345 2547 < / div > 2346 - < / details > 2548 + < / section > 2347 2549 @ endif 2348 2550 2349 2551 { { - - Team . Everyone who can reach this site , plus anyone invited who has not ⋯ 9 lines Show the 9 lines above this hunk @@ -2359,11 +2561,11 @@ select.panel:hover { border-color: var(--accent); } 2359 2561 Visible to any member ( a viewer should be able to tell how widely a site 2360 2562 is shared ) ; only admin and owner get the controls . - - } } 2361 2563 @ if ( ownsActive ) 2362 - < details class = " mb-8 border-border panel reveal " > 2363 - < summary class = "flex items-center justify-between px-5 py-4 " style = "cursor: pointer " > 2564 + < section class = "border-border panel site-settings-panel " > 2565 + < div class = "flex items-center justify-between px-5 py-4" > 2364 2566 < span class = "font-semibold text-sm" > Team < / span > 2365 2567 < span class = "px-2 py-0.5 text-[11px] rounded-full mono" style = "border: 1px solid var(--border); color: var(--text-3)" > { { teamMembers . length } } with access < / span > 2366 - < / summary > 2568 + < / div > 2367 2569 < div class = "pb-5 px-5" > 2368 2570 2369 2571 @ foreach ( teamMembers as m ) ⋯ 70 lines Show the 70 lines above this hunk @@ -2440,7 +2642,7 @@ select.panel:hover { border-color: var(--accent); } 2440 2642 @ endif 2441 2643 2442 2644 < / div > 2443 - < / details > 2645 + < / section > 2444 2646 @ endif 2445 2647 2446 2648 { { - - Import from Google Analytics . Owner - only : it writes history into this ⋯ 7 lines Show the 7 lines above this hunk @@ -2454,14 +2656,14 @@ select.panel:hover { border-color: var(--accent); } 2454 2656 conditional parts use : show with their SSR state in the static style 2455 2657 ( stx - standards § 7.1 / § 7.3 ) , as the share control above does . - - } } 2456 2658 @ if ( ownsActive ) 2457 - < details class = " mb-8 border-border panel reveal " > 2458 - < summary class = "flex items-center justify-between px-5 py-4 " style = "cursor: pointer " > 2659 + < section class = "border-border panel site-settings-panel " > 2660 + < div class = "flex items-center justify-between px-5 py-4" > 2459 2661 < span class = "font-semibold text-sm" > Import from Google Analytics < / span > 2460 2662 < span class = "px-2 py-0.5 text-[11px] rounded-full mono" style = "border: 1px solid var(--border); color: var(--text-3)" > GA4 < / span > 2461 - < / summary > 2663 + < / div > 2462 2664 < div class = "pb-5 px-5" > 2463 2665 < p class = "mb-3 text-sm text-text-2" > 2464 - Brings your GA4 history over directly — no CSV export . In the Google Cloud project that owns the property : 2666 + Brings your GA4 history over directly , with no CSV export . In the Google Cloud project that owns the property : 2465 2667 create a service account , download its JSON key , enable the Analytics Data API , then add that service 2466 2668 account 's email as a <span class="font-medium">Viewer</span> under GA4 → Admin → Property access management. 2467 2669 < / p > ⋯ 42 lines Show the 42 lines above this hunk @@ -2510,7 +2712,7 @@ select.panel:hover { border-color: var(--accent); } 2510 2712 Re - importing the same period replaces it rather than doubling it . 2511 2713 < / p > 2512 2714 < / div > 2513 - < / details > 2715 + < / section > 2514 2716 @ endif 2515 2717 2516 2718 { { - - Import from Fathom . Owner - only for the same reason as the GA panel , and ⋯ 12 lines Show the 12 lines above this hunk @@ -2529,11 +2731,11 @@ select.panel:hover { border-color: var(--accent); } 2529 2731 : dragover , which the runtime does not recognise as events and would 2530 2732 quietly turn into DOM attributes that never fire . - - } } 2531 2733 @ if ( ownsActive ) 2532 - < details class = " mb-8 border-border panel reveal " > 2533 - < summary class = "flex items-center justify-between px-5 py-4 " style = "cursor: pointer " > 2734 + < section class = "border-border panel site-settings-panel " > 2735 + < div class = "flex items-center justify-between px-5 py-4" > 2534 2736 < span class = "font-semibold text-sm" > Import from Fathom < / span > 2535 2737 < span class = "px-2 py-0.5 text-[11px] rounded-full mono" style = "border: 1px solid var(--border); color: var(--text-3)" > CSV < / span > 2536 - < / summary > 2738 + < / div > 2537 2739 < div class = "pb-5 px-5" > 2538 2740 @ if ( canImportCsv ) 2539 2741 < p class = "mb-3 text-sm text-text-2" > ⋯ 69 lines Show the 69 lines above this hunk @@ -2609,13 +2811,13 @@ select.panel:hover { border-color: var(--accent); } 2609 2811 < / div > 2610 2812 @ endif 2611 2813 < / div > 2612 - < / details > 2814 + < / section > 2613 2815 @ endif 2614 2816 2615 2817 @ if ( shareMode ) 2616 2818 < div class = "mb-6 px-4 py-3 text-sm panel reveal" style = "border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); color: var(--text-2)" > 2617 2819 < span class = "font-semibold text-text" > Shared read - only view . < / span > 2618 - You 're viewing a shared dashboard — data only , no sign -in needed . 2820 + This link shows report data only . Sign - in and management controls are unavailable . 2619 2821 < / div > 2620 2822 @ endif 2621 2823 ⋯ 1 line Show the 1 line above this hunk @@ -2623,7 +2825,7 @@ select.panel:hover { border-color: var(--accent); } 2623 2825 2624 2826 Two server @ ifs and no client @ if , for the usual reason : a client @ if 2625 2827 nested inside a server one breaks stx 's matching. The instance 2626 - branch is server - side because config / privacy . ts is a server value — an 2828 + branch is server - side because config / privacy . ts is a server value , so an 2627 2829 install that does not permit regions shows the reason rather than a 2628 2830 switch the endpoint would answer 409 to . 2629 2831 ⋯ 2 lines Show the 2 lines above this hunk @@ -2632,7 +2834,7 @@ select.panel:hover { border-color: var(--accent); } 2632 2834 optimistic tick that then failed would leave the box saying yes about a 2633 2835 site recording nothing . - - } } 2634 2836 @ if ( ownsActive ) 2635 - < div class = " mb-8 p-5 panel reveal " > 2837 + < div class = "p-5 panel site-settings-panel " > 2636 2838 < div class = "flex flex-wrap gap-3 items-start justify-between" > 2637 2839 < div style = "max-width:60ch" > 2638 2840 < div class = "font-semibold text-sm" > Record state and province < / div > ⋯ 27 lines Show the 27 lines above this hunk @@ -2666,15 +2868,15 @@ select.panel:hover { border-color: var(--accent); } 2666 2868 Signals live in the < script client > block ; shareUrl is seeded from the 2667 2869 server - bridged existing token so the current link shows on load . - - } } 2668 2870 @ if ( ownsActive ) 2669 - < div class = " mb-8 p-5 panel reveal " > 2871 + < div class = "p-5 panel site-settings-panel " > 2670 2872 < div class = "flex flex-wrap gap-3 items-center justify-between" > 2671 2873 < div > 2672 2874 < div class = "font-semibold text-sm" > Share this dashboard < / div > 2673 - < div class = "mt-0.5 text-text-3 text-xs" > A read - only link — anyone with it can view , but not manage , this site . < / div > 2875 + < div class = "mt-0.5 text-text-3 text-xs" > Anyone with this read - only link can view the report , but cannot manage the site . < / div > 2674 2876 < / div > 2675 2877 { { - - No @ if here : a client - signal @ if nested inside a server @ if breaks stx 's 2676 2878 @ endif matching . : show is the binding for this ( stx - standards § 7.3 ) and 2677 - carries its SSR state in the static style , per § 7.1 — the element ships 2879 + carries its SSR state in the static style , per § 7.1 . The element ships 2678 2880 hidden and the first effect reveals it . - - } } 2679 2881 < div class = "flex flex-wrap gap-2 items-center" : show = "shareUrl()" style = "display:none" > 2680 2882 < input readonly : value = "shareUrl()" aria - label = "Share link" class = "text-xs mono" style = "min-width:220px;max-width:340px;padding:6px 9px;border-radius:8px;border:1px solid var(--border);background:var(--bg);color:var(--text-2)" > ⋯ 10 lines Show the 10 lines above this hunk @@ -2691,21 +2893,6 @@ select.panel:hover { border-color: var(--accent); } 2691 2893 ( below , @ else ) instead of the panels . - - } } 2692 2894 @ if ( viewGranted ) 2693 2895 2694 - { { - - KPIs : hairline - separated , mono numerics , no heavy card chrome - - } } 2695 - < div class = "grid grid-cols-2 lg:grid-cols-7 mb-6 border-border lg:divide-x panel reveal" > 2696 - @ foreach ( kpiCards as kpi ) 2697 - < div class = "px-5 py-5 border-border" > 2698 - < div class = "mb-2 font-medium text-text-3 text-xs tracking-wider uppercase" > { { kpi . label } } < / div > 2699 - < div class = "font-semibold text-3xl tracking-tight mono" > { { kpi . value } } < / div > 2700 - @ if ( kpi . delta ! = = null ) 2701 - < div class = "mt-1.5 font-medium text-xs mono" style = "color: " > 2702 - { { kpi . delta > = 0 ? '▲' : '▼' } } { { Math . abs ( kpi . delta ) } } % < span class = "text-text-3" > vs prev < / span > 2703 - < / div > 2704 - @ endif 2705 - < / div > 2706 - @ endforeach 2707 - < / div > 2708 - 2709 2896 { { - - Timeseries : page views ( area ) + unique visitors ( line ) - - } } 2710 2897 < div class = "mb-6 p-5 panel reveal" > 2711 2898 < div class = "flex flex-wrap gap-3 items-center justify-between mb-5" > ⋯ 61 lines Show the 61 lines above this hunk @@ -2773,7 +2960,7 @@ select.panel:hover { border-color: var(--accent); } 2773 2960 < div class = "absolute inset-y-1 left-0 rounded" style = "width: %; background: var(--bar);" > < / div > 2774 2961 < span class = "relative pr-2 text-text-2 truncate ga-row-label mono" > { { p . path } } < / span > 2775 2962 < span class = "flex relative gap-3.5 items-baseline" > 2776 - < span class = "tabular-nums text-text-3 mono" title = "Bounce rate — of sessions that landed on this page , the share that bounced " > { { Number ( p . entries ) ? `${Math.round((Number(p.bounces) / Number(p.entries)) * 100)}%` : ' — ' } } < / span > 2963 + < span class = "tabular-nums text-text-3 mono" title = "Bounce rate for sessions that landed on this page" > { { Number ( p . entries ) ? `${Math.round((Number(p.bounces) / Number(p.entries)) * 100)}%` : ' n/a ' } } < / span > 2777 2964 < span class = "font-medium tabular-nums mono" > { { Number ( p . views ) . toLocaleString ( ) } } < / span > 2778 2965 < / span > 2779 2966 < / StxLink > ⋯ 90 lines Show the 90 lines above this hunk @@ -2870,7 +3057,7 @@ select.panel:hover { border-color: var(--accent); } 2870 3057 it is . Splitting by device divides the sample count , 2871 3058 so this is common on small sites and has to read as 2872 3059 "not enough people" rather than "broken" . - - } } 2873 - < span class = "text-text-3" title = "Withheld — NaN of measurements needed" > — < / span > 3060 + < span class = "text-text-3" title = "Withheld : NaN of measurements needed" > n / a < / span > 2874 3061 @ else 2875 3062 < span class = "text-text-3" title = "No measurements yet" > · < / span > 2876 3063 @ endif ⋯ 47 lines Show the 47 lines above this hunk @@ -2924,8 +3111,8 @@ select.panel:hover { border-color: var(--accent); } 2924 3111 < td class = "py-2 tabular-nums text-right mono" > { { Number ( q . impressions ) . toLocaleString ( ) } } < / td > 2925 3112 { { - - Derived here from the two stored counts , never read from a 2926 3113 ctr column - - see the migration for why one is not stored . - - } } 2927 - < td class = "py-2 tabular-nums text-right mono" > { { Number ( q . impressions ) > 0 ? `${(Number(q.clicks) / Number(q.impressions) * 100).toFixed(1)}%` : ' — ' } } < / td > 2928 - < td class = "pr-1 py-2 tabular-nums text-right mono" > { { q . position = = = null ? ' — ' : Number ( q . position ) . toFixed ( 1 ) } } < / td > 3114 + < td class = "py-2 tabular-nums text-right mono" > { { Number ( q . impressions ) > 0 ? `${(Number(q.clicks) / Number(q.impressions) * 100).toFixed(1)}%` : ' n/a ' } } < / td > 3115 + < td class = "pr-1 py-2 tabular-nums text-right mono" > { { q . position = = = null ? ' n/a ' : Number ( q . position ) . toFixed ( 1 ) } } < / td > 2929 3116 < / tr > 2930 3117 @ endforeach 2931 3118 < / tbody > ⋯ 478 lines Show the 478 lines above this hunk @@ -3410,7 +3597,7 @@ select.panel:hover { border-color: var(--accent); } 3410 3597 const note = document . querySelector ( '[data-country-missing]' ) 3411 3598 if ( note ) { 3412 3599 const label = missing . map ( c = > serverNames [ c ] | | names [ c ] | | c ) . join ( ', ' ) 3413 - note . textContent = `${label} ${missing.length === 1 ? 'is' : 'are'} too small to plot at this scale — see the list below.` 3600 + note . textContent = `${label} ${missing.length === 1 ? 'is' : 'are'} too small to plot at this scale . See the list below.` 3414 3601 note . removeAttribute ( 'hidden' ) 3415 3602 } 3416 3603 }