Skip to content

Commit 6becdd3

Browse files
committed
feat(comments): resend-code link with 30s cooldown + docs polish
Two independent additions grouped because they share the docs set I needed to re-align for the release pipeline diagram: - comments.js: after the magic-code form auths, surface a "Resend code" link that's disabled for 30s then clickable. Shows "(Xs)" countdown while the timer runs. Also persists the compose-textarea size to localStorage (`socket-pages:compose-size`) so readers don't lose their preferred editor height across page loads. - docs/: release.md + pages-design-system.md + tour.md + hardening.md + converters.md get box-drawing alignment, typo fixes, and a couple of small clarifications. The release.md frame-line comment explains why every line has to render at exactly 66 display cells (east-asian-width trap).
1 parent 4a5be23 commit 6becdd3

6 files changed

Lines changed: 260 additions & 69 deletions

File tree

comments.js

Lines changed: 159 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
const BACKEND = (cfg.backend || '').replace(/\/+$/, '')
2323
const JWT_KEY = 'socket-pages:jwt'
2424
const EMAIL_KEY = 'socket-pages:email'
25+
const COMPOSE_SIZE_KEY = 'socket-pages:compose-size'
2526

2627
const slug = document.body.getAttribute('data-slug') || ''
2728
const partId = Number.parseInt(
@@ -731,6 +732,10 @@
731732
</label>
732733
<button type="submit" class="wt-primary">Verify</button>
733734
<button type="button" class="wt-secondary wt-cancel">Cancel</button>
735+
<p class="wt-resend">
736+
<button type="button" class="wt-resend-link" disabled>Resend code</button>
737+
<span class="wt-resend-timer" aria-live="polite"></span>
738+
</p>
734739
<p class="wt-error" aria-live="polite"></p>
735740
`
736741
// Set the email display via textContent, not string interp.
@@ -758,6 +763,66 @@
758763
close()
759764
resolve(false)
760765
})
766+
767+
// Resend-code link with a 30s cooldown. Disabled + shows
768+
// "(Xs)" until the timer hits 0, then becomes clickable.
769+
// Clicking re-POSTs /auth/request for the pending email
770+
// and restarts the cooldown. Inline status writes into
771+
// `.wt-resend-timer` so assistive tech hears each
772+
// transition (aria-live="polite"). Clean up the
773+
// interval on close() so it doesn't fire after the
774+
// dialog is gone.
775+
const resendBtn = form.querySelector('.wt-resend-link')
776+
const resendTimer = form.querySelector('.wt-resend-timer')
777+
let cooldownSecs = 30
778+
let resendInterval = null
779+
const startCooldown = () => {
780+
cooldownSecs = 30
781+
resendBtn.disabled = true
782+
resendTimer.textContent = `(${cooldownSecs}s)`
783+
clearInterval(resendInterval)
784+
resendInterval = setInterval(() => {
785+
cooldownSecs -= 1
786+
if (cooldownSecs <= 0) {
787+
clearInterval(resendInterval)
788+
resendInterval = null
789+
resendBtn.disabled = false
790+
resendTimer.textContent = ''
791+
return
792+
}
793+
resendTimer.textContent = `(${cooldownSecs}s)`
794+
}, 1000)
795+
}
796+
resendBtn.addEventListener('click', async () => {
797+
if (resendBtn.disabled) {
798+
return
799+
}
800+
resendBtn.disabled = true
801+
resendTimer.textContent = 'sending…'
802+
try {
803+
const res = await api('/auth/request', {
804+
method: 'POST',
805+
body: JSON.stringify({ email: pendingEmail }),
806+
})
807+
if (!res.ok) {
808+
throw new Error('Could not resend. Try again.')
809+
}
810+
startCooldown()
811+
} catch (e) {
812+
resendBtn.disabled = false
813+
resendTimer.textContent = ''
814+
errEl.innerHTML = ''
815+
errEl.textContent = e?.message || 'Could not resend.'
816+
}
817+
})
818+
// Arm the cooldown on first render so step 2 opens with
819+
// the resend link disabled (the code was just sent).
820+
startCooldown()
821+
// Tear down the interval when the dialog closes (cancel
822+
// button, outside-click, Escape) so timers don't leak.
823+
overlay.addEventListener('close', () => clearInterval(resendInterval), {
824+
once: true,
825+
})
761826
}
762827

763828
form.addEventListener('submit', async e => {
@@ -823,7 +888,18 @@
823888

824889
const ensureAuth = async () => {
825890
if (state.jwt) {
826-
return true
891+
// Stored token may be stale (expired, revoked, backend
892+
// reset its signing key). Verify before trusting it —
893+
// without this, the UI silently accepts the stale token,
894+
// later API calls 401, and the sign-in dialog never
895+
// appears because `ensureAuth` already returned true.
896+
// `silentCheck` hits /auth/check with a short timeout;
897+
// on failure we drop the token, clear the email, and
898+
// fall through to the sign-in flow.
899+
if (await silentCheck()) {
900+
return true
901+
}
902+
saveJwt(null, null)
827903
}
828904
return runAuthFlow()
829905
}
@@ -1074,17 +1150,48 @@
10741150
return
10751151
}
10761152
case 'delete-comment': {
1153+
// Inline confirm UI — replace the .wt-actions row with
1154+
// a short Yes/No prompt. Native browser confirm()
1155+
// dialogs look out-of-place against the site chrome
1156+
// and steal focus. An inline prompt stays in the
1157+
// comment's own card, carries the site's colors, and
1158+
// is cancellable without hitting Escape on a modal.
10771159
const id = actionEl.dataset.id
1078-
if (!confirm('Delete this comment?')) {
1160+
const card = actionEl.closest('.wt-comment')
1161+
const actions = card?.querySelector('.wt-actions')
1162+
if (!card || !actions) {
10791163
return
10801164
}
1165+
const confirmEl = document.createElement('div')
1166+
confirmEl.className = 'wt-confirm'
1167+
confirmEl.innerHTML = `
1168+
<span class="wt-confirm-msg">Delete this comment?</span>
1169+
<button type="button" class="wt-confirm-yes" data-action="delete-confirm" data-id="${esc(id)}">Delete</button>
1170+
<button type="button" class="wt-confirm-no" data-action="delete-cancel">Cancel</button>
1171+
`
1172+
actions.style.display = 'none'
1173+
card.appendChild(confirmEl)
1174+
confirmEl.querySelector('.wt-confirm-yes')?.focus()
1175+
return
1176+
}
1177+
case 'delete-cancel': {
1178+
const card = actionEl.closest('.wt-comment')
1179+
card?.querySelector('.wt-confirm')?.remove()
1180+
const actions = card?.querySelector('.wt-actions')
1181+
if (actions) {
1182+
actions.style.display = ''
1183+
}
1184+
return
1185+
}
1186+
case 'delete-confirm': {
1187+
const id = actionEl.dataset.id
10811188
try {
10821189
await apiJson(`/${slug}/api/comments/${id}`, { method: 'DELETE' })
10831190
state.comments = state.comments.filter(x => x.id !== id)
10841191
renderAll()
10851192
refreshUnresolvedCount()
10861193
} catch {
1087-
/* ignore */
1194+
/* ignore — the card just stays; user can retry */
10881195
}
10891196
return
10901197
}
@@ -1125,8 +1232,11 @@
11251232
// Draft auto-save keys. Scoped by file+line+parent so two composers
11261233
// on different anchors keep separate drafts, and a reply draft
11271234
// doesn't clobber a top-level draft on the same selection.
1235+
// Namespaced under `socket-pages:draft:` alongside theme / jwt /
1236+
// email / compose-size so every client-side pref shares one
1237+
// prefix (easy to audit / clear as a group).
11281238
const draftKey = (file, lineFrom, lineTo, parentId) =>
1129-
`wt:draft:${slug}:${file}:${lineFrom}-${lineTo}:${parentId || 'root'}`
1239+
`socket-pages:draft:${slug}:${file}:${lineFrom}-${lineTo}:${parentId || 'root'}`
11301240

11311241
// localStorage-backed draft store, gated by `navigator.locks` so
11321242
// two tabs that open the same composer don't race each other
@@ -1182,12 +1292,29 @@
11821292

11831293
const dialog = document.createElement('dialog')
11841294
dialog.className = 'wt-comment-form'
1295+
// Restore the user's last-chosen compose-size preference.
1296+
// `compact` (default) = anchored to the selected lines,
1297+
// textarea sized for a quick note. `fill` = gmail-style
1298+
// full-area composer, roomy for longer drafts. Persisted
1299+
// to localStorage under the same `socket-pages:` namespace
1300+
// as theme / jwt / email so all client-side prefs sit on
1301+
// one prefix. Errors swallowed (private mode / quota).
1302+
try {
1303+
if (localStorage.getItem(COMPOSE_SIZE_KEY) === 'fill') {
1304+
dialog.classList.add('wt-comment-form-fill')
1305+
}
1306+
} catch {
1307+
/* localStorage unavailable — default to compact */
1308+
}
11851309
const form = document.createElement('form')
11861310
form.method = 'dialog'
11871311
form.innerHTML = `
11881312
<div class="wt-comment-header">
11891313
<strong>${esc(file)}</strong>
11901314
<span>Lines ${lineFrom}${lineTo !== lineFrom ? '–' + lineTo : ''}</span>
1315+
<button type="button" class="wt-comment-size-toggle"
1316+
aria-label="Toggle composer size"
1317+
title="Toggle composer size"></button>
11911318
</div>
11921319
<textarea class="wt-input wt-textarea" placeholder="Write a comment…" required maxlength="10000" enterkeyhint="send"></textarea>
11931320
<div class="wt-row">
@@ -1199,6 +1326,34 @@
11991326
dialog.appendChild(form)
12001327
document.body.appendChild(dialog)
12011328

1329+
// Size-toggle: flips between compact (anchored-to-code)
1330+
// and fill (gmail-style). Persists the user's choice so
1331+
// the next comment they write opens in the same mode.
1332+
const sizeToggle = dialog.querySelector('.wt-comment-size-toggle')
1333+
const updateToggleLabel = () => {
1334+
const isFill = dialog.classList.contains('wt-comment-form-fill')
1335+
sizeToggle.textContent = isFill ? '⤡' : '⤢'
1336+
sizeToggle.setAttribute(
1337+
'aria-label',
1338+
isFill ? 'Shrink composer' : 'Expand composer',
1339+
)
1340+
sizeToggle.setAttribute(
1341+
'title',
1342+
isFill ? 'Shrink composer' : 'Expand composer',
1343+
)
1344+
}
1345+
updateToggleLabel()
1346+
sizeToggle.addEventListener('click', () => {
1347+
dialog.classList.toggle('wt-comment-form-fill')
1348+
const isFill = dialog.classList.contains('wt-comment-form-fill')
1349+
try {
1350+
localStorage.setItem(COMPOSE_SIZE_KEY, isFill ? 'fill' : 'compact')
1351+
} catch {
1352+
/* best-effort persistence */
1353+
}
1354+
updateToggleLabel()
1355+
})
1356+
12021357
const close = () => {
12031358
if (dialog.open) {
12041359
dialog.close()

docs/converters.md

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,20 @@ not PURL.
1313

1414
## The three directions
1515

16+
<!-- Box-drawing alignment note: every frame line below must
17+
render at exactly 66 display cells. No emoji or CJK chars
18+
(those are 2 cells wide in monospace). Verify widths with:
19+
python3 -c "import unicodedata; [print(sum(2 if unicodedata.east_asian_width(c) in ('W','F') else 1 for c in l.rstrip())) for l in open('docs/converters.md').readlines()[21:29]]"
20+
-->
21+
1622
```
17-
┌────────────┐ fromUrl() ┌────────────┐
18-
│ URL │ ──────────────────▶│ PackageURL │
19-
│ │ │ │
20-
│ │◀──────────────────┐│ │
21-
│ │ toRepositoryUrl()├│ │
22-
│ │ toDownloadUrl() ││ │
23-
│ │ ││ │
24-
└────────────┘ │└────────────┘
25-
26-
getAllUrls() returns both
23+
┌───────────────────────────────────────────────────────────────┐
24+
│ URL -----------------fromUrl()-----------> PackageURL │
25+
│ <---------------toRepositoryUrl()---- │
26+
│ <---------------toDownloadUrl()------ │
27+
│ │
28+
│ getAllUrls() returns both directions at once │
29+
└───────────────────────────────────────────────────────────────┘
2730
```
2831

2932
- **`UrlConverter.fromUrl(str)`** — URL string → PackageURL (or

docs/hardening.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,12 @@ This doc is how the library refuses all six.
5858
`src/strings.ts` exports `isInjectionCharCode(code: number)`. It
5959
returns `true` for any character code in one of four classes:
6060

61-
| Class | Codes | Why |
62-
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
63-
| **C0 control characters** | `0x00``0x1f` | NUL (truncation), TAB / LF / CR (log injection), ESC (terminal escape), everything else in that range |
64-
| **Shell metacharacters + brackets + quotes** | `0x20` (space), `!`, `"`, `#`, `$`, `%`, `&`, `'`, `(`, `)`, `*`, `;`, `<`, `=`, `>`, `?`, `[`, `\`, `]`, `` ` ``, `{`, `\|` (pipe), `}`, `~`, DEL | Shell interpretation, SQL quote-escape, URL-fragment injection |
65-
| **C1 control characters** | `0x80``0x9f` | Legacy control bytes; some terminals still act on them |
66-
| **Unicode invisible/directional** | `U+200B``U+200F`, `U+202A``U+202E`, `U+2060`, `U+FEFF`, `U+FFFC`, `U+FFFD` | Zero-width chars, bidi override characters (IDN-homograph attacks), BOM, object replacement |
61+
| Class | Codes | Why |
62+
| -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
63+
| **C0 control characters** | `0x00``0x1f` | NUL (truncation), TAB / LF / CR (log injection), ESC (terminal escape), everything else in that range |
64+
| **Shell metacharacters + brackets + quotes** | `0x20` (space), `!`, `"`, `#`, `$`, `%`, `&`, `'`, `(`, `)`, `*`, `;`, `<`, `=`, `>`, `?`, `[`, `\`, `]`, `` ` ``, `{`, `\|` (pipe), `}`, `~`, DEL | Shell interpretation, SQL quote-escape, URL-fragment injection |
65+
| **C1 control characters** | `0x80``0x9f` | Legacy control bytes; some terminals still act on them |
66+
| **Unicode invisible/directional** | `U+200B``U+200F`, `U+202A``U+202E`, `U+2060`, `U+FEFF`, `U+FFFC`, `U+FFFD` | Zero-width chars, bidi override characters (IDN-homograph attacks), BOM, object replacement |
6767

6868
Any input containing one of these characters in a component where
6969
we scan for injection throws `PurlInjectionError` before the

0 commit comments

Comments
 (0)