Skip to content

Commit 14fb2ba

Browse files
committed
feat(dev,html-app): include Plausible script and add privacy disclosure
1 parent a130886 commit 14fb2ba

8 files changed

Lines changed: 481 additions & 5 deletions

File tree

dev/tools/AGENTS.md

Lines changed: 166 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1184,11 +1184,14 @@ There are two different footer patterns depending on whether the page has the Se
11841184

11851185
### Tool app pages (have the Secutils logo header)
11861186

1187-
Since branding is already in the header, the footer should contain a **short description of the tool** - not a "Powered by" watermark. Use `<p>` text, no logo repetition.
1187+
Since branding is already in the header, the footer should contain a **short description of the tool** - not a "Powered by" watermark. Use `<p>` text, no logo repetition. Every footer also carries a **Privacy** link (a `<button>` that opens the canonical privacy dialog - see "Privacy dialog" below). The link is a `<button>` rather than an `<a href="#privacy">` so it doesn't pollute history or the URL fragment (the fragment is reserved for tool state, see "URL state encoding" above).
1188+
1189+
Two-line layout: the tool description on the first line, the Privacy link demoted to a smaller, dimmer second line so it reads as "fine print" rather than competing with the description.
11881190

11891191
```html
11901192
<footer class="su-footer">
11911193
<p>A single-file tool description goes here.</p>
1194+
<p class="su-footer-fineprint"><button type="button" class="su-footer-link" id="privacyOpen">Privacy</button></p>
11921195
</footer>
11931196
```
11941197

@@ -1200,8 +1203,14 @@ Since branding is already in the header, the footer should contain a **short des
12001203
color: var(--text-muted);
12011204
font-size: 0.8rem;
12021205
}
1206+
.su-footer p { margin: 0; }
1207+
.su-footer-fineprint { margin-top: 6px !important; font-size: 0.7rem; opacity: 0.75; }
1208+
.su-footer-link { background: none; border: none; padding: 0; color: inherit; font: inherit; cursor: pointer; text-decoration: underline; text-underline-offset: 2px; }
1209+
.su-footer-link:hover { color: var(--text); }
12031210
```
12041211

1212+
`opacity: 0.75` (not a darker `color`) is intentional: it dims **both** the muted text and the inherited link colour in one go, and stays correct across the light/dark theme swap without needing per-theme overrides. The `!important` on `.su-footer-fineprint`'s `margin-top` only exists to override the universal `* { margin: 0; }` reset declared at the top of every tool's stylesheet.
1213+
12051214
### Generated/exported output files (no Secutils header - e.g. downloaded HTML from Markdown → HTML tool)
12061215

12071216
Since there is no header with branding, include a **"Powered by Secutils.dev" watermark footer** - subtle, non-distracting, links to `https://secutils.dev`:
@@ -1226,6 +1235,156 @@ Watermark CSS: `text-align: center; padding: 32px 24px; opacity: 0.6; font-size:
12261235

12271236
3. **Use Secutils brand accent colors** (`#fed047` yellow, `#642340` maroon) for links, progress bar, blockquote borders, etc.
12281237

1238+
## Analytics (Plausible)
1239+
1240+
Every tool HTML file (including `index.html`) carries the same privacy-friendly
1241+
[Plausible](https://plausible.io/) snippet. The snippet lives in `<head>`, placed
1242+
**immediately after the Google Fonts `<link>`** and before the inline `<style>`
1243+
block (per the [Plausible integration guides](https://plausible.io/docs/integration-guides)).
1244+
The matching `<script type="application/ld+json">` SEO block stays where it is - it
1245+
goes earlier in `<head>`, between the meta tags.
1246+
1247+
### Markup (copy verbatim)
1248+
1249+
```html
1250+
<!-- Privacy-friendly analytics by Plausible -->
1251+
<script defer src="https://tools.secutils.dev/js/script.js"></script>
1252+
<script>
1253+
window.plausible = window.plausible || function () { (plausible.q = plausible.q || []).push(arguments) };
1254+
plausible.init = plausible.init || function (i) { plausible.o = i || {} };
1255+
plausible.init();
1256+
</script>
1257+
```
1258+
1259+
Three load-bearing details:
1260+
1261+
- **Script URL is first-party** (`https://tools.secutils.dev/js/script.js`, same
1262+
host as the page). Bypasses third-party adblockers that filter `plausible.io`
1263+
or generic analytics domains, and piggybacks on the existing connection pool.
1264+
The host is reverse-proxied to Plausible upstream by the same infra that
1265+
serves the tools.
1266+
- **`defer`, not `async`.** `defer` keeps the script's execution ordered
1267+
relative to the inline init shim (which runs in document order after parsing
1268+
finishes) and avoids the tiny race where the queue stub might run before the
1269+
loader is ready. Both work in practice; `defer` is the conservative choice
1270+
for an in-`<head>` placement.
1271+
- **`init()` form without `data-domain`.** Plausible auto-derives the domain
1272+
from `location.hostname`, so a single snippet works on every page and the
1273+
dashboard automatically attributes events to `tools.secutils.dev`. The
1274+
inline shim queues any `plausible(...)` calls made before the loader
1275+
arrives, so future custom events (e.g. `plausible('Copy share link')`)
1276+
buffer cleanly.
1277+
1278+
### How the deploy pipeline treats the snippet
1279+
1280+
`html-minifier-terser` strips the `<!-- Privacy-friendly analytics by Plausible -->`
1281+
comment via `removeComments: true` and re-emits both `<script>` tags as-is (the
1282+
`src="..."` script tag is preserved, the inline init shim goes through
1283+
`minifyJS`). No special handling in [`deploy.ts`](deploy.ts) is required.
1284+
1285+
### Why inline-HTML rather than the dynamic-injection pattern used on `secutils.dev`
1286+
1287+
The main `secutils.dev` marketing site injects the same Plausible script
1288+
dynamically from a TypeScript entry. That works because its Parcel build
1289+
bundles the entry into a single JS file. The tools are single static HTML
1290+
files with no per-page build step beyond `html-minifier-terser`, so inlining
1291+
the snippet is simpler, deterministic, survives minification, and gives
1292+
every page the analytics loader before the inline init shim runs (rather
1293+
than after a paint).
1294+
1295+
## Privacy dialog (footer)
1296+
1297+
Every tool footer carries a **Privacy** button that opens a native `<dialog>`
1298+
explaining (1) tool state stays in the browser and (2) what Plausible
1299+
collects. The dialog is the user-facing complement of the analytics snippet
1300+
above: every tool that runs Plausible discloses Plausible.
1301+
1302+
### Why a native `<dialog>`
1303+
1304+
`<dialog>.showModal()` gives Escape-to-close, focus trapping, `role="dialog"`,
1305+
a `::backdrop` pseudo-element, and document inertness for free - no library,
1306+
no manual ARIA, no keyboard-trap helper. Supported in every evergreen
1307+
browser. The dialog does not close on backdrop click by default; that
1308+
matches the "short, action-required modal" convention and avoids accidental
1309+
dismissals on touch devices. If a tool ever needs backdrop-click dismissal,
1310+
wire it up locally; do not add it to the canonical snippet.
1311+
1312+
**Centering is `inset: 0; margin: auto;` plus a `max-height` cap.** The native
1313+
user-agent stylesheet only resolves `margin: auto` horizontally because no
1314+
`top`/`bottom` are set on the modal-positioned dialog; adding `inset: 0` gives
1315+
both axes an anchor so `margin: auto` distributes the remaining space evenly,
1316+
vertically and horizontally. `max-height: calc(100% - 32px)` keeps a 16 px
1317+
breathing room at top/bottom on short viewports (e.g. landscape phone) and
1318+
lets the dialog body scroll instead of overflowing the viewport. Without the
1319+
`max-height`, the dialog could exceed the viewport and the bottom margin
1320+
would collapse, breaking the vertical centering.
1321+
1322+
### Markup (copy verbatim)
1323+
1324+
Place as the **last child of `<body>`**, after `</footer>` and before the
1325+
final `<script>` block. The copy is intentionally generic so the same block
1326+
ships unchanged across every tool (the dialog enumerates payload types from
1327+
every tool, not just the current page's):
1328+
1329+
```html
1330+
<dialog id="privacyDialog" class="su-dialog" aria-labelledby="privacyDialogTitle">
1331+
<header class="su-dialog-header">
1332+
<h2 id="privacyDialogTitle">Privacy</h2>
1333+
<button type="button" class="su-dialog-close" id="privacyClose" aria-label="Close">
1334+
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 3l10 10M13 3L3 13"/></svg>
1335+
</button>
1336+
</header>
1337+
<div class="su-dialog-body">
1338+
<p><strong>Your data stays in your browser.</strong> These tools run entirely client-side. Tokens, PEMs, SAML payloads, Markdown source, and mock-response bodies are never sent to the Secutils.dev server. State that needs to survive a reload (or be shared) lives in the URL fragment (<code>#&hellip;</code>), which browsers never transmit to the server.</p>
1339+
<p><strong>Anonymous usage analytics.</strong> We use <a href="https://plausible.io/" target="_blank" rel="noopener noreferrer">Plausible Analytics</a>, a privacy-first, GDPR-compliant tool, to collect aggregate usage data. No cookies, no personal data, no individual tracking. The data is limited to top pages, referral sources, visit duration, and device-class metadata (device type, OS, country, browser). Full details in the <a href="https://plausible.io/data-policy" target="_blank" rel="noopener noreferrer">Plausible Data Policy</a>.</p>
1340+
<p class="su-dialog-fineprint">See the full <a href="https://secutils.dev/privacy" target="_blank" rel="noopener noreferrer">Secutils.dev privacy policy</a> for details on the wider service.</p>
1341+
</div>
1342+
</dialog>
1343+
```
1344+
1345+
The matching footer button is documented in the "Footer" section above
1346+
(every footer carries `<button class="su-footer-link" id="privacyOpen">Privacy</button>`).
1347+
1348+
### CSS (copy verbatim)
1349+
1350+
Place next to the existing `.su-footer` rule:
1351+
1352+
```css
1353+
.su-dialog { max-width: 520px; width: calc(100% - 32px); max-height: calc(100% - 32px); inset: 0; margin: auto; padding: 0; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); color: var(--text); box-shadow: 0 20px 60px rgba(0,0,0,0.4); }
1354+
.su-dialog::backdrop { background: rgba(0,0,0,0.45); backdrop-filter: blur(2px); }
1355+
.su-dialog-header { display: flex; align-items: center; justify-content: space-between; padding: 14px 18px; border-bottom: 1px solid var(--border); }
1356+
.su-dialog-header h2 { font-size: 1rem; font-weight: 600; }
1357+
.su-dialog-close { width: 28px; height: 28px; padding: 0; display: flex; align-items: center; justify-content: center; border-radius: 50%; border: 1px solid var(--border); background: var(--surface); color: var(--text-muted); cursor: pointer; transition: all .15s; }
1358+
.su-dialog-close:hover { background: var(--surface-hover); color: var(--text); }
1359+
.su-dialog-body { padding: 16px 18px; font-size: 0.875rem; line-height: 1.55; color: var(--text); }
1360+
.su-dialog-body p { margin-bottom: 12px; }
1361+
.su-dialog-body p:last-child { margin-bottom: 0; }
1362+
.su-dialog-body code { font-family: var(--mono); background: var(--surface-hover); padding: 1px 5px; border-radius: 4px; font-size: 0.85em; }
1363+
.su-dialog-body a { color: var(--primary); text-decoration: none; }
1364+
.su-dialog-body a:hover { text-decoration: underline; }
1365+
.su-dialog-fineprint { font-size: 0.8rem; color: var(--text-muted); }
1366+
```
1367+
1368+
### Wiring (copy verbatim)
1369+
1370+
A standalone IIFE inside the tool's main `<script>` block, placed
1371+
**immediately after the theme-toggle IIFE** so it sits next to the other
1372+
chrome wiring:
1373+
1374+
```js
1375+
(() => {
1376+
const dlg = document.getElementById('privacyDialog');
1377+
document.getElementById('privacyOpen').addEventListener('click', () => dlg.showModal());
1378+
document.getElementById('privacyClose').addEventListener('click', () => dlg.close());
1379+
})();
1380+
```
1381+
1382+
Three lines: open, close, and a reference. No state, no listeners on the
1383+
backdrop, no manual focus management - the native `<dialog>` handles all of
1384+
that. Tools that ship in IE-era syntax (`var` / `function ()`) like
1385+
`index.html` mirror the same style with `var` instead of `const`; the
1386+
behaviour is identical.
1387+
12291388
## Responsive (mobile)
12301389

12311390
```css
@@ -1404,10 +1563,12 @@ that explains it in detail.
14041563
skill link button (see "Skill link button"), and theme toggle; body styled with the
14051564
shared brand variables; full SEO head block (see "SEO requirements"); `<noscript>`
14061565
fallback; `su-tool-path`, `su-tool-name`, `su-tool-description`,
1407-
`su-tool-promote` meta tags; **bottom "more free tools" banner** as the last
1408-
child of `<main>` (see "More free tools bottom CTA"); footer. Do NOT add a
1409-
separate `<nav class="su-related">` block - the banner is the sole
1410-
related-tools surface.
1566+
`su-tool-promote` meta tags; the Plausible analytics snippet in `<head>` (see
1567+
"Analytics (Plausible)"); **bottom "more free tools" banner** as the last child of
1568+
`<main>` (see "More free tools bottom CTA"); footer with a **Privacy** button (see
1569+
"Footer") backed by the canonical `<dialog>` block as the last child of `<body>`
1570+
(see "Privacy dialog"). Do NOT add a separate `<nav class="su-related">` block -
1571+
the banner is the sole related-tools surface.
14111572
2. **Author the skill** - `dev/tools/<name>.skill.md` as a real Claude Code / Cursor
14121573
SKILL.md (terse `name` + `description` frontmatter, rich Markdown body with `## Inputs`,
14131574
`## Wire format`, `## How to produce the URL` runnable snippet, `## After producing`,

dev/tools/certificate-decoder.html

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@
3030
<link rel="preconnect" href="https://fonts.googleapis.com">
3131
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
3232
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300..700&family=Roboto+Mono:wght@400..700&display=swap" rel="stylesheet">
33+
<!-- Privacy-friendly analytics by Plausible -->
34+
<script defer src="https://tools.secutils.dev/js/script.js"></script>
35+
<script>
36+
window.plausible = window.plausible || function () { (plausible.q = plausible.q || []).push(arguments) };
37+
plausible.init = plausible.init || function (i) { plausible.o = i || {} };
38+
plausible.init();
39+
</script>
3340
<style>
3441
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
3542
:root, [data-theme="dark"] {
@@ -172,6 +179,23 @@
172179
@keyframes toastIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
173180

174181
.su-footer { text-align: center; padding: 16px; border-top: 1px solid var(--border); color: var(--text-muted); font-size: 0.8rem; transition: border-color .25s, color .25s; }
182+
.su-footer p { margin: 0; }
183+
.su-footer-fineprint { margin-top: 6px !important; font-size: 0.7rem; opacity: 0.75; }
184+
.su-footer-link { background: none; border: none; padding: 0; color: inherit; font: inherit; cursor: pointer; text-decoration: underline; text-underline-offset: 2px; }
185+
.su-footer-link:hover { color: var(--text); }
186+
.su-dialog { max-width: 520px; width: calc(100% - 32px); max-height: calc(100% - 32px); inset: 0; margin: auto; padding: 0; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); color: var(--text); box-shadow: 0 20px 60px rgba(0,0,0,0.4); }
187+
.su-dialog::backdrop { background: rgba(0,0,0,0.45); backdrop-filter: blur(2px); }
188+
.su-dialog-header { display: flex; align-items: center; justify-content: space-between; padding: 14px 18px; border-bottom: 1px solid var(--border); }
189+
.su-dialog-header h2 { font-size: 1rem; font-weight: 600; }
190+
.su-dialog-close { width: 28px; height: 28px; padding: 0; display: flex; align-items: center; justify-content: center; border-radius: 50%; border: 1px solid var(--border); background: var(--surface); color: var(--text-muted); cursor: pointer; transition: all .15s; }
191+
.su-dialog-close:hover { background: var(--surface-hover); color: var(--text); }
192+
.su-dialog-body { padding: 16px 18px; font-size: 0.875rem; line-height: 1.55; color: var(--text); }
193+
.su-dialog-body p { margin-bottom: 12px; }
194+
.su-dialog-body p:last-child { margin-bottom: 0; }
195+
.su-dialog-body code { font-family: var(--mono); background: var(--surface-hover); padding: 1px 5px; border-radius: 4px; font-size: 0.85em; }
196+
.su-dialog-body a { color: var(--primary); text-decoration: none; }
197+
.su-dialog-body a:hover { text-decoration: underline; }
198+
.su-dialog-fineprint { font-size: 0.8rem; color: var(--text-muted); }
175199

176200
::-webkit-scrollbar { width: 8px; height: 8px; }
177201
::-webkit-scrollbar-track { background: var(--surface); }
@@ -282,8 +306,23 @@
282306

283307
<footer class="su-footer">
284308
<p>Decode and inspect PEM-encoded X.509 certificate chains.</p>
309+
<p class="su-footer-fineprint"><button type="button" class="su-footer-link" id="privacyOpen">Privacy</button></p>
285310
</footer>
286311

312+
<dialog id="privacyDialog" class="su-dialog" aria-labelledby="privacyDialogTitle">
313+
<header class="su-dialog-header">
314+
<h2 id="privacyDialogTitle">Privacy</h2>
315+
<button type="button" class="su-dialog-close" id="privacyClose" aria-label="Close">
316+
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 3l10 10M13 3L3 13"/></svg>
317+
</button>
318+
</header>
319+
<div class="su-dialog-body">
320+
<p><strong>Your data stays in your browser.</strong> These tools run entirely client-side. Tokens, PEMs, SAML payloads, Markdown source, and mock-response bodies are never sent to the Secutils.dev server. State that needs to survive a reload (or be shared) lives in the URL fragment (<code>#&hellip;</code>), which browsers never transmit to the server.</p>
321+
<p><strong>Anonymous usage analytics.</strong> We use <a href="https://plausible.io/" target="_blank" rel="noopener noreferrer">Plausible Analytics</a>, a privacy-first, GDPR-compliant tool, to collect aggregate usage data. No cookies, no personal data, no individual tracking. The data is limited to top pages, referral sources, visit duration, and device-class metadata (device type, OS, country, browser). Full details in the <a href="https://plausible.io/data-policy" target="_blank" rel="noopener noreferrer">Plausible Data Policy</a>.</p>
322+
<p class="su-dialog-fineprint">See the full <a href="https://secutils.dev/privacy" target="_blank" rel="noopener noreferrer">Secutils.dev privacy policy</a> for details on the wider service.</p>
323+
</div>
324+
</dialog>
325+
287326
<div id="toast" class="toast" style="display:none">
288327
<span id="toastMsg"></span>
289328
</div>
@@ -312,6 +351,12 @@
312351
} catch(e) {}
313352
})();
314353

354+
(() => {
355+
const dlg = document.getElementById('privacyDialog');
356+
document.getElementById('privacyOpen').addEventListener('click', () => dlg.showModal());
357+
document.getElementById('privacyClose').addEventListener('click', () => dlg.close());
358+
})();
359+
315360
// URL state encoding: see dev/tools/AGENTS.md → "URL state encoding".
316361
const utf8Enc = new TextEncoder();
317362
const utf8Dec = new TextDecoder();

0 commit comments

Comments
 (0)