tacilytics 2.0.2 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/index.html CHANGED
@@ -8,7 +8,8 @@
8
8
  <link rel="icon" href="/assets/tacilytics-mark.svg" type="image/svg+xml">
9
9
  <meta name="theme-color" content="#071114">
10
10
  <link rel="preload" href="/assets/fonts/inter-latin-wght-normal.woff2" as="font" type="font/woff2" crossorigin>
11
- <link rel="stylesheet" href="/style.css?v=2.3.0">
11
+ <script src="/theme.js?v=1"></script>
12
+ <link rel="stylesheet" href="/style.css?v=2.5.0.5">
12
13
  </head>
13
14
  <body>
14
15
 
@@ -17,10 +18,48 @@
17
18
  <div class="modal">
18
19
  <img class="logo" src="/assets/tacilytics-mark.svg" alt="">
19
20
  <h2>Tacilytics</h2>
20
- <p>Cookieless analytics. Sign in with your passphrase.</p>
21
- <div id="loginErr" class="err"></div>
22
- <input id="pw" type="password" placeholder="Passphrase" autocomplete="current-password">
23
- <button class="btn" id="loginBtn">Sign in</button>
21
+ <p id="loginHint">Cookieless analytics. Sign in with your passphrase.</p>
22
+ <div id="loginErr" class="err" role="alert"></div>
23
+ <div id="loginStep1">
24
+ <input id="user" class="hide" type="text" placeholder="Username" autocomplete="username webauthn" autocapitalize="off" spellcheck="false">
25
+ <input id="pw" type="password" placeholder="Passphrase" autocomplete="current-password">
26
+ <button class="btn" id="loginBtn">Sign in</button>
27
+ <button class="btn ghost hide" id="passkeyBtn" style="margin-top:10px"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-key"></use></svg>Sign in with a passkey</button>
28
+ </div>
29
+ <div id="loginStep2" class="hide">
30
+ <input id="otp" type="text" inputmode="numeric" placeholder="6-digit code" autocomplete="one-time-code" maxlength="20">
31
+ <button class="btn" id="otpBtn">Verify</button>
32
+ <p class="muted small" style="margin:12px 0 0">Lost your phone? Enter one of your recovery codes instead.</p>
33
+ </div>
34
+ </div>
35
+ </div>
36
+
37
+ <!-- Reset: new password from a one-time link -->
38
+ <div id="reset" class="modal-overlay hide">
39
+ <div class="modal">
40
+ <img class="logo" src="/assets/tacilytics-mark.svg" alt="">
41
+ <h2>New password</h2>
42
+ <p id="resetHint">Choose a new password.</p>
43
+ <div id="resetErr" class="err" role="alert"></div>
44
+ <div id="resetForm">
45
+ <input id="resetPw" type="password" placeholder="New password (at least 10 characters)" autocomplete="new-password">
46
+ <button class="btn" id="resetBtn">Save password</button>
47
+ </div>
48
+ </div>
49
+ </div>
50
+
51
+ <!-- Invite: create an account from an invite link -->
52
+ <div id="invite" class="modal-overlay hide">
53
+ <div class="modal">
54
+ <img class="logo" src="/assets/tacilytics-mark.svg" alt="">
55
+ <h2>Join Tacilytics</h2>
56
+ <p id="inviteHint">Choose a username and a password.</p>
57
+ <div id="inviteErr" class="err" role="alert"></div>
58
+ <div id="inviteForm">
59
+ <input id="invUser" type="text" placeholder="Username" autocomplete="username" autocapitalize="off" spellcheck="false">
60
+ <input id="invPw" type="password" placeholder="Password (at least 10 characters)" autocomplete="new-password">
61
+ <button class="btn" id="invBtn">Create account</button>
62
+ </div>
24
63
  </div>
25
64
  </div>
26
65
 
@@ -37,7 +76,9 @@
37
76
  <div class="header-right">
38
77
  <div class="realtime" id="realtimeBtn" title="View live visitors" style="cursor:pointer"><span class="dot"></span><span id="realtime">0</span> online now</div>
39
78
  <select id="siteSelect"></select>
40
- <button class="btn ghost" id="addSiteBtn"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=1#i-plus"></use></svg>Add site</button>
79
+ <button class="btn ghost tz-btn" id="tzBtn" title="Time zone of this site — days start at midnight here"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-timer"></use></svg><span id="tzName">UTC</span></button>
80
+ <button class="btn ghost" id="addSiteBtn"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-plus"></use></svg>Add site</button>
81
+ <button class="btn ghost" id="settingsBtn" title="Account, security and team"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-settings"></use></svg><span id="meName">Settings</span></button>
41
82
  <button class="btn ghost danger" id="logoutBtn">Sign out</button>
42
83
  </div>
43
84
  </header>
@@ -52,12 +93,14 @@
52
93
  </div>
53
94
  <div class="toolbar-right">
54
95
  <div class="range" id="range">
55
- <input type="date" id="fromDate" title="From"><span class="arrow"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=1#i-arrow"></use></svg></span><input type="date" id="toDate" title="To">
96
+ <input type="date" id="fromDate" title="From"><span class="arrow"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-arrow"></use></svg></span><input type="date" id="toDate" title="To">
56
97
  <button class="btn ghost" id="applyRange">Apply</button>
57
98
  </div>
58
- <button class="btn ghost" id="exportBtn"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=1#i-download"></use></svg>CSV</button>
59
- <button class="btn ghost" id="shareBtn" title="Create a public read-only link"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=1#i-share"></use></svg>Share</button>
60
- <button class="btn ghost" id="snippetBtn"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=1#i-code"></use></svg>Embed code</button>
99
+ <button class="btn ghost" id="exportBtn"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-download"></use></svg>CSV</button>
100
+ <button class="btn ghost" id="alertsBtn" title="Get a message on traffic spikes, drops or reached goals"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-bell"></use></svg>Alerts</button>
101
+ <button class="btn ghost" id="shareBtn" title="Create a public read-only link"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-share"></use></svg>Share</button>
102
+ <button class="btn ghost" id="importBtn" title="Bring your history from Google Analytics or Plausible"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-download"></use></svg>Import</button>
103
+ <button class="btn ghost" id="snippetBtn"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-code"></use></svg>Embed code</button>
61
104
  </div>
62
105
  </div>
63
106
 
@@ -87,14 +130,16 @@
87
130
  <div class="legend">
88
131
  <button type="button" class="leg" id="leg-visitors" data-key="visitors" aria-pressed="true"><span class="swatch" style="background:var(--chart-visitors)"></span>Visitors</button>
89
132
  <button type="button" class="leg" id="leg-pageviews" data-key="pageviews" aria-pressed="true"><span class="swatch" style="background:var(--chart-pageviews)"></span>Pageviews</button>
133
+ <button type="button" class="leg" id="annBtn" title="Mark a day in the chart, e.g. a newsletter or a release"><span class="swatch ann"></span>Notes</button>
90
134
  </div>
91
135
  </div>
92
136
  <svg id="chart" viewBox="0 0 800 220" preserveAspectRatio="none"></svg>
137
+ <p class="muted small hide" id="importedNote" style="margin:8px 2px 0"></p>
93
138
  </div>
94
139
 
95
140
  <div class="panels">
96
141
  <div class="card panel">
97
- <h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=1#i-pages"></use></svg> Pages
142
+ <h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-pages"></use></svg> Pages
98
143
  <span class="tabs" id="pageTabs">
99
144
  <button data-t="pages" class="active">Top</button>
100
145
  <button data-t="entryPages">Entry</button>
@@ -104,7 +149,7 @@
104
149
  <div class="bars" id="p-pages"></div>
105
150
  </div>
106
151
  <div class="card panel">
107
- <h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=1#i-sources"></use></svg> Sources
152
+ <h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-sources"></use></svg> Sources
108
153
  <span class="tabs" id="srcTabs">
109
154
  <button data-t="sources" class="active">Source</button>
110
155
  <button data-t="mediums">Medium</button>
@@ -113,37 +158,37 @@
113
158
  </h3>
114
159
  <div class="bars" id="p-sources"></div>
115
160
  </div>
116
- <div class="card panel"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=1#i-globe"></use></svg> Locations</h3><div class="bars" id="p-countries"></div></div>
117
- <div class="card panel"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=1#i-browser"></use></svg> Browsers</h3><div class="bars" id="p-browsers"></div></div>
118
- <div class="card panel"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=1#i-os"></use></svg> OS</h3><div class="bars" id="p-os"></div></div>
119
- <div class="card panel"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=1#i-device"></use></svg> Devices</h3><div class="bars" id="p-devices"></div></div>
120
- <div class="card panel hide" id="w-platforms"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=1#i-platform"></use></svg> Platforms
161
+ <div class="card panel"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-globe"></use></svg> Locations</h3><div class="bars" id="p-countries"></div></div>
162
+ <div class="card panel"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-browser"></use></svg> Browsers</h3><div class="bars" id="p-browsers"></div></div>
163
+ <div class="card panel"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-os"></use></svg> OS</h3><div class="bars" id="p-os"></div></div>
164
+ <div class="card panel"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-device"></use></svg> Devices</h3><div class="bars" id="p-devices"></div></div>
165
+ <div class="card panel hide" id="w-platforms"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-platform"></use></svg> Platforms
121
166
  <span class="tabs" id="pfTabs"><button data-t="platforms" class="active">Platform</button><button data-t="appVersions">App version</button></span>
122
167
  </h3><div class="bars" id="p-platforms"></div></div>
123
- <div class="card panel"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=1#i-goal"></use></svg> Goals &amp; conversions</h3><div class="bars" id="p-goals"></div></div>
124
- <div class="card panel"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=1#i-lang"></use></svg> Languages</h3><div class="bars" id="p-languages"></div></div>
168
+ <div class="card panel"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-goal"></use></svg> Goals &amp; conversions</h3><div class="bars" id="p-goals"></div></div>
169
+ <div class="card panel"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-lang"></use></svg> Languages</h3><div class="bars" id="p-languages"></div></div>
125
170
  </div>
126
171
 
127
172
  <!-- shop insights: revenue by source, site search, broken pages, scroll depth (13.10.2026) -->
128
173
  <div class="panels" id="insights" style="margin-top:16px">
129
- <div class="card panel hide" id="w-revsrc"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=1#i-revenue"></use></svg> Revenue by source <span class="muted hint">first source of the buying visit</span></h3><div class="bars" id="p-revsrc"></div></div>
130
- <div class="card panel hide" id="w-revpf"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=1#i-platform"></use></svg> Revenue by platform <span class="muted hint">web vs. apps</span></h3><div class="bars" id="p-revpf"></div></div>
131
- <div class="card panel hide" id="w-search"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=1#i-search"></use></svg> Site search
174
+ <div class="card panel hide" id="w-revsrc"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-revenue"></use></svg> Revenue by source <span class="muted hint">first source of the buying visit</span></h3><div class="bars" id="p-revsrc"></div></div>
175
+ <div class="card panel hide" id="w-revpf"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-platform"></use></svg> Revenue by platform <span class="muted hint">web vs. apps</span></h3><div class="bars" id="p-revpf"></div></div>
176
+ <div class="card panel hide" id="w-search"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-search"></use></svg> Site search
132
177
  <span class="tabs" id="searchTabs"><button data-t="top" class="active">Top</button><button data-t="zeroResults">No results</button></span>
133
178
  </h3><p class="muted hint" id="searchMeta"></p><div class="bars" id="p-search"></div></div>
134
- <div class="card panel hide" id="w-404"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=1#i-404"></use></svg> Broken pages (404)</h3><div class="bars" id="p-404"></div></div>
135
- <div class="card panel hide" id="w-scroll"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=1#i-scroll"></use></svg> Scroll depth <span class="muted hint">avg · share reaching 75 %</span></h3><div class="bars" id="p-scroll"></div></div>
179
+ <div class="card panel hide" id="w-404"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-404"></use></svg> Broken pages (404)</h3><div class="bars" id="p-404"></div></div>
180
+ <div class="card panel hide" id="w-scroll"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-scroll"></use></svg> Scroll depth <span class="muted hint">avg · share reaching 75 %</span></h3><div class="bars" id="p-scroll"></div></div>
136
181
  </div>
137
182
 
138
183
  <!-- visitor journeys (sankey) -->
139
184
  <div class="card panel" style="margin-top:16px">
140
- <h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=1#i-journey"></use></svg> Visitor journeys <span class="muted" id="journeyMeta" style="font-size:.75rem;font-weight:400"></span></h3>
185
+ <h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-journey"></use></svg> Visitor journeys <span class="muted" id="journeyMeta" style="font-size:.75rem;font-weight:400"></span></h3>
141
186
  <div id="journeyWrap" style="overflow-x:auto"><svg id="journey" width="100%" height="340"></svg></div>
142
187
  </div>
143
188
 
144
189
  <!-- funnels -->
145
190
  <div class="card panel" style="margin-top:16px">
146
- <h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=1#i-funnel"></use></svg> Funnels
191
+ <h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-funnel"></use></svg> Funnels
147
192
  <span class="tabs"><button id="addFunnelBtn">+ New funnel</button></span>
148
193
  </h3>
149
194
  <div id="funnels" class="funnels"></div>
@@ -151,33 +196,53 @@
151
196
 
152
197
  <!-- custom-event properties (shown when drilled into a single goal) -->
153
198
  <div id="propsWrap" class="panels hide" style="margin-top:16px"></div>
154
- <p class="footer-note">Tacilytics · open source · <a href="https://github.com/Open-Cosmic-Software/tacilytics" target="_blank" rel="noopener">GitHub</a></p>
199
+ <p class="footer-note">Tacilytics<span id="appVersion"></span> · open source · <a href="https://github.com/Open-Cosmic-Software/tacilytics" target="_blank" rel="noopener">GitHub</a></p>
155
200
  </div>
156
201
  </div>
157
202
 
158
203
  <!-- Add site modal -->
159
- <div id="addModal" class="modal-overlay hide">
204
+ <div id="addModal" class="modal-overlay hide" data-guard>
160
205
  <div class="modal">
161
- <span class="logo em"><svg class="ico big" aria-hidden="true"><use href="/assets/icons.svg?v=1#i-site"></use></svg></span>
206
+ <button type="button" class="modal-x" data-close aria-label="Close" title="Close"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-x"></use></svg></button>
207
+ <span class="logo em"><svg class="ico big" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-site"></use></svg></span>
162
208
  <h2>Add a site</h2>
163
209
  <p>Enter the domain you want to track.</p>
164
210
  <div id="addErr" class="err"></div>
165
211
  <input id="newDomain" type="text" placeholder="example.com" autocomplete="off">
212
+ <label class="field-label" for="newTz">Time zone (days start at midnight here)</label>
213
+ <select id="newTz" class="tz-select"></select>
166
214
  <button class="btn" id="createSiteBtn">Create</button>
167
215
  <button class="btn ghost" id="cancelAdd" style="margin-top:10px">Cancel</button>
168
216
  </div>
169
217
  </div>
170
218
 
219
+ <!-- Time zone of a site (Fabian) -->
220
+ <div id="tzModal" class="modal-overlay hide" data-guard>
221
+ <div class="modal">
222
+ <button type="button" class="modal-x" data-close aria-label="Close" title="Close"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-x"></use></svg></button>
223
+ <span class="logo em"><svg class="ico big" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-timer"></use></svg></span>
224
+ <h2>Time zone</h2>
225
+ <p><span>Days, hours, the weekly report and alerts follow this time zone for</span> <b id="tzSite"></b></p>
226
+ <p>Stored data does not change — switching back and forth is safe.</p>
227
+ <p class="muted small">Unique visitors per day are an approximation outside UTC: the anonymous visitor ID changes at midnight UTC.</p>
228
+ <div id="tzErr" class="err"></div>
229
+ <select id="tzSelect" class="tz-select"></select>
230
+ <button class="btn" id="saveTz">Save</button>
231
+ <button class="btn ghost" id="cancelTz" style="margin-top:10px">Cancel</button>
232
+ </div>
233
+ </div>
234
+
171
235
  <!-- Snippet modal: website + apps (13.10.2026) -->
172
236
  <div id="snippetModal" class="modal-overlay hide">
173
237
  <div class="modal modal-wide">
174
- <span class="logo em"><svg class="ico big" aria-hidden="true"><use href="/assets/icons.svg?v=1#i-code"></use></svg></span>
238
+ <button type="button" class="modal-x" data-close aria-label="Close" title="Close"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-x"></use></svg></button>
239
+ <span class="logo em"><svg class="ico big" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-code"></use></svg></span>
175
240
  <h2>Add tracking</h2>
176
241
  <p style="margin-bottom:12px">for <strong id="snipDomain"></strong>. Same dashboard for website and apps.</p>
177
242
  <div class="tabs snip-tabs" id="snipTabs">
178
- <button data-t="web" class="active"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=1#i-web"></use></svg>Website</button>
179
- <button data-t="flutter"><svg class="ico brand" style="color:#02569B" aria-hidden="true"><use href="/assets/icons.svg?v=1#b-flutter"></use></svg>Flutter app</button>
180
- <button data-t="other"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=1#i-mobile"></use></svg>Other apps</button>
243
+ <button data-t="web" class="active"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-web"></use></svg>Website</button>
244
+ <button data-t="flutter"><svg class="ico brand" style="color:#02569B" aria-hidden="true"><use href="/assets/icons.svg?v=2#b-flutter"></use></svg>Flutter app</button>
245
+ <button data-t="other"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-mobile"></use></svg>Other apps</button>
181
246
  </div>
182
247
  <p class="muted snip-step" id="snipStep1"></p>
183
248
  <div class="snippet" id="snippetCode"></div>
@@ -193,7 +258,8 @@
193
258
  <!-- Share modal -->
194
259
  <div id="shareModal" class="modal-overlay hide">
195
260
  <div class="modal">
196
- <span class="logo em"><svg class="ico big" aria-hidden="true"><use href="/assets/icons.svg?v=1#i-share"></use></svg></span>
261
+ <button type="button" class="modal-x" data-close aria-label="Close" title="Close"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-x"></use></svg></button>
262
+ <span class="logo em"><svg class="ico big" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-share"></use></svg></span>
197
263
  <h2>Share dashboard</h2>
198
264
  <p>Anyone with this link can view stats for <strong id="shareDomain"></strong> — read-only, no login.</p>
199
265
  <div class="snippet" id="shareUrl" style="word-break:break-all"></div>
@@ -204,9 +270,10 @@
204
270
  </div>
205
271
 
206
272
  <!-- Funnel builder modal -->
207
- <div id="funnelModal" class="modal-overlay hide">
273
+ <div id="funnelModal" class="modal-overlay hide" data-guard>
208
274
  <div class="modal">
209
- <span class="logo em"><svg class="ico big" aria-hidden="true"><use href="/assets/icons.svg?v=1#i-funnel"></use></svg></span>
275
+ <button type="button" class="modal-x" data-close aria-label="Close" title="Close"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-x"></use></svg></button>
276
+ <span class="logo em"><svg class="ico big" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-funnel"></use></svg></span>
210
277
  <h2>New funnel</h2>
211
278
  <p>2–8 ordered steps. A visitor advances only after completing the previous step.</p>
212
279
  <div id="funnelErr" class="err"></div>
@@ -221,6 +288,7 @@
221
288
  <!-- Live visitors modal -->
222
289
  <div id="liveModal" class="modal-overlay hide">
223
290
  <div class="modal live">
291
+ <button type="button" class="modal-x" data-close aria-label="Close" title="Close"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-x"></use></svg></button>
224
292
  <div class="live-head">
225
293
  <h2>Live</h2>
226
294
  <span class="realtime"><span class="dot"></span><span id="live-online">0</span> online</span>
@@ -233,7 +301,96 @@
233
301
  </div>
234
302
  </div>
235
303
 
304
+ <!-- Import -->
305
+ <div id="importModal" class="modal-overlay hide">
306
+ <div class="modal modal-wide settings">
307
+ <button type="button" class="modal-x" data-close aria-label="Close" title="Close"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-x"></use></svg></button>
308
+ <h2>Import history into <span id="impSite"></span></h2>
309
+ <p>Bring your old numbers along. They show up in the chart and in the panels for the days <strong>before</strong> Tacilytics started counting — never twice.</p>
310
+ <div id="impErr" class="err" role="alert"></div>
311
+ <div class="tabs set-tabs" id="impTabs" style="margin:0 0 12px">
312
+ <button data-src="plausible" class="active">Plausible</button>
313
+ <button data-src="ga">Google Analytics 4</button>
314
+ </div>
315
+ <div class="set-card">
316
+ <p class="muted small" id="impHowto"></p>
317
+ <div class="set-inline"><input type="file" id="impFile" accept=".zip,.csv" multiple><button class="btn" id="impGo">Import</button></div>
318
+ <p class="muted small" id="impStatus" style="margin:8px 0 0"></p>
319
+ </div>
320
+ <div id="impList"></div>
321
+ <button class="btn ghost" id="closeImport" style="margin-top:12px">Close</button>
322
+ </div>
323
+ </div>
324
+
325
+ <!-- Alerts -->
326
+ <div id="alertsModal" class="modal-overlay hide" data-guard>
327
+ <div class="modal modal-wide settings">
328
+ <button type="button" class="modal-x" data-close aria-label="Close" title="Close"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-x"></use></svg></button>
329
+ <h2>Alerts for <span id="alertsSite"></span></h2>
330
+ <p>Tacilytics sends a message to a webhook (Discord, Slack, or anything that takes JSON). Checked every 10 minutes.</p>
331
+ <div id="alertsErr" class="err" role="alert"></div>
332
+ <div id="alertsList"></div>
333
+ <div class="set-card">
334
+ <h3>New alert</h3>
335
+ <div class="set-inline">
336
+ <select id="alKind">
337
+ <option value="spike">Traffic spike — about 3× the usual for this hour</option>
338
+ <option value="drop">Traffic drop — under 20 % of usual (tracker broken?)</option>
339
+ <option value="goal">Goal reached — e.g. 10× Purchase</option>
340
+ </select>
341
+ </div>
342
+ <div class="set-inline hide" id="alGoalRow">
343
+ <input id="alGoal" type="text" placeholder="Event name, e.g. Purchase" maxlength="80" list="alGoalList">
344
+ <datalist id="alGoalList"></datalist>
345
+ <input id="alThreshold" type="number" min="1" value="10" style="flex:0 0 90px" title="How many">
346
+ <select id="alWindow" style="flex:0 0 140px"><option value="1">in 1 hour</option><option value="24" selected>in 24 hours</option><option value="168">in 7 days</option></select>
347
+ </div>
348
+ <div class="set-inline">
349
+ <input id="alWebhook" type="url" placeholder="Webhook URL (https://…)">
350
+ <button class="btn" id="alAdd">Add alert</button>
351
+ </div>
352
+ <p class="muted small" id="alHint" style="margin:8px 0 0"></p>
353
+ </div>
354
+ <button class="btn ghost" id="closeAlerts" style="margin-top:12px">Close</button>
355
+ </div>
356
+ </div>
357
+
358
+ <!-- Chart annotations -->
359
+ <div id="annModal" class="modal-overlay hide" data-guard>
360
+ <div class="modal modal-wide settings">
361
+ <button type="button" class="modal-x" data-close aria-label="Close" title="Close"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-x"></use></svg></button>
362
+ <h2>Notes in the chart</h2>
363
+ <p>Mark a day — a newsletter, a release, a sale — so a spike explains itself later. Everyone with access to this site sees them.</p>
364
+ <div id="annErr" class="err" role="alert"></div>
365
+ <div class="set-inline"><input id="annDay" type="date" style="flex:0 0 170px;color-scheme:dark"><input id="annText" type="text" maxlength="140" placeholder="e.g. Newsletter sent"><button class="btn" id="saveAnn">Add</button></div>
366
+ <div id="annList" style="margin-top:14px"></div>
367
+ <button class="btn ghost" id="closeAnn" style="margin-top:16px">Close</button>
368
+ </div>
369
+ </div>
370
+
371
+ <!-- Settings: account, security, team -->
372
+ <div id="settingsModal" class="modal-overlay hide">
373
+ <div class="modal modal-wide settings">
374
+ <div class="settings-head">
375
+ <h2>Settings</h2>
376
+ <div class="tabs set-tabs" id="setTabs">
377
+ <button data-t="security" class="active"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-shield"></use></svg>Security</button>
378
+ <button data-t="team"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-users"></use></svg>Team</button>
379
+ <button data-t="activity"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-timer"></use></svg>Activity</button>
380
+ </div>
381
+ <button type="button" class="modal-x" data-close aria-label="Close" title="Close"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-x"></use></svg></button>
382
+ </div>
383
+ <div id="setErr" class="err" role="alert"></div>
384
+ <div id="setSecurity"></div>
385
+ <div id="setTeam" class="hide"></div>
386
+ <div id="setActivity" class="hide"></div>
387
+ <button class="btn ghost" id="closeSettings" style="margin-top:18px">Close</button>
388
+ </div>
389
+ </div>
390
+
236
391
  <div class="tip" id="tip"></div>
237
- <script src="/app.js?v=2.2.0"></script>
392
+ <script src="/i18n.js?v=2.2.3"></script>
393
+ <script src="/account.js?v=2.5.6"></script>
394
+ <script src="/app.js?v=2.7.2"></script>
238
395
  </body>
239
396
  </html>
package/public/style.css CHANGED
@@ -413,3 +413,143 @@ header::after { content: ""; position: absolute; left: 18px; right: 18px; bottom
413
413
  /* native dropdown lists follow the dark theme (otherwise light text on a white popup) */
414
414
  select { color-scheme: dark; }
415
415
  select option { background: #0c1a1d; color: var(--text); }
416
+
417
+ /* ── settings: security + team (2.1) ───────────────────────────────────────── */
418
+ .modal.settings { text-align: left; max-width: 680px; }
419
+ .settings-head { display: flex; align-items: center; gap: 14px; margin-bottom: 12px; flex-wrap: wrap; }
420
+ .settings-head h2 { margin: 0; }
421
+ .set-tabs { margin-left: auto; display: inline-flex; gap: 2px; background: rgba(255,255,255,.03); border: 1px solid var(--line); border-radius: 9px; padding: 2px; }
422
+ .set-tabs button { background: transparent; border: none; color: var(--text-dim); padding: 6px 12px; border-radius: 7px; cursor: pointer; font: 550 0.8rem inherit; font-family: inherit; display: inline-flex; align-items: center; gap: 6px; }
423
+ .set-tabs button.active { background: var(--surface-2); color: var(--text); box-shadow: inset 0 0 0 1px var(--line-strong); }
424
+ .set-card { border: 1px solid var(--line); border-radius: 14px; padding: 16px 18px; margin-bottom: 12px; background: rgba(255,255,255,.02); }
425
+ .set-card h3 { font-size: 0.95rem; font-weight: 650; margin: 0 0 6px; display: flex; align-items: center; gap: 8px; }
426
+ .set-card p { margin-bottom: 12px; font-size: 0.84rem; }
427
+ .modal .set-card input, .modal .set-card select { text-align: left; margin-bottom: 0; }
428
+ .modal .set-card .btn { width: auto; padding: 9px 14px; }
429
+ .set-card > input { margin-bottom: 10px !important; display: block; }
430
+ .set-card > .btn { width: 100%; }
431
+ .set-inline { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 8px; }
432
+ .set-inline input, .set-inline select { flex: 1 1 160px; min-width: 0; }
433
+ .set-row { display: flex; align-items: center; gap: 10px; padding: 9px 0; border-top: 1px solid var(--line); font-size: 0.86rem; }
434
+ .set-row:first-of-type { border-top: none; }
435
+ .set-row > span:first-child { flex: 1; min-width: 0; display: inline-flex; align-items: center; gap: 7px; flex-wrap: wrap; }
436
+ .team-row > span:first-child { display: block; }
437
+ .team-row select { width: auto; padding: 6px 8px; font-size: 0.8rem; }
438
+ .row-actions { display: inline-flex; gap: 6px; }
439
+ .btn.mini, .modal .btn.mini { padding: 5px 10px; font-size: 0.76rem; width: auto; }
440
+ .small { font-size: 0.76rem; }
441
+ .pill { font-size: 0.66rem; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; padding: 2px 7px; border-radius: 99px; border: 1px solid var(--line-strong); color: var(--text-dim); }
442
+ .totp-qr { display: grid; place-items: center; margin: 6px 0 10px; }
443
+ .totp-qr img { width: 180px; height: 180px; background: #fff; border-radius: 12px; padding: 8px; }
444
+ .snippet.center { text-align: center; letter-spacing: .08em; }
445
+ .snippet.recovery { columns: 2; text-align: center; font-size: 0.86rem; line-height: 1.9; }
446
+ .site-pick { display: flex; flex-wrap: wrap; gap: 6px 14px; margin: 10px 0 4px; font-size: 0.82rem; }
447
+ .site-pick label { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
448
+ .modal .site-pick input { width: auto; margin: 0; accent-color: var(--accent-strong, var(--accent)); }
449
+ .site-edit { padding: 4px 0 10px; }
450
+ #user, #pw { margin-bottom: 10px; }
451
+ #login .btn.ghost .ico, #settingsBtn .ico { margin-right: 6px; }
452
+ /* viewers: hide everything that would only answer with 403 */
453
+ body.role-viewer #addSiteBtn, body.role-viewer #shareBtn, body.role-viewer #addFunnelBtn { display: none !important; }
454
+ @media (max-width: 560px) { #meName { display: none; } .team-row { flex-wrap: wrap; } }
455
+ a.btn, a.btn:hover { text-decoration: none; display: inline-flex; align-items: center; gap: 6px; color: var(--text); }
456
+ .swatch.ann { width: 9px; height: 9px; transform: rotate(45deg); border-radius: 1px; background: var(--accent-2, #ff7a5c); }
457
+ .tip-note { color: var(--accent-2, #ff7a5c); }
458
+ body.role-viewer #annBtn { display: none !important; }
459
+ .act-row { align-items: baseline; }
460
+ .act-row .act-time { flex: 0 0 96px; }
461
+ .act-row > span:nth-child(2) { flex: 1; display: block; }
462
+ .act-target { color: var(--text); background: rgba(255,255,255,.04); border: 1px solid var(--line); border-radius: 6px; padding: 1px 6px; font-size: 0.8rem; }
463
+ .act-ip { flex: 0 0 auto; font-family: ui-monospace, monospace; }
464
+ @media (max-width: 560px) { .act-ip { display: none; } }
465
+ body.role-viewer #alertsBtn { display: none !important; }
466
+ #alertsList [data-al-test] { min-width: 72px; justify-content: center; }
467
+ body.role-viewer #importBtn { display: none !important; }
468
+ #impFile { flex: 1 1 240px; color: var(--text-dim); font-size: .82rem; }
469
+ #impFile::file-selector-button { font: 600 .8rem inherit; font-family: inherit; color: var(--text); background: rgba(255,255,255,.04); border: 1px solid var(--line-strong); border-radius: 9px; padding: 7px 12px; margin-right: 10px; cursor: pointer; }
470
+ #impFile::file-selector-button:hover { background: rgba(255,255,255,.08); }
471
+
472
+ /* ══ light theme (html[data-theme="light"], chosen in Settings or following the system) ══════════ */
473
+ html[data-theme="light"] {
474
+ color-scheme: light;
475
+ --accent: #0d9488;
476
+ --accent-strong: #0f766e;
477
+ --accent-2: #e8603f;
478
+ --accent-soft: rgba(13, 148, 136, 0.10);
479
+ --accent-line: rgba(13, 148, 136, 0.35);
480
+ --chart-visitors: #0d9488;
481
+ --chart-pageviews: #e8603f;
482
+ --green: #059669; --red: #dc2626; --yellow: #b45309;
483
+ --bg: #f4f8f7; --bg2: #eef3f2;
484
+ --surface: rgba(255, 255, 255, 0.86);
485
+ --surface-2: #eef4f3;
486
+ --line: rgba(10, 40, 38, 0.09);
487
+ --line-strong: rgba(10, 40, 38, 0.16);
488
+ --text: #0f2624; --text-dim: #3f5a56; --text-faint: #647a77;
489
+ --cyan: #0e7490;
490
+ --shadow: 0 1px 0 rgba(255,255,255,.7) inset, 0 10px 28px -16px rgba(15,40,38,.25);
491
+ }
492
+ html[data-theme="light"] body {
493
+ background:
494
+ radial-gradient(1100px 560px at 8% -12%, rgba(13,148,136,.10), transparent 60%),
495
+ radial-gradient(800px 460px at 104% -4%, rgba(232,96,63,.07), transparent 55%),
496
+ var(--bg);
497
+ }
498
+ html[data-theme="light"] header {
499
+ background: linear-gradient(180deg, rgba(255,255,255,.92), rgba(247,251,250,.88));
500
+ box-shadow: 0 1px 0 rgba(255,255,255,.8) inset, 0 16px 34px -24px rgba(15,40,38,.35);
501
+ }
502
+ html[data-theme="light"] .btn { color: #fff; box-shadow: 0 6px 16px -8px rgba(13,148,136,.55); }
503
+ html[data-theme="light"] .btn.ghost,
504
+ html[data-theme="light"] .header-right .btn.ghost,
505
+ html[data-theme="light"] .header-right select { background: rgba(255,255,255,.75); color: var(--text); border-color: var(--line-strong); }
506
+ html[data-theme="light"] .header-right .btn.ghost:hover, html[data-theme="light"] .btn.ghost:hover { background: rgba(13,148,136,.07); }
507
+ html[data-theme="light"] .stats .stat:first-child { background: linear-gradient(160deg, rgba(13,148,136,.12), rgba(255,255,255,.9) 55%); border-color: rgba(13,148,136,.25); }
508
+ html[data-theme="light"] .commerce .stat:first-child { background: linear-gradient(160deg, rgba(232,96,63,.10), rgba(255,255,255,.9) 55%); border-color: rgba(232,96,63,.22); }
509
+ html[data-theme="light"] .bar .fill { background: linear-gradient(90deg, rgba(13,148,136,.16), rgba(13,148,136,.04)); }
510
+ html[data-theme="light"] .bar:hover .fill { background: linear-gradient(90deg, rgba(13,148,136,.24), rgba(232,96,63,.08)); }
511
+ html[data-theme="light"] .realtime { background: rgba(13,148,136,.07); border-color: rgba(13,148,136,.22); color: #0f766e; }
512
+ html[data-theme="light"] .trend.up { color: #0f766e; background: rgba(13,148,136,.10); }
513
+ html[data-theme="light"] .trend.down { color: #c2410c; background: rgba(232,96,63,.10); }
514
+ html[data-theme="light"] #p-pages .bar .name .txt, html[data-theme="light"] #p-404 .bar .name .txt, html[data-theme="light"] .feed-row .path { color: #1d3a37; }
515
+ html[data-theme="light"] .modal { background: rgba(255,255,255,.96); box-shadow: 0 30px 80px rgba(15,40,38,.25), 0 0 0 1px var(--line-strong); }
516
+ html[data-theme="light"] .modal-overlay { background: rgba(220,232,230,.6); }
517
+ html[data-theme="light"] #login { background: radial-gradient(700px 420px at 50% 30%, rgba(13,148,136,.14), transparent 65%), rgba(236,244,242,.92); }
518
+ html[data-theme="light"] .snippet { background: #f2f7f6; color: #17403b; border-color: var(--line); }
519
+ html[data-theme="light"] select option { background: #fff; color: var(--text); }
520
+ html[data-theme="light"] input, html[data-theme="light"] select, html[data-theme="light"] textarea { background-color: #fff; color: var(--text); border-color: var(--line-strong); }
521
+ html[data-theme="light"] .set-card { background: rgba(255,255,255,.7); }
522
+ html[data-theme="light"] .pill, html[data-theme="light"] .act-target { background: rgba(10,40,38,.04); }
523
+ html[data-theme="light"] .flag { box-shadow: 0 0 0 1px rgba(0,0,0,.08); }
524
+ html[data-theme="light"] .tip { background: #fff; color: var(--text); border-color: var(--line-strong); box-shadow: 0 10px 30px -10px rgba(15,40,38,.3); }
525
+ html[data-theme="light"] .range input[type=date], html[data-theme="light"] #annDay { color-scheme: light !important; }
526
+ html[data-theme="light"] .set-tabs button.active, html[data-theme="light"] .panel h3 .tabs button.active { background: #fff; }
527
+ html[data-theme="light"] .pill { color: #36504c; border-color: rgba(10,40,38,.22); }
528
+ html[data-theme="light"] .stat .label { color: #3f5a56; }
529
+
530
+ /* one close button for every closable modal — sticky, so it stays reachable in long modals */
531
+ .modal-x { position: sticky; top: 0; float: right; margin: -14px -12px 0 8px; z-index: 3; width: 34px; height: 34px; display: inline-flex; align-items: center; justify-content: center;
532
+ background: var(--surface-2, rgba(255,255,255,.06)); border: 1px solid var(--line); border-radius: 10px; color: var(--text-dim); cursor: pointer; }
533
+ .modal-x:hover, .modal-x:focus-visible { color: var(--text); border-color: var(--line-strong); }
534
+ .settings-head .modal-x { position: static; float: none; margin: 0 0 0 6px; flex: none; }
535
+ .modal.settings .settings-head { position: sticky; top: -32px; z-index: 3; margin: -32px -30px 12px; padding: 22px 30px 12px;
536
+ background: rgb(23,26,34); border-bottom: 1px solid var(--line); border-radius: 20px 20px 0 0; }
537
+ html[data-theme="light"] .modal.settings .settings-head { background: #fff; }
538
+
539
+ /* scrollbars in Tacilytics style — thin, quiet, follows light/dark (Fabian) */
540
+ :root { --sb-thumb: rgba(180, 255, 240, 0.16); --sb-thumb-hover: rgba(180, 255, 240, 0.32); }
541
+ html[data-theme="light"] { --sb-thumb: rgba(10, 40, 38, 0.18); --sb-thumb-hover: rgba(10, 40, 38, 0.34); }
542
+ * { scrollbar-width: thin; scrollbar-color: var(--sb-thumb) transparent; }
543
+ ::-webkit-scrollbar { width: 8px; height: 8px; }
544
+ ::-webkit-scrollbar-track { background: transparent; }
545
+ ::-webkit-scrollbar-thumb { background: var(--sb-thumb); border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; }
546
+ ::-webkit-scrollbar-thumb:hover { background: var(--sb-thumb-hover); background-clip: padding-box; }
547
+ ::-webkit-scrollbar-corner { background: transparent; }
548
+ .modal::-webkit-scrollbar-track { margin: 14px 0; } /* keeps the thumb off the rounded corners */
549
+ /* login / reset / invite always on top: a 401 while Settings is open must not hide the login behind it */
550
+ #login, #reset, #invite { z-index: 1100; }
551
+ /* ── time zones ── */
552
+ .modal .tz-select { width: 100%; margin-bottom: 14px; }
553
+ .modal .field-label { display: block; color: var(--text-dim); font-size: 0.8rem; margin: -4px 0 6px; text-align: left; }
554
+ .modal p.small { font-size: 0.78rem; margin-top: -14px; }
555
+ .tz-btn span { max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -5,8 +5,10 @@
5
5
  Optional data-* flags on the <script> tag:
6
6
  data-honor-dnt respect Do-Not-Track / Global Privacy Control (skip all tracking)
7
7
  data-track-outbound auto-record clicks on links to other domains as events
8
+ data-track-downloads auto-record file downloads as "File Download" events (optional value: "pdf,zip,…")
8
9
  data-track-404 record a "404" event when the page is an error page
9
10
  (value = CSS selector that only exists on error pages, e.g. "body.is-act-error")
11
+ (own visits: open the site once with ?tacilytics=off to stop counting this browser, ?tacilytics=on to undo)
10
12
  data-track-search record "Search" events: value = query-string parameter, e.g. "search"
11
13
  (results counted via data-search-results selector, default ".product-box")
12
14
  Purchases: tacilytics('Purchase', { value: 49.90, currency: 'EUR', items: 3 }) on the thank-you page
@@ -24,6 +26,18 @@
24
26
  if (navigator.globalPrivacyControl === true || dnt === '1' || dnt === 'yes') return;
25
27
  }
26
28
 
29
+ // "Don't count this browser" (your own visits): open the site once with ?tacilytics=off
30
+ // (?tacilytics=on counts it again). Remembered per browser in localStorage, nothing is sent.
31
+ try {
32
+ var optParam = new URLSearchParams(location.search).get('tacilytics');
33
+ if (optParam === 'off') { localStorage.setItem('tacilytics_ignore', '1'); console.info('Tacilytics: this browser is no longer counted on ' + location.hostname + ' (undo: ?tacilytics=on)'); }
34
+ else if (optParam === 'on') { localStorage.removeItem('tacilytics_ignore'); console.info('Tacilytics: this browser is counted again on ' + location.hostname); }
35
+ if (localStorage.getItem('tacilytics_ignore') === '1') {
36
+ window.tacilytics = window.nyx = function () {}; // calls on the page keep working, silently
37
+ return;
38
+ }
39
+ } catch (e) {} // storage blocked → just count normally
40
+
27
41
  var endpoint = new URL(s.src).origin + '/api/collect';
28
42
  var tz = '';
29
43
  try { tz = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch (e) {}
@@ -105,11 +119,37 @@
105
119
  window.tacilytics = function (name, props) { if (name && typeof name === 'string') send(name.slice(0, 80), props); };
106
120
  window.nyx = window.tacilytics; // alias for installs from before the rename
107
121
 
122
+ // ── optional automatic file-download tracking ──
123
+ // data-track-downloads → common file types (pdf, zip, docx, xlsx, mp3, …)
124
+ // data-track-downloads="pdf,epub,dmg" → your own list
125
+ // any link with the download attribute counts too
126
+ var dlAttr = s.getAttribute('data-track-downloads');
127
+ if (dlAttr !== null) {
128
+ var dlExt = (dlAttr || 'pdf,zip,rar,7z,gz,tar,dmg,exe,msi,pkg,apk,ipa,doc,docx,xls,xlsx,csv,ppt,pptx,odt,ods,txt,rtf,epub,mp3,wav,flac,mp4,mov,avi,mkv,webm,jpg,jpeg,png,gif,svg,webp,json,xml,iso')
129
+ .toLowerCase().split(/[\s,]+/).filter(Boolean);
130
+ document.addEventListener('click', function (ev) {
131
+ var a = ev.target && ev.target.closest && ev.target.closest('a[href]');
132
+ if (!a) return;
133
+ try {
134
+ var u = new URL(a.href, location.href);
135
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return;
136
+ var m = u.pathname.toLowerCase().match(/\.([a-z0-9]{1,5})$/);
137
+ var ext = m ? m[1] : '';
138
+ if (!a.hasAttribute('download') && dlExt.indexOf(ext) < 0) return;
139
+ var file = decodeURIComponent(u.pathname.split('/').pop() || '').slice(0, 120);
140
+ // same-site files keep their path; other sites keep host + path (no query: it can carry tokens)
141
+ var where = u.hostname === location.hostname ? u.pathname : u.hostname + u.pathname;
142
+ ev.__tacilyticsDownload = true; // the outbound handler skips this click: one event per click
143
+ window.tacilytics('File Download', { url: where.slice(0, 200), file: file, type: ext || 'file' });
144
+ } catch (e) {}
145
+ }, true);
146
+ }
147
+
108
148
  // ── optional automatic outbound-link tracking ──
109
149
  if (s.hasAttribute('data-track-outbound')) {
110
150
  document.addEventListener('click', function (ev) {
111
151
  var a = ev.target && ev.target.closest && ev.target.closest('a[href]');
112
- if (!a) return;
152
+ if (!a || ev.__tacilyticsDownload) return;
113
153
  try {
114
154
  var u = new URL(a.href, location.href);
115
155
  if ((u.protocol === 'http:' || u.protocol === 'https:') && u.hostname !== location.hostname) {
@@ -0,0 +1,14 @@
1
+ /* Tacilytics — theme + language, applied before first paint (loaded synchronously in <head>). */
2
+ (function () {
3
+ var d = document.documentElement;
4
+ function pref() { try { return localStorage.getItem('tacilytics_theme') || 'system'; } catch (e) { return 'system'; } }
5
+ function apply() {
6
+ var p = pref();
7
+ var light = p === 'light' || (p === 'system' && window.matchMedia && matchMedia('(prefers-color-scheme: light)').matches);
8
+ d.setAttribute('data-theme', light ? 'light' : 'dark');
9
+ var m = document.querySelector('meta[name="theme-color"]'); if (m) m.setAttribute('content', light ? '#f4f8f7' : '#071114');
10
+ }
11
+ apply();
12
+ if (window.matchMedia) { try { matchMedia('(prefers-color-scheme: light)').addEventListener('change', apply); } catch (e) {} }
13
+ window.TaciTheme = { get: pref, set: function (v) { try { localStorage.setItem('tacilytics_theme', v); } catch (e) {} apply(); } };
14
+ })();