Make lazy-auth-server usable on mobile (#714)

The consent page shipped without a viewport meta tag, so mobile browsers
laid it out at a desktop width. Its Approve/Deny buttons were 37px tall
and nested a <button> inside an <a>, which is invalid HTML — interactive
content is not permitted inside a link.

- Add a viewport meta tag to the consent page
- Replace the <a><button> nesting with anchors styled as buttons
  (role="button" plus a Space-key handler to keep button keyboard
  semantics)
- Lay the actions out as a wrapping flex row with equal 56px tap targets
- Add touch-action: manipulation so a tap resolves to a click immediately
  rather than waiting on the double-tap-to-zoom gesture
- Suppress long-press text selection and the iOS callout menu on the
  buttons, scoped so the card's client/redirect details stay copyable
- Give buttons an :active style since removing the native tap highlight
  otherwise leaves no press feedback
- Apply the same 56px minimum and touch handling to the in-app view's
  buttons (the fullscreen toggle was ~33x33)
This commit is contained in:
Mel Ludowise 2026-07-16 09:00:17 -07:00 • committed by GitHub
commit 9778cf97a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 38 additions and 6 deletions

View file

@ -414,20 +414,36 @@ async function handleAuthorize(req: Request, res: Response) {
if (state) denyUrl.searchParams.set("state", state); if (state) denyUrl.searchParams.set("state", state);
res.type("text/html").send(/*html*/ `<!DOCTYPE html> res.type("text/html").send(/*html*/ `<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Authorize</title> <html><head><meta charset="utf-8"><title>Authorize</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>body{font-family:system-ui,sans-serif;max-width:420px;margin:40px auto;padding:0 16px;color-scheme:light dark} <style>body{font-family:system-ui,sans-serif;max-width:420px;margin:40px auto;padding:0 16px;color-scheme:light dark}
.box{border:1px solid #ccc;border-radius:8px;padding:20px}dl{display:grid;grid-template-columns:auto 1fr;gap:6px 12px;font-size:14px;margin:16px 0} .box{border:1px solid #ccc;border-radius:8px;padding:20px}
dl{display:grid;grid-template-columns:auto 1fr;gap:6px 12px;font-size:14px;margin:16px 0}
dt{color:#888}dd{margin:0;word-break:break-all} dt{color:#888}dd{margin:0;word-break:break-all}
button{padding:10px 20px;margin:4px;font-size:15px;font-weight:bold;border:none;border-radius:6px;cursor:pointer} .actions{display:flex;flex-wrap:wrap;gap:12px;-webkit-user-select:none;user-select:none;-webkit-touch-callout:none}
.approve{background:#1a7a3e;color:#fff}.deny{background:#b91c1c;color:#fff}</style></head> .btn{box-sizing:border-box;flex:1;min-height:56px;min-width:56px;display:flex;align-items:center;justify-content:center;
padding:12px 20px;font-size:16px;font-weight:bold;border:none;border-radius:8px;cursor:pointer;
text-decoration:none;color:#fff;touch-action:manipulation;-webkit-tap-highlight-color:transparent}
.btn:active{filter:brightness(0.8)}
.approve{background:#1a7a3e}.deny{background:#b91c1c}</style></head>
<body><div class="box"> <body><div class="box">
<h2>🔑 Mock Authorization</h2> <h2>🔑 Mock Authorization</h2>
<p>An application is requesting access:</p> <p>An application is requesting access:</p>
<dl><dt>Client</dt><dd>${escapeHtml(client_id ?? "(none)")}</dd> <dl><dt>Client</dt><dd>${escapeHtml(client_id ?? "(none)")}</dd>
<dt>Scope</dt><dd>${escapeHtml(scope ?? "(default)")}</dd> <dt>Scope</dt><dd>${escapeHtml(scope ?? "(default)")}</dd>
<dt>Redirect</dt><dd>${escapeHtml(redirect_uri)}</dd></dl> <dt>Redirect</dt><dd>${escapeHtml(redirect_uri)}</dd></dl>
<a href="${escapeHtml(approveUrl.href)}"><button class="approve">Approve</button></a> <div class="actions">
<a href="${escapeHtml(denyUrl.href)}"><button class="deny">Deny</button></a> <a class="btn approve" role="button" href="${escapeHtml(approveUrl.href)}">Approve</a>
</div></body></html>`); <a class="btn deny" role="button" href="${escapeHtml(denyUrl.href)}">Deny</a>
</div>
</div>
<script>
// Anchors only activate on Enter; role="button" promises Space too.
for (const a of document.querySelectorAll(".btn"))
a.addEventListener("keydown", (e) => {
if (e.key === " ") { e.preventDefault(); a.click(); }
});
</script>
</body></html>`);
return; return;
} }

View file

@ -17,9 +17,17 @@
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: var(--spacing-sm); gap: var(--spacing-sm);
/* Keep a long press on or near the buttons from starting text selection
or raising the iOS callout menu instead of a click. */
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
} }
button { button {
/* Match the consent page's 56px tap targets (server.ts .btn). */
min-height: 56px;
min-width: 56px;
padding: var(--spacing-sm) var(--spacing-md); padding: var(--spacing-sm) var(--spacing-md);
border: none; border: none;
border-radius: var(--border-radius-md); border-radius: var(--border-radius-md);
@ -27,12 +35,20 @@ button {
font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold);
background-color: var(--color-accent); background-color: var(--color-accent);
cursor: pointer; cursor: pointer;
/* Suppress the double-tap-to-zoom gesture so a tap resolves to a click
immediately instead of waiting to see if a second tap follows. */
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
&:disabled { &:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
&:active:not(:disabled) {
filter: brightness(0.85);
}
&:hover:not(:disabled) { &:hover:not(:disabled) {
background-color: color-mix( background-color: color-mix(
in srgb, in srgb,