@supersuit/suggest-a-change 0.1.12 → 0.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.
Files changed (62) hide show
  1. package/CHANGELOG.md +22 -1
  2. package/README.md +163 -8
  3. package/dist/client/adapters.d.ts +27 -0
  4. package/dist/client/context.d.ts +61 -0
  5. package/dist/client/context.js +279 -0
  6. package/dist/client/detail.d.ts +2 -2
  7. package/dist/client/detail.js +12 -8
  8. package/dist/client/entry.js +10 -5
  9. package/dist/client/index.d.ts +2 -0
  10. package/dist/client/index.js +1 -0
  11. package/dist/client/mine.js +4 -2
  12. package/dist/client/runtime.js +3 -0
  13. package/dist/client/seen.d.ts +12 -0
  14. package/dist/client/seen.js +30 -7
  15. package/dist/client/sheet.js +64 -4
  16. package/dist/client/words.d.ts +10 -1
  17. package/dist/client/words.js +11 -2
  18. package/dist/contract.d.mts +32 -22
  19. package/dist/contract.mjs +127 -37
  20. package/dist/github/api.d.ts +100 -0
  21. package/dist/github/api.js +164 -0
  22. package/dist/github/compose.d.ts +152 -0
  23. package/dist/github/compose.js +309 -0
  24. package/dist/github/filer.d.ts +72 -0
  25. package/dist/github/filer.js +89 -0
  26. package/dist/github/hosts.d.ts +41 -0
  27. package/dist/github/hosts.js +154 -0
  28. package/dist/github/index.d.ts +12 -0
  29. package/dist/github/index.js +9 -0
  30. package/dist/github/privacy.d.ts +34 -0
  31. package/dist/github/privacy.js +142 -0
  32. package/dist/github/relay.d.ts +159 -0
  33. package/dist/github/relay.js +734 -0
  34. package/dist/server/geo.d.ts +17 -0
  35. package/dist/server/geo.js +35 -0
  36. package/dist/server/handlers.d.ts +54 -3
  37. package/dist/server/handlers.js +143 -41
  38. package/dist/server/index.d.ts +3 -1
  39. package/dist/server/index.js +1 -0
  40. package/package.json +5 -1
  41. package/src/client/adapters.ts +18 -1
  42. package/src/client/context.ts +243 -0
  43. package/src/client/detail.tsx +13 -8
  44. package/src/client/entry.tsx +9 -5
  45. package/src/client/index.ts +2 -0
  46. package/src/client/mine.tsx +7 -3
  47. package/src/client/runtime.ts +3 -0
  48. package/src/client/seen.ts +27 -6
  49. package/src/client/sheet.tsx +78 -4
  50. package/src/client/styles.css +11 -2
  51. package/src/client/words.ts +15 -2
  52. package/src/contract.mjs +110 -37
  53. package/src/github/api.ts +169 -0
  54. package/src/github/compose.ts +354 -0
  55. package/src/github/filer.ts +96 -0
  56. package/src/github/hosts.ts +117 -0
  57. package/src/github/index.ts +15 -0
  58. package/src/github/privacy.ts +131 -0
  59. package/src/github/relay.ts +637 -0
  60. package/src/server/geo.ts +32 -0
  61. package/src/server/handlers.ts +158 -45
  62. package/src/server/index.ts +3 -1
@@ -0,0 +1,243 @@
1
+ // EVERY PIECE OF CONTEXT THAT HELPS FIX IT, collected by the module with no app code. Each item says, in a line written
2
+ // for the person sending, why it is shared; the sheet lists them all, on by default, and any one can be left out.
3
+ //
4
+ // The trail (the last pages, taps and errors) is kept from the moment the module first mounts, in memory only, in
5
+ // small ring buffers. NEVER captured: what anyone typed (an input's value), cookies, storage, or a query string.
6
+ import type { Identity } from './adapters.ts'
7
+
8
+ export interface ContextItem { key: string; label: string; value: string; why: string }
9
+ export interface Trail { routes: { route: string; at: string }[]; taps: { label: string; at: string }[]; errors: { message: string; frame?: string; at: string }[] }
10
+ export interface ReportContext extends Trail { items: ContextItem[] }
11
+ /** What an app adds through the `context` adapter: anything app-specific, each with why it helps. */
12
+ export interface AppContextItem { label: string; value: string; why: string }
13
+
14
+ const MAX = { routes: 10, taps: 20, errors: 10, items: 40 }
15
+ // ONE trail per page, however many copies of the module a host bundles: the state lives on the global object under a
16
+ // registered symbol, so a second copy finds the first one's listeners installed and shares its trail.
17
+ type Shared = { trail: Trail; installedAt: number; undo: (() => void) | null }
18
+ const SHARED = Symbol.for('@supersuit/suggest-a-change/context')
19
+ const shared: Shared = ((globalThis as Record<symbol, unknown>)[SHARED] as Shared | undefined)
20
+ ?? ((globalThis as Record<symbol, unknown>)[SHARED] = { trail: { routes: [], taps: [], errors: [] }, installedAt: 0, undo: null }) as Shared
21
+ const trail = shared.trail
22
+
23
+ const clip = (s: string, max: number) => (s.length <= max ? s : `${s.slice(0, max - 1).trimEnd()}…`)
24
+ const iso = () => new Date().toISOString()
25
+ const push = <T>(list: T[], x: T, max: number) => { list.push(x); if (list.length > max) list.splice(0, list.length - max) }
26
+ /** A URL or path with no query and no hash, wherever it appears in a piece of text. */
27
+ export const withoutQueries = (s: string) => s.replace(/([^\s?#'"()]*)[?#][^\s'"():]*/g, (_m, before: string) => before)
28
+ /** A path segment that is an id, a token or an address rather than a name: a uuid, a long number, a long hex or
29
+ * base64 run with letters and digits, anything over 24 characters, or anything with an @. */
30
+ const idLike = (raw: string) => {
31
+ let s = raw
32
+ try { s = decodeURIComponent(raw) } catch { /* as it is */ }
33
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s)
34
+ || /^\d{4,}$/.test(s)
35
+ || (/^[0-9a-f]{12,}$/i.test(s) && /\d/.test(s))
36
+ || (/^[A-Za-z0-9_-]{16,}$/.test(s) && /\d/.test(s) && /[A-Za-z]/.test(s))
37
+ || s.length > 24
38
+ || s.includes('@')
39
+ }
40
+ const routeOf = (href: string) => {
41
+ let path: string
42
+ try { path = new URL(href, 'http://x').pathname } catch { path = String(href).split(/[?#]/)[0] }
43
+ return path.split('/').map((seg) => (seg && idLike(seg) ? ':id' : seg)).join('/')
44
+ }
45
+
46
+ function recordRoute() {
47
+ if (typeof location === 'undefined') return
48
+ const route = routeOf(location.href)
49
+ if (trail.routes.at(-1)?.route === route) return
50
+ push(trail.routes, { route: clip(route, 300), at: iso() }, MAX.routes)
51
+ }
52
+
53
+ /** What a tapped thing is called: its label or its text, never what was typed into it. */
54
+ function labelOf(target: EventTarget | null): string | null {
55
+ if (!(target instanceof Element)) return null
56
+ // Anything someone types into is "a text field", by its own label at most and never its text: an editable region,
57
+ // a text, search or combo box, wherever inside it the tap landed.
58
+ const typing = target.closest('[contenteditable]:not([contenteditable="false"]), [role="textbox"], [role="searchbox"], [role="combobox"]')
59
+ if (typing) return clip(typing.getAttribute('aria-label')?.trim() || 'a text field', 60)
60
+ const el = target.closest('button, a, summary, label, select, textarea, input, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="checkbox"], [aria-label]') ?? target
61
+ const aria = el.getAttribute('aria-label')?.trim()
62
+ if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement) {
63
+ if (aria) return clip(aria, 60)
64
+ const byId = el.id ? document.querySelector(`label[for="${CSS.escape(el.id)}"]`)?.textContent?.trim() : ''
65
+ if (byId) return clip(byId.replace(/\s+/g, ' '), 60)
66
+ if (el instanceof HTMLTextAreaElement) return 'a text box'
67
+ if (el instanceof HTMLSelectElement) return 'a menu'
68
+ const type = el.type
69
+ return type === 'checkbox' ? 'a checkbox' : type === 'radio' ? 'a choice' : type === 'button' || type === 'submit' ? clip(el.value ? 'a button' : 'a button', 60) : 'a text field'
70
+ }
71
+ if (aria) return clip(aria, 60)
72
+ const text = (el.textContent ?? '').replace(/\s+/g, ' ').trim()
73
+ if (text) return clip(text, 60)
74
+ const title = el.getAttribute('title')?.trim()
75
+ return title ? clip(title, 60) : el.tagName.toLowerCase()
76
+ }
77
+
78
+ const firstFrame = (stack: unknown): string | undefined => {
79
+ if (typeof stack !== 'string') return undefined
80
+ const line = stack.split('\n').slice(1).map((l) => l.trim()).find((l) => /^at |@/.test(l))
81
+ return line ? clip(withoutQueries(line), 200) : undefined
82
+ }
83
+ const messageOf = (x: unknown): string => (x instanceof Error ? x.message : typeof x === 'string' ? x : x === undefined ? '' : typeof x === 'number' || typeof x === 'boolean' ? String(x) : '[object]')
84
+ function recordError(message: string, stack?: unknown) {
85
+ push(trail.errors, { message: clip(withoutQueries(message || 'An error with no message'), 300), ...(firstFrame(stack) ? { frame: firstFrame(stack) } : {}), at: iso() }, MAX.errors)
86
+ }
87
+
88
+ /** Starts keeping the trail. Idempotent: every screen of the module calls it on mount, and it installs once. */
89
+ export function installContextListeners(): void {
90
+ if (shared.undo || typeof window === 'undefined' || typeof document === 'undefined') return
91
+ shared.installedAt = Date.now()
92
+ // A wrapper a host put on top of ours may outlive a reset; once reset, ours only passes the call through.
93
+ let live = true
94
+ recordRoute()
95
+ const onClick = (e: Event) => { const l = labelOf(e.target); if (l) push(trail.taps, { label: l, at: iso() }, MAX.taps) }
96
+ const onError = (e: Event) => { const ev = e as ErrorEvent; recordError(ev.message || messageOf(ev.error), (ev.error as { stack?: unknown } | undefined)?.stack) }
97
+ const onRejection = (e: Event) => { const reason = (e as PromiseRejectionEvent).reason; recordError(`Unhandled rejection: ${messageOf(reason)}`, (reason as { stack?: unknown } | undefined)?.stack) }
98
+ const onNav = () => recordRoute()
99
+ document.addEventListener('click', onClick, true)
100
+ window.addEventListener('error', onError)
101
+ window.addEventListener('unhandledrejection', onRejection)
102
+ window.addEventListener('popstate', onNav)
103
+ window.addEventListener('hashchange', onNav)
104
+ const { pushState, replaceState } = history
105
+ const ourPush = function (this: History, ...a: Parameters<History['pushState']>) { const r = pushState.apply(this, a); if (live) recordRoute(); return r }
106
+ history.pushState = ourPush
107
+ const ourReplace = function (this: History, ...a: Parameters<History['replaceState']>) { const r = replaceState.apply(this, a); if (live) recordRoute(); return r }
108
+ history.replaceState = ourReplace
109
+ const consoleError = console.error
110
+ const ourError = function (this: Console, ...args: unknown[]) {
111
+ if (live) try { recordError(args.map(messageOf).filter(Boolean).join(' '), (args.find((a) => a instanceof Error) as Error | undefined)?.stack) } catch { /* the trail never breaks logging */ }
112
+ return consoleError.apply(this, args as [])
113
+ }
114
+ console.error = ourError
115
+ shared.undo = () => {
116
+ live = false
117
+ document.removeEventListener('click', onClick, true)
118
+ window.removeEventListener('error', onError)
119
+ window.removeEventListener('unhandledrejection', onRejection)
120
+ window.removeEventListener('popstate', onNav)
121
+ window.removeEventListener('hashchange', onNav)
122
+ // Put back only what is still ours: a wrapper the host added later stays, and calls through ours harmlessly.
123
+ if (history.pushState === ourPush) history.pushState = pushState
124
+ if (history.replaceState === ourReplace) history.replaceState = replaceState
125
+ if (console.error === ourError) console.error = consoleError
126
+ }
127
+ }
128
+
129
+ /** Stops keeping the trail and forgets it (tests, and a host that signs someone out). */
130
+ export function resetContext(): void {
131
+ shared.undo?.(); shared.undo = null
132
+ trail.routes.length = 0; trail.taps.length = 0; trail.errors.length = 0
133
+ }
134
+
135
+ /** A copy of the trail as it stands. */
136
+ export const contextTrail = (): Trail => ({ routes: trail.routes.map((r) => ({ ...r })), taps: trail.taps.map((t) => ({ ...t })), errors: trail.errors.map((e) => ({ ...e })) })
137
+
138
+ /** A user agent in plain words: "iPhone, iOS 18.1, Safari 18.1". */
139
+ export function parseUserAgent(ua: string): string {
140
+ const v = (re: RegExp) => re.exec(ua)?.[1]?.replace(/_/g, '.')
141
+ let device = 'Unknown device'; let os = ''
142
+ if (/iPhone/.test(ua)) { device = 'iPhone'; os = `iOS ${v(/OS (\d+[_.]\d+)/) ?? ''}`.trim() }
143
+ else if (/iPad/.test(ua)) { device = 'iPad'; os = `iPadOS ${v(/OS (\d+[_.]\d+)/) ?? ''}`.trim() }
144
+ else if (/Android/.test(ua)) { device = /Mobile/.test(ua) ? 'Android phone' : 'Android tablet'; os = `Android ${v(/Android (\d+(?:\.\d+)?)/) ?? ''}`.trim() }
145
+ else if (/Macintosh|Mac OS X/.test(ua)) { device = 'Mac'; os = 'macOS' }
146
+ else if (/Windows/.test(ua)) { device = 'Windows PC'; os = 'Windows' }
147
+ else if (/CrOS/.test(ua)) { device = 'Chromebook'; os = 'ChromeOS' }
148
+ else if (/Linux/.test(ua)) { device = 'Linux computer'; os = 'Linux' }
149
+ const browser =
150
+ v(/Edg(?:A|iOS)?\/(\d+)/) ? `Edge ${v(/Edg(?:A|iOS)?\/(\d+)/)}`
151
+ : v(/(?:Firefox|FxiOS)\/(\d+)/) ? `Firefox ${v(/(?:Firefox|FxiOS)\/(\d+)/)}`
152
+ : v(/(?:Chrome|CriOS)\/(\d+)/) ? `Chrome ${v(/(?:Chrome|CriOS)\/(\d+)/)}`
153
+ : /Safari/.test(ua) && v(/Version\/(\d+(?:\.\d+)?)/) ? `Safari ${v(/Version\/(\d+(?:\.\d+)?)/)}`
154
+ : ''
155
+ return [device, os, browser].filter(Boolean).join(', ')
156
+ }
157
+
158
+ /** The device in plain words from User-Agent Client Hints when the browser has them, else from the user agent. */
159
+ async function deviceWords(given?: string): Promise<string> {
160
+ if (given) return parseUserAgent(given)
161
+ const nav = typeof navigator === 'undefined' ? undefined : (navigator as Navigator & { userAgentData?: { getHighEntropyValues?(h: string[]): Promise<Record<string, unknown>> } })
162
+ const uad = nav?.userAgentData
163
+ if (uad?.getHighEntropyValues) {
164
+ try {
165
+ const h = await Promise.race([uad.getHighEntropyValues(['platform', 'platformVersion', 'model', 'fullVersionList', 'mobile']), new Promise<null>((r) => setTimeout(() => r(null), 300))])
166
+ if (h) {
167
+ const brands = (h.fullVersionList as { brand: string; version: string }[] | undefined)?.filter((b) => !/Not.?A.?Brand|Chromium/i.test(b.brand)) ?? []
168
+ const b = brands.find((x) => /Edge/.test(x.brand)) ?? brands[0]
169
+ const platform = String(h.platform ?? '')
170
+ const device = String(h.model ?? '') || (platform === 'macOS' ? 'Mac' : platform === 'Windows' ? 'Windows PC' : platform === 'Android' ? (h.mobile ? 'Android phone' : 'Android tablet') : platform || 'Unknown device')
171
+ const os = [platform, String(h.platformVersion ?? '').split('.').slice(0, 2).join('.')].filter(Boolean).join(' ')
172
+ return [device, os, b ? `${b.brand.replace('Google ', '').replace('Microsoft ', '')} ${b.version.split('.')[0]}` : ''].filter(Boolean).join(', ')
173
+ }
174
+ } catch { /* fall back to the user agent */ }
175
+ }
176
+ return parseUserAgent(nav?.userAgent ?? '')
177
+ }
178
+
179
+ const two = (n: number) => String(n).padStart(2, '0')
180
+ function timeWords(d: Date): string {
181
+ const zone = (() => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone } catch { return '' } })()
182
+ const off = -d.getTimezoneOffset()
183
+ const offset = `UTC${off >= 0 ? '+' : '-'}${two(Math.floor(Math.abs(off) / 60))}:${two(Math.abs(off) % 60)}`
184
+ return `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())} ${two(d.getHours())}:${two(d.getMinutes())} (${[zone, offset].filter(Boolean).join(', ')})`
185
+ }
186
+ const media = (q: string) => { try { return typeof matchMedia === 'function' && matchMedia(q).matches } catch { return false } }
187
+
188
+ export interface CollectOptions {
189
+ identity?: Identity | null
190
+ where?: { screen?: string; route?: string }
191
+ about?: { app?: string; version?: string }
192
+ /** The app's own items (the `context` adapter). */
193
+ extra?: () => Promise<AppContextItem[]>
194
+ /** Ask the browser for a precise location (shows its permission prompt). Off unless the app opts in. */
195
+ precise?: boolean
196
+ /** A user agent to read instead of the browser's (tests). */
197
+ userAgent?: string
198
+ now?: () => number
199
+ }
200
+
201
+ /** Everything the module knows that would help fix it, each item with why it is shared, and the trail. */
202
+ export async function collectContext(o: CollectOptions = {}): Promise<ReportContext> {
203
+ const now = new Date(o.now?.() ?? Date.now())
204
+ const items: ContextItem[] = []
205
+ const add = (key: string, label: string, value: string | undefined | null, why: string) => { if (value) items.push({ key, label: clip(label, 80), value: clip(value, 500), why: clip(why, 200) }) }
206
+ const who = o.identity as (Identity & { name?: string; username?: string }) | null | undefined
207
+ add('account', 'Your name in the app', who?.name || who?.username, 'so whoever fixes it knows who to thank, and can look at your account if the problem is in it')
208
+ add('time', 'Your local time', timeWords(now), 'so the fixer can line it up with what the app logged at that moment')
209
+ const langs = typeof navigator === 'undefined' ? [] : (navigator.languages?.length ? [...navigator.languages] : navigator.language ? [navigator.language] : [])
210
+ add('language', 'Your languages', langs.join(', '), 'some problems only happen in one language or date format')
211
+ add('device', 'Your device', await deviceWords(o.userAgent), 'so the fixer can try it on the same kind of device and browser')
212
+ if (typeof window !== 'undefined') {
213
+ const s = typeof screen === 'undefined' ? null : screen
214
+ add('screen', 'Screen and window', `${s ? `${s.width}x${s.height} screen, ` : ''}${window.innerWidth}x${window.innerHeight} window, ${window.devicePixelRatio || 1}x pixels`, 'how things are laid out depends on the size of the screen and window')
215
+ const look = [media('(prefers-color-scheme: dark)') ? 'dark mode' : 'light mode', ...(media('(prefers-reduced-motion: reduce)') ? ['reduced motion'] : [])]
216
+ add('appearance', 'Appearance', look.join(', '), 'some problems only show in dark mode or with animations turned down')
217
+ }
218
+ if (typeof navigator !== 'undefined') {
219
+ const c = (navigator as Navigator & { connection?: { type?: string; effectiveType?: string } }).connection
220
+ add('connection', 'Connection', [navigator.onLine === false ? 'offline' : 'online', c?.type, c?.effectiveType && `${c.effectiveType} speed`].filter(Boolean).join(', '), 'a slow or dropped connection explains many problems')
221
+ }
222
+ const since = shared.installedAt || (typeof performance !== 'undefined' ? performance.timeOrigin : 0)
223
+ if (since) {
224
+ const min = Math.max(0, Math.round((now.getTime() - since) / 60_000))
225
+ add('age', 'How long the page was open', min < 1 ? 'under a minute' : `${min} minute${min === 1 ? '' : 's'}`, 'some problems only start after a page has been open a while')
226
+ }
227
+ if (o.precise && typeof navigator !== 'undefined' && navigator.geolocation) {
228
+ const pos = await new Promise<GeolocationPosition | null>((resolve) => {
229
+ try { navigator.geolocation.getCurrentPosition(resolve, () => resolve(null), { maximumAge: 600_000, timeout: 5000 }) } catch { resolve(null) }
230
+ })
231
+ if (pos) add('place', 'Where you were', `${pos.coords.latitude.toFixed(3)}, ${pos.coords.longitude.toFixed(3)}`, 'this app asked for it, because some problems only happen in one place')
232
+ }
233
+ if (o.extra) {
234
+ try {
235
+ const got = await o.extra()
236
+ ;(Array.isArray(got) ? got : []).forEach((x) => {
237
+ // An app item that does not say why it is shared is not shared.
238
+ if (x && typeof x.label === 'string' && typeof x.value === 'string' && typeof x.why === 'string' && x.why.trim()) add(`app:${items.filter((i) => i.key.startsWith('app:')).length}`, x.label, x.value, x.why)
239
+ })
240
+ } catch { /* the app's own context failing never blocks a suggestion */ }
241
+ }
242
+ return { items: items.slice(0, MAX.items), ...contextTrail() }
243
+ }
@@ -3,7 +3,7 @@
3
3
  // it has an end status. A report's words are data: they are rendered as text, never as markup. A report
4
4
  // that is still only on this phone is shown from the phone's own copy, with the app's refusal if it gave one.
5
5
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
6
- import { END, STATUSES } from '../contract.mjs'
6
+ import { END, STATUSES, currentStatus } from '../contract.mjs'
7
7
  import type { Adapters, FieldProps } from './adapters.ts'
8
8
  import { DefaultField } from './default-field.tsx'
9
9
  import { draftKey } from './drafts.ts'
@@ -13,7 +13,7 @@ import { useRuntime, useTick, type FollowUp } from './runtime.ts'
13
13
  import { markSeen } from './seen.ts'
14
14
  import { Toasts } from './toast.tsx'
15
15
  import { dropCache, isRefusal, oneCacheKey, readCache, useClearOnSignOut, writeCache } from './view-cache.ts'
16
- import { effectiveCeilings, lengthWords, sizeWords, statusMeaning, statusWords, stringsFor } from './words.ts'
16
+ import { effectiveCeilings, linkableIssue, lengthWords, sizeWords, statusMeaning, statusWords, stringsFor } from './words.ts'
17
17
 
18
18
  export function statusOf(r: Record<string, unknown>): string {
19
19
  if (typeof r.status === 'string') return r.status
@@ -22,7 +22,7 @@ export function statusOf(r: Record<string, unknown>): string {
22
22
  return 'received'
23
23
  }
24
24
 
25
- /** The step shown under "Being made". The timeline entry is the truth (that is where the receiving side puts it); a top-level `step` is read only when there is no timeline. */
25
+ /** The step shown under the current status. The timeline entry is the truth (that is where the receiving side puts it); a top-level `step` is read only when there is no timeline. */
26
26
  export function stepOf(r: Record<string, unknown>): string | undefined {
27
27
  const t = r.timeline
28
28
  if (Array.isArray(t) && t.length) { const s = (t[t.length - 1] as { step?: unknown })?.step; return typeof s === 'string' && s ? s : undefined }
@@ -33,7 +33,10 @@ const timelineOf = (r?: Record<string, unknown>): string[] | undefined =>
33
33
 
34
34
  /** The strip is made from the contract's own statuses, one step per distinct word ("planned" reads as "With Pluma"),
35
35
  * and a step is marked from the report's timeline: reached, skipped (the report went past it), or still ahead. */
36
- export function StatusStrip({ status, owner, step, timeline, label }: { status: string; owner: string; step?: string; timeline?: string[]; /** The list's accessible name; the screens pass the app's own (words.text.stepLabel). */ label?: string }) {
36
+ export function StatusStrip({ status: given, owner, step, timeline: givenTimeline, label }: { status: string; owner: string; step?: string; timeline?: string[]; /** The list's accessible name; the screens pass the app's own (words.text.stepLabel). */ label?: string }) {
37
+ // An old (0.1.x) status reads as the new one it means, on the strip and in the timeline it is marked from.
38
+ const status = currentStatus(given)
39
+ const timeline = givenTimeline?.map((s) => currentStatus(s))
37
40
  const nodes = useMemo(() => {
38
41
  const out: { words: string; ids: string[] }[] = []
39
42
  for (const id of STATUSES.filter((s: string) => !END.includes(s))) {
@@ -46,7 +49,7 @@ export function StatusStrip({ status, owner, step, timeline, label }: { status:
46
49
  const ended = END.includes(status)
47
50
  const here = nodes.findIndex((n) => n.ids.includes(status))
48
51
  const at = ended ? nodes.length : Math.max(0, here)
49
- const items = [...nodes, { words: statusWords(ended ? status : 'live', owner), ids: [ended ? status : 'end'] }]
52
+ const items = [...nodes, { words: statusWords(ended ? status : 'done', owner), ids: [ended ? status : 'end'] }]
50
53
  const reached = new Set(timeline ?? [])
51
54
  return (
52
55
  <ol className="sac-strip" aria-label={label ?? stringsFor({ app: 'x', owner }).stepLabel} data-status={status}>
@@ -60,7 +63,7 @@ export function StatusStrip({ status, owner, step, timeline, label }: { status:
60
63
  return (
61
64
  <li key={it.words + i} className="sac-step" data-state={state} aria-current={i === at ? 'step' : undefined}>
62
65
  <span>{it.words}</span>
63
- {i === at && it.ids.includes('being-made') && step ? <span className="sac-step-detail">{step}</span> : null}
66
+ {i === at && step ? <span className="sac-step-detail">{step}</span> : null}
64
67
  </li>
65
68
  )
66
69
  })}
@@ -188,9 +191,10 @@ export function SuggestionDetail({ adapters, id }: { adapters: Adapters; id: str
188
191
  if (who === null) return <p role="alert">{S.signedOutMine}</p>
189
192
  if (report === undefined) return <p>{S.loading}</p>
190
193
  if (report === null) return <p role="alert">{S.couldNotOpen}</p>
191
- const status = statusOf(report)
194
+ const status = currentStatus(statusOf(report))
192
195
  const owner = adapters.words.owner
193
196
  const open = !END.includes(status) && status !== 'on-phone'
197
+ const issue = linkableIssue(report.issue)
194
198
  const answers = (report.answers ?? {}) as Record<string, string>
195
199
  const voice = Array.isArray(report.voice) ? (report.voice as { file: string; ms: number }[]) : []
196
200
  const media = Array.isArray(report.media) ? (report.media as { file: string; type: string; bytes: number }[]) : []
@@ -229,7 +233,8 @@ export function SuggestionDetail({ adapters, id }: { adapters: Adapters; id: str
229
233
  return (
230
234
  <article className="sac-detail" data-report={id}>
231
235
  <h3 className="sac-status" data-status={status}>{statusWords(status, owner)}</h3>
232
- <p className="sac-muted">{statusMeaning(status, owner)}{status === 'live' && typeof report.liveAt === 'string' ? ` (${new Date(report.liveAt).toLocaleDateString()})` : ''}</p>
236
+ <p className="sac-muted">{statusMeaning(status, owner, report.issue)}</p>
237
+ {issue ? <p><a className="sac-issue-link" href={issue.url} target="_blank" rel="noopener noreferrer">{S.issueLink(issue.number)}</a></p> : null}
233
238
  {refusal ? (
234
239
  <>
235
240
  <p role="alert" className="sac-error">{refusal}</p>
@@ -3,9 +3,10 @@
3
3
  // Signed out, it opens nothing and says why. Signed in, it hands over to the host (`onOpen`), which
4
4
  // shows the sheet. `<ReportProblem>` is the same control for an error screen, already filled in.
5
5
  import { useEffect, useState, type AriaRole, type ReactNode } from 'react'
6
+ import { currentStatus } from '../contract.mjs'
6
7
  import type { Adapters } from './adapters.ts'
7
8
  import { useRuntime, type Runtime } from './runtime.ts'
8
- import { firstToastThisOpen, moved, onSeen } from './seen.ts'
9
+ import { firstToastThisOpen, fresh, moved, onSeen } from './seen.ts'
9
10
  import { SuggestSheet } from './sheet.tsx'
10
11
  import { toast, Toasts } from './toast.tsx'
11
12
  import { useClearOnSignOut } from './view-cache.ts'
@@ -19,7 +20,7 @@ export function KeepSending({ adapters }: { adapters: Adapters }) {
19
20
  }
20
21
 
21
22
  // THE NEWS: once per mount, asks the app for this person's reports and compares each with what they last opened. A move
22
- // since then is counted (the dot), and the newest one gets the page load's one toast, whichever of the entry or a
23
+ // since then is counted (the dot), and the newest one the last page load had not already seen gets the page load's one toast, whichever of the entry or a
23
24
  // root-mounted SuggestNews asks first (the guard is per page load and account, in seen.ts). Opening a report anywhere
24
25
  // recounts. `toasted` says this mount showed the toast, so a standalone entry knows to draw a toast host for it.
25
26
  function useNews(adapters: Adapters, rt: Runtime | null): { moved: number; toasted: boolean } {
@@ -44,10 +45,13 @@ function useNews(adapters: Adapters, rt: Runtime | null): { moved: number; toast
44
45
  try { now = await adapters.identity() } catch { now = null }
45
46
  if (!live || !rows || !now || now.account !== rt.who.account) return
46
47
  const m = await count()
47
- if (!live || !m?.length || !firstToastThisOpen(id)) return
48
+ if (!live || !m) return
49
+ // Only a move the last page load had not already seen is toasted; an older one keeps just the dot.
50
+ const f = await fresh(rt.store, id, m)
51
+ if (!f.length || !firstToastThisOpen(id)) return
48
52
  const at = (x: string) => rows!.find((r) => r.id === x)?.at ?? 0
49
- const top = [...m].sort((a, b) => at(b.id) - at(a.id))[0]
50
- const note = top.status === 'not-now' ? rows.find((r) => r.id === top.id)?.note : undefined
53
+ const top = [...f].sort((a, b) => at(b.id) - at(a.id))[0]
54
+ const note = currentStatus(top.status) === 'not-planned' ? rows.find((r) => r.id === top.id)?.note : undefined
51
55
  setToasted(true)
52
56
  toast({ id: `sac-moved-${top.id}`, text: S.movedToast(statusWords(top.status, adapters.words.owner), typeof note === 'string' && note ? note : undefined), ms: 6000 })
53
57
  })()
@@ -15,3 +15,5 @@ export * from './mine.tsx'
15
15
  export { seenStatuses, markSeen, moved, onSeen, seenKey, type Seen } from './seen.ts'
16
16
  export * from './detail.tsx'
17
17
  export { refreshIdentity } from './runtime.ts'
18
+ export { collectContext, installContextListeners, contextTrail, resetContext, parseUserAgent, withoutQueries } from './context.ts'
19
+ export type { ContextItem, AppContextItem, ReportContext, Trail, CollectOptions } from './context.ts'
@@ -11,7 +11,8 @@ import { useRuntime, useTick } from './runtime.ts'
11
11
  import { moved, onSeen } from './seen.ts'
12
12
  import { dropCache, isRefusal, mineCacheKey, pruneOnes, readCache, useClearOnSignOut, writeCache } from './view-cache.ts'
13
13
  import { Toasts } from './toast.tsx'
14
- import { statusMeaning, statusWords, stringsFor } from './words.ts'
14
+ import { currentStatus } from '../contract.mjs'
15
+ import { linkableIssue, statusMeaning, statusWords, stringsFor } from './words.ts'
15
16
 
16
17
  type Row = Summary & { error?: string }
17
18
 
@@ -119,14 +120,17 @@ export function MySuggestions({ adapters, onOpen }: { adapters: Adapters; onOpen
119
120
  <ul className="sac-list">
120
121
  {rows.map((r) => {
121
122
  const preview = typeof r.words === 'string' ? r.words : typeof r.title === 'string' ? r.title : ''
123
+ const issue = linkableIssue(r.issue)
122
124
  return (
123
- <li key={r.id} className="sac-row" data-status={r.status}>
125
+ <li key={r.id} className="sac-row" data-status={currentStatus(r.status)}>
124
126
  <button type="button" className="sac-row-button" onClick={() => (onOpen ? onOpen(r.id) : setOpen(r.id))}>
125
127
  {fresh.has(r.id) ? <span className="sac-new" data-new="">{S.newMark}</span> : null}
126
128
  {preview ? <span className="sac-row-words">{preview}</span> : null}
127
129
  <span className="sac-row-status">{statusWords(r.status, owner)}</span>
128
- <span className="sac-muted">{statusMeaning(r.status, owner)}</span>
130
+ <span className="sac-muted">{statusMeaning(r.status, owner, r.issue)}</span>
129
131
  </button>
132
+ {/* The row links to the issue; someone who cannot read the repo still sees the number and the state. */}
133
+ {issue ? <a className="sac-issue-link" href={issue.url} target="_blank" rel="noopener noreferrer">{S.issueLink(issue.number)}</a> : null}
130
134
  {r.error ? (
131
135
  <>
132
136
  <p role="alert" className="sac-error">{r.error}</p>
@@ -7,6 +7,7 @@
7
7
  // when none of its writes is left there, because a write leaves only after the adapter said `done`.
8
8
  import { useEffect, useRef, useState } from 'react'
9
9
  import type { Adapters, Identity } from './adapters.ts'
10
+ import { installContextListeners } from './context.ts'
10
11
  import { draftKey } from './drafts.ts'
11
12
  import { openStore, type Store } from './idb.ts'
12
13
  import { makeOutbox, type Op, type Sent } from './outbox.ts'
@@ -179,6 +180,8 @@ export function useRuntime(adapters: Adapters): { rt: Runtime | null; who: Ident
179
180
  const [signal, setSignal] = useState(0)
180
181
  const current = useRef<{ account: string | null; release: () => void }>({ account: null, release: () => {} })
181
182
  const alive = useRef(true)
183
+ // Every screen of the module mounts this, so the trail (pages, taps, errors) starts with the first one; it installs once.
184
+ useEffect(() => { installContextListeners() }, [])
182
185
  useEffect(() => { alive.current = true; return () => { alive.current = false; current.current.release(); current.current = { account: null, release: () => {} } } }, [])
183
186
  useEffect(() => { const l = () => setSignal((n) => n + 1); identityListeners.add(l); return () => { identityListeners.delete(l) } }, [])
184
187
  useEffect(() => { if (state.rt) state.rt.adapters = adapters })
@@ -4,7 +4,7 @@
4
4
  // every end) is "moved": the entry shows a dot, My suggestions marks the row New, and one toast says it. Opening
5
5
  // the report marks it seen. The record sits beside the cached views, under the same app-and-account key, so another
6
6
  // account never reads it and signing out clears it with them (clearViewCache).
7
- import { TOLD } from '../contract.mjs'
7
+ import { TOLD, currentStatus } from '../contract.mjs'
8
8
  import { draftKey } from './drafts.ts'
9
9
  import type { Store } from './idb.ts'
10
10
  import { readCache, writeCache } from './view-cache.ts'
@@ -34,8 +34,9 @@ export async function seenStatuses(store: Store, id: Id): Promise<Seen> {
34
34
  export function markSeen(store: Store, id: Id, reportId: string, status: string): Promise<void> {
35
35
  return serial(seenKey(id), async () => {
36
36
  const seen = await seenStatuses(store, id)
37
- if (seen[reportId] === status) return
38
- seen[reportId] = status
37
+ const now = currentStatus(status) // kept in the new words, so an old status and the one it means are the same news
38
+ if (seen[reportId] === now) return
39
+ seen[reportId] = now
39
40
  await writeCache(store, seenKey(id), seen)
40
41
  for (const l of listeners) l()
41
42
  })
@@ -51,9 +52,10 @@ export function moved(store: Store, id: Id, rows: { id: string; status: string }
51
52
  let changed = false
52
53
  for (const r of rows) {
53
54
  if (typeof r?.id !== 'string' || typeof r.status !== 'string' || r.status === 'on-phone') continue
54
- const had = seen[r.id]
55
- if (had === r.status) continue
56
- if (had === undefined || !TOLD.includes(r.status)) { seen[r.id] = r.status; changed = true; continue }
55
+ const had = seen[r.id] === undefined ? undefined : currentStatus(seen[r.id])
56
+ const now = currentStatus(r.status)
57
+ if (had === now) continue
58
+ if (had === undefined || !TOLD.includes(now)) { seen[r.id] = now; changed = true; continue }
57
59
  out.push({ id: r.id, status: r.status })
58
60
  }
59
61
  if (changed) await writeCache(store, seenKey(id), seen)
@@ -70,3 +72,22 @@ export function firstToastThisOpen(id: Id): boolean {
70
72
  toasted.add(k)
71
73
  return true
72
74
  }
75
+
76
+ /** WHAT THE LAST PAGE LOAD ALREADY SAW. The moves (report and status) that were already news the last time the app was
77
+ * loaded, so a move the person was told about, or could have been, is not toasted again on the next load; it keeps
78
+ * only the dot and the New mark until they open it. Returns the moves in `m` that are new since then, and records `m`
79
+ * as what this load saw. With no record yet (the first load on this release) every move is fresh, as before. */
80
+ export const noticedKey = (id: Id) => `${draftKey(id)}#view:noticed`
81
+ export function fresh(store: Store, id: Id, m: { id: string; status: string }[]): Promise<{ id: string; status: string }[]> {
82
+ return serial(noticedKey(id), async () => {
83
+ const had = await readCache<Seen>(store, noticedKey(id))
84
+ const before = had && typeof had === 'object' ? had : {}
85
+ const out = m.filter((r) => before[r.id] !== r.status)
86
+ const now: Seen = { ...before }
87
+ for (const r of m) now[r.id] = r.status
88
+ // A report no longer moved (opened, or gone) drops out, so the record stays the size of the dot.
89
+ for (const k of Object.keys(now)) if (!m.some((r) => r.id === k)) delete now[k]
90
+ await writeCache(store, noticedKey(id), now)
91
+ return out
92
+ })
93
+ }
@@ -6,7 +6,11 @@
6
6
  // through the outbox, so a closed app loses nothing. A voice note alone is a whole report.
7
7
  import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
8
8
  import { isAcceptedMedia, sanitizeRoute, typeForFile, validateReport } from '../contract.mjs'
9
- import type { Adapters, FieldProps, Kind } from './adapters.ts'
9
+ import type { Adapters, FieldProps, Kind, SheetDestination } from './adapters.ts'
10
+ import { destinationWords, whatGoes, type GoesItem } from '../github/compose.ts'
11
+ import { validContact } from '../contract.mjs'
12
+ import type { Contact } from './adapters.ts'
13
+ import { collectContext, type ReportContext } from './context.ts'
10
14
  import { useDialog } from './dialog.ts'
11
15
  import { DefaultField } from './default-field.tsx'
12
16
  import { draftKey, loadDraft, saveDraft } from './drafts.ts'
@@ -83,6 +87,32 @@ export function SuggestSheet({ adapters, where, onClose, initial }: { adapters:
83
87
  const [announce, setAnnounce] = useState('')
84
88
  const [sent, setSent] = useState<{ id: string; files: { file: string; name: string; bytes: number }[] } | null>(null)
85
89
  const [loaded, setLoaded] = useState(false)
90
+ const [dest, setDest] = useState<SheetDestination | null>(null)
91
+ // Everything shared so it can be fixed, each with why; all on by default, any one can be left out (`choice`).
92
+ const [ctx, setCtx] = useState<ReportContext | null>(null)
93
+ const [contact, setContact] = useState<Contact | null>(null)
94
+ const [choice, setChoice] = useState<Record<string, boolean>>({})
95
+ // Where it files, asked once when the sheet opens; until it answers (or with none) the one-line note stays.
96
+ useEffect(() => {
97
+ if (!adapters.destination) return
98
+ let live = true
99
+ adapters.destination().then((d) => { if (live) setDest(d && typeof d.repo === 'string' ? d : null) }, () => { if (live) setDest(null) })
100
+ return () => { live = false }
101
+ }, [adapters])
102
+ const contextOptions = useCallback(() => {
103
+ const c = adapters.context
104
+ return typeof c === 'function' ? { extra: c, precise: false } : { extra: c?.items ? () => c.items!() : undefined, precise: Boolean(c?.precise) }
105
+ }, [adapters])
106
+ const gather = useCallback(() => collectContext({ identity: who ?? null, where, about: adapters.about, ...contextOptions(), ...(adapters.phone?.now ? { now: adapters.phone.now } : {}) }), [who, where, adapters, contextOptions])
107
+ useEffect(() => {
108
+ if (!who) return
109
+ let live = true
110
+ void gather().then((c) => { if (live) setCtx(c) })
111
+ if (adapters.contact) adapters.contact().then((c) => { if (live) setContact(c && !validContact(c).length ? c : null) }, () => {})
112
+ return () => { live = false }
113
+ // where is an object the host may rebuild each render; the context is gathered once per open and again at Send.
114
+ // eslint-disable-next-line react-hooks/exhaustive-deps
115
+ }, [adapters, who])
86
116
  const chain = useRef<Promise<unknown>>(Promise.resolve())
87
117
  // What is on the sheet, kept in step with state so a second take or picture added a moment later counts the first.
88
118
  const voiceRef = useRef<Voice[]>([])
@@ -265,6 +295,30 @@ export function SuggestSheet({ adapters, where, onClose, initial }: { adapters:
265
295
  ] as { name: Diag; label: string; value: string | undefined }[]).filter((d): d is { name: Diag; label: string; value: string } => Boolean(d.value))
266
296
  const lineParts = [`this screen (${where.screen}, ${sanitizeRoute(where.route)})`, ...avail.map((d) => d.label)]
267
297
  const summary = `${S.goesTo(adapters.words.owner)} ${S.includes(lineParts)}`
298
+ // With a destination, the one line becomes the list of exactly what the issue will carry, built by the same function
299
+ // the issue is written from (`whatGoes`), so the list and the issue cannot drift. It follows the draft as it changes.
300
+ // Every row of the list, whether or not there is a destination; optional rows carry a tick box.
301
+ const rows = whatGoes({
302
+ kind, words, voice: voice.map((v) => ({ file: v.file, ms: v.ms })), media: media.map((m) => ({ file: m.file, type: m.type, bytes: m.bytes })),
303
+ where: { screen: where.screen, route: sanitizeRoute(where.route) },
304
+ diagnostics: Object.fromEntries(avail.map((d) => [d.name, d.value])),
305
+ appName: adapters.about?.app ?? adapters.words.app,
306
+ ...(dest?.name ? { filerName: dest.name } : {}),
307
+ ...(ctx ? { context: ctx } : {}),
308
+ ...(contact ? { who: { contact } } : {}),
309
+ }, dest ?? { repo: '', public: false, account: '', attaches: true })
310
+ const isOn = (key: string, fallback = true) => choice[key] ?? rows.find((r) => r.key === key)?.on ?? fallback
311
+ const goes = dest ? rows : null
312
+ const destWords = dest ? destinationWords(dest) : null
313
+ const optionalRow = (g: GoesItem) => (
314
+ <li key={g.key} data-goes={g.key} className="sac-goes-opt">
315
+ <label>
316
+ <input type="checkbox" checked={isOn(g.key)} onChange={(e) => setChoice((c) => ({ ...c, [g.key]: e.target.checked }))} />{' '}
317
+ <span className="sac-goes-label">{g.text}</span>{g.value ? <>: <span className="sac-goes-value">{g.value}</span></> : null}{' '}
318
+ <span className="sac-muted sac-goes-why">({g.why}{g.reason ? `; off by default because ${g.reason}` : ''})</span>
319
+ </label>
320
+ </li>
321
+ )
268
322
 
269
323
  const hasAnything = Boolean(words.trim() || voice.length || media.length)
270
324
 
@@ -273,13 +327,20 @@ export function SuggestSheet({ adapters, where, onClose, initial }: { adapters:
273
327
  setError('')
274
328
  await chain.current
275
329
  const d = await saveDraft(rt.store, key, { kind, words, attachments: [...voice.map((v) => v.key), ...media.map((m) => m.key)] })
330
+ // What was left out never leaves the phone: the context gathered again now (the trail has grown), minus every row
331
+ // the person unticked, and the diagnostics those rows stand for.
332
+ const fresh = await gather()
333
+ const keep = { ...fresh, items: fresh.items.filter((i) => isOn(`ctx:${i.key}`)), routes: isOn('routes') ? fresh.routes : [], taps: isOn('taps') ? fresh.taps : [], errors: isOn('errors') ? fresh.errors : [] }
334
+ const leftOut: Record<string, Diag> = { 'ctx:device': 'device', 'ctx:screen': 'screen', 'ctx:time': 'at' }
276
335
  const diagnostics: Record<string, string> = {}
277
- for (const x of avail) diagnostics[x.name] = x.value
336
+ for (const x of avail) if (!Object.entries(leftOut).some(([k, d]) => d === x.name && !isOn(k))) diagnostics[x.name] = x.value
337
+ const reach = contact && isOn('contact') ? contact : null
278
338
  const report: Record<string, unknown> & { id: string } = {
279
339
  id: d.reportId,
280
340
  kind,
281
341
  ...(adapters.about ? { app: Object.fromEntries(Object.entries(adapters.about).filter(([k]) => k !== 'version')) } : {}),
282
- who: { member: who.account, role: who.role },
342
+ who: { member: who.account, role: who.role, ...(reach ? { contact: reach } : {}) },
343
+ context: keep,
283
344
  words: words.trim(),
284
345
  voice: voice.map((v) => ({ file: v.file, ms: v.ms, field: v.field })),
285
346
  media: await Promise.all(media.map(async (m) => {
@@ -398,7 +459,20 @@ export function SuggestSheet({ adapters, where, onClose, initial }: { adapters:
398
459
  ) : null}
399
460
  </fieldset>
400
461
 
401
- <p className="sac-muted sac-fyi" data-sac-summary="">{summary}</p>
462
+ {goes && destWords ? (
463
+ <div className="sac-goes sac-fyi" data-sac-goes="">
464
+ <p className="sac-goes-head"><strong>{destWords.headline}</strong>{' ('}<span className={destWords.danger ? 'sac-danger' : 'sac-muted'}>{destWords.privacy}</span>)</p>
465
+ <p className="sac-muted">It will carry:</p>
466
+ <ul className="sac-goes-list">{goes.filter((g) => !g.optional).map((g) => <li key={g.key} data-goes={g.key}>{g.text}</li>)}</ul>
467
+ {goes.some((g) => g.optional) ? <ul className="sac-goes-opts" aria-label={S.contextLegend}>{goes.filter((g) => g.optional).map(optionalRow)}</ul> : null}
468
+ <p className="sac-muted">{destWords.account}</p>
469
+ </div>
470
+ ) : (
471
+ <div className="sac-fyi">
472
+ <p className="sac-muted sac-fyi" data-sac-summary="">{summary}</p>
473
+ {rows.some((g) => g.optional) ? <ul className="sac-goes-opts" aria-label={S.contextLegend}>{rows.filter((g) => g.optional).map(optionalRow)}</ul> : null}
474
+ </div>
475
+ )}
402
476
 
403
477
  {error ? <p role="alert" className="sac-error">{error}</p> : null}
404
478
  <div className="sac-actions">
@@ -56,6 +56,11 @@
56
56
  .sac-muted { color: var(--sac-muted); font-size: 0.9rem; }
57
57
  .sac-fyi { font-size: 0.8rem; margin: 0; }
58
58
  .sac-error { color: var(--sac-danger); margin: 0; }
59
+ .sac-danger { color: var(--sac-danger); font-weight: 600; }
60
+ .sac-goes p { margin: 0; }
61
+ .sac-goes-list { margin: 2px 0; padding: 0; list-style: none; display: flex; flex-wrap: wrap; gap: 2px 0; }
62
+ .sac-goes-list li:not(:last-child)::after { content: " \00b7 "; white-space: pre; color: var(--sac-muted); }
63
+ .sac-issue-link { color: var(--sac-accent); font-size: 0.9rem; }
59
64
  .sac-receipt-line { font-weight: 600; margin: 0; }
60
65
  .sac-strip { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; }
61
66
  .sac-step { border: 1px solid var(--sac-border); border-radius: 999px; padding: 4px 10px; color: var(--sac-muted); }
@@ -63,8 +68,12 @@
63
68
  .sac-step[data-state="now"] { border-color: var(--sac-accent); color: var(--sac-fg); font-weight: 600; }
64
69
  .sac-step-detail { display: block; font-weight: 400; font-size: 0.85rem; }
65
70
  .sac-words { white-space: pre-wrap; overflow-wrap: anywhere; }
66
- .sac-toasts { position: fixed; left: 0; right: 0; top: calc(env(safe-area-inset-top) + 8px); z-index: calc(var(--sac-z) + 1); display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 0 16px; pointer-events: none; }
67
- .sac-toast { pointer-events: auto; font: inherit; width: 100%; max-width: 28rem; text-align: left; padding: 12px 16px; border-radius: var(--sac-radius); border: 1px solid var(--sac-border); background: var(--sac-bg); color: var(--sac-fg); }
71
+ /* Toasts sit small at the bottom edge, never over the top of the host's page: the top is where an app opens onto
72
+ its own content, and a full-width bar there reads as the app's header. Each is a pill as wide as its words, with a
73
+ drawn x so it reads as dismissable (the whole pill is the button). */
74
+ .sac-toasts { position: fixed; left: 0; right: 0; bottom: calc(env(safe-area-inset-bottom) + 12px); z-index: calc(var(--sac-z) + 1); display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 0 16px; pointer-events: none; }
75
+ .sac-toast { pointer-events: auto; font: inherit; font-size: 0.875rem; line-height: 1.3; width: auto; max-width: min(100%, 24rem); text-align: left; padding: 6px 12px; border-radius: 999px; border: 1px solid var(--sac-border); background: var(--sac-bg); color: var(--sac-fg); display: inline-flex; align-items: center; gap: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18); }
76
+ .sac-toast::after { content: "\00d7"; color: var(--sac-muted); font-size: 1rem; line-height: 1; }
68
77
  .sac-toast[data-tone="warn"] { background: var(--sac-fg); color: var(--sac-bg); }
69
78
  .sac-entry-why { margin-left: 8px; color: var(--sac-muted); font-size: 0.9rem; }
70
79
  .sac-sheet :focus-visible, .sac-entry:focus-visible, .sac-button:focus-visible, .sac-record:focus-visible, .sac-row-button:focus-visible { outline: 3px solid var(--sac-focus); outline-offset: 2px; }