@supersuit/suggest-a-change 0.2.3 → 0.2.5
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/CHANGELOG.md +10 -0
- package/README.md +3 -1
- package/dist/client/default-field.js +41 -7
- package/dist/client/sheet.js +8 -1
- package/dist/client/words.d.ts +13 -0
- package/dist/client/words.js +11 -5
- package/package.json +1 -1
- package/src/client/default-field.tsx +41 -10
- package/src/client/sheet.tsx +28 -6
- package/src/client/styles.css +21 -2
- package/src/client/words.ts +24 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.5 (2026-10-26)
|
|
4
|
+
|
|
5
|
+
- **A tap on the microphone records.** `DefaultField` was hold-only: on a tap the release ended the take before the microphone had answered, so nothing was recorded and nothing said to hold (seen on iPhone Safari). Now a press shorter than 350 ms starts a take that keeps recording until the next tap (or the time limit), and a longer press records until it is let go. Leaving the button, or a pointer the browser cancels (iOS Safari does when its permission prompt appears), never ends a tapped take. `startTake` is still called synchronously inside the press, with nothing awaited before it. While recording the dot turns to a stop square and pulses (under `prefers-reduced-motion: no-preference`).
|
|
6
|
+
- **The microphone sits inside its text box.** Bottom right of a text area, centred at the right of a one-line input, a 44px target, with the box's padding keeping text clear of it (`.sac-voice-box`, `.sac-voice-multi`, `.sac-voice-single`). The `[data-voice-field]` / `[data-voice-control]` contract is unchanged.
|
|
7
|
+
- **Words.** `holdToTalk` now defaults to "Tap to talk", `stopTalking` to "Tap to stop", and the placeholders say "tap the microphone and say it". The keys keep their names.
|
|
8
|
+
|
|
9
|
+
## 0.2.4 (2026-10-26)
|
|
10
|
+
|
|
11
|
+
- **The list of what a suggestion carries is folded by default.** With a destination, the sheet above Send says one line, "Files an issue on <repo> (private)" or, in `--sac-danger` and never folded, "(public: anyone can read it)", then a toggle "What it carries (N)" counting what will go (the always rows plus each ticked one). Tapping it opens the full list with its tick boxes, reasons and account line; unticking inside still leaves an item out and lowers the count. Without a destination the tick boxes fold under the one line as "Also shared (N)". The toggle is a `<button aria-expanded aria-controls>` with a 44px target (`.sac-fold`); the app's `destinationNote` stays visible outside the fold. New replaceable words: `filesOn`, `privateShort`, `publicShort`, `carriesToggle`, `alsoSharedToggle`, `carriesLead`. `destinationWords` is unchanged. Earned by the operator's read of the open list on a phone: "it's a little bit disturbing with all the checkboxes shown."
|
|
12
|
+
|
|
3
13
|
## 0.2.3 (2026-10-26)
|
|
4
14
|
|
|
5
15
|
- **An app can turn any built-in context item off.** `context: { items?, precise?, off: ContextKey[] }`, with keys `taps`, `pages`, `errors`, `time`, `language`, `device`, `screen`, `appearance`, `connection`, `age`, `account` (`CONTEXT_KEYS`, `contextOff`). An item off is never collected (the trail keeps nothing of it from the first mount and forgets what it had), never listed by the sheet, and never in the report or the issue; `device`, `screen` and `time` off drop the matching diagnostic too. `installContextListeners({ off })` and `collectContext({ off })` take the same list. Earned by a family app whose taps were a child's name, a visit or a medicine: it stripped taps on its server while the sheet still promised "your last taps".
|
package/README.md
CHANGED
|
@@ -52,7 +52,7 @@ const adapters: Adapters = {
|
|
|
52
52
|
|
|
53
53
|
`SuggestEntry` also takes `className` and `role` (for example `role="menuitem"` when a host shows it as a menu row); no role is set by default. My suggestions and the detail view keep their last answer from the app on the phone (module-owned IndexedDB, keyed by app and account like a draft), draw it at once with a quiet "Updating" line, and replace it when the app answers; if the app cannot be reached they keep the saved rows and say so. An adapter tells a refusal from an unreachable app: `mine()` and `one()` throw an error with `refused: true` or a `status` of 401, 403 or 404 for a refusal (the saved copy is then deleted), and anything else thrown is unreachable. Saved reports the list no longer holds are pruned, and signing out clears them. A first open with nothing saved shows "Loading".
|
|
54
54
|
|
|
55
|
-
The entry also keeps the phone's outbox moving while it is mounted (a report sent with the Mac away goes when it is back, with the sheet closed); a host with no always-mounted entry renders `<KeepSending adapters={adapters} />` once, high in the app. The entry carries `data-suggest-entry` (the attribute the screen check looks for; see "The check" below). Signed out, it opens nothing and says why. The receipt says "Got it" only after the adapter has answered `done` for the report and every attachment; until then it says the report is on the phone. Every text field the module renders sits in a `[data-voice-field]` container with a `[data-voice-control]`; a host's `Field` slot must keep that shape (it receives `FieldProps`, which is the Helm's voice field props). A voice note alone is a whole report: the recording is kept and uploaded as a part, and the receiving Mac writes the transcript.
|
|
55
|
+
The entry also keeps the phone's outbox moving while it is mounted (a report sent with the Mac away goes when it is back, with the sheet closed); a host with no always-mounted entry renders `<KeepSending adapters={adapters} />` once, high in the app. The entry carries `data-suggest-entry` (the attribute the screen check looks for; see "The check" below). Signed out, it opens nothing and says why. The receipt says "Got it" only after the adapter has answered `done` for the report and every attachment; until then it says the report is on the phone. Every text field the module renders sits in a `[data-voice-field]` container with a `[data-voice-control]`; a host's `Field` slot must keep that shape (it receives `FieldProps`, which is the Helm's voice field props). A voice note alone is a whole report: the recording is kept and uploaded as a part, and the receiving Mac writes the transcript. `DefaultField` puts its microphone inside the text box (bottom right of a text area, centred at the right of a one-line input, a 44px target, with the text kept clear of it). A tap starts a take that records until the next tap or the time limit; a press held past a tap records until it is let go; leaving the button or a pointer the browser cancels (iOS Safari's microphone prompt) never ends a tapped take. The recorder is asked inside the press with nothing awaited first, so iOS Safari counts the microphone request as part of the gesture. A host's own `Field` should follow the same two rules.
|
|
56
56
|
|
|
57
57
|
Styling: structure only, class names prefixed `sac-`. `@supersuit/suggest-a-change/styles.css` is optional defaults (16px inputs so iOS does not zoom, 44px touch targets, safe-area padding on the sheet). Re-skin it with these custom properties:
|
|
58
58
|
|
|
@@ -101,6 +101,8 @@ The issue carries it all in a "Context (shared so this can be fixed)" table of w
|
|
|
101
101
|
|
|
102
102
|
**Where it files.** With the optional `destination()` adapter, answering `{ repo, public, account, attaches, name? }`, that line becomes the list of exactly what the issue will carry ("your words · your voice note and its transcript · 2 screenshots, saved to the suggestion-attachments branch of acme/notes · this screen (Photos, /photos) · notes 1.2.3 · iPhone, 390x844 · the time · your name, Alex Kim"), the repo it files on, who can read it there (a public repo says so in `--sac-danger`), and whose GitHub account files it. The list follows the draft as things are added or removed, and it is built by `whatGoes` from `./github`, the same pieces `composeIssue` writes the body from, so the sheet and the issue cannot drift. `attaches: false` says files wait until the repo can take them. With `via: "relay"` (and `relayApp`, the relay's app name as people see it) the account line says the issue is opened by that app and whose it is ("Opened by the Supersuit Suggestions app on GitHub, which says it came from Alex Kim."), since the filer's own account is not what opens it. Sending is the consent. Without the adapter (or when it answers null) the sheet keeps the one line.
|
|
103
103
|
|
|
104
|
+
**The list is folded by default.** Above Send the sheet shows one line saying where it files ("Files an issue on acme/notes (private)"; a public repo reads "(public: anyone can read it)" in `--sac-danger`, always shown, never folded) and a toggle with the count of what goes ("What it carries (14)"). Tapping it opens the itemized list above with its tick boxes and reasons, and the account line; the count follows what is ticked. Without a destination the tick boxes fold the same way under the one line ("Also shared (9)"). The toggle is a `<button aria-expanded>` (`.sac-fold`, a 44px target), and the app's `destinationNote` stays outside the fold. Every word of it is replaceable through `words.text` (`filesOn`, `privateShort`, `publicShort`, `carriesToggle`, `alsoSharedToggle`, `carriesLead`).
|
|
105
|
+
|
|
104
106
|
**The app's own note.** `words.text.destinationNote` is a sentence or two only the app can say about where a report goes and what it carries ("The people who fix this are outside your family. A photo or video of your child goes too, and a video keeps where it was filmed."). The sheet shows it under the list (or under the one line, without a destination), before Send, in the module's styling (`.sac-note`), and never instead of the list. It is a word rather than a field of `destination()` because every word a person reads lives in `words.text`, where an app already keeps its consent sentence (`goesTo`, `includes`) and translates it, and because it has to show whether or not `destination()` answers: an app that turns `destination()` on keeps its note. My suggestions and the detail view link the issue a report became ("Issue #7"); a member who cannot read the repo still sees the number and the state.
|
|
105
107
|
|
|
106
108
|
**Identity.** Every screen asks `adapters.identity()` when it mounts, whenever it is handed a different `adapters` object, and whenever the host calls `refreshIdentity()` (exported from `"./client"`; call it after a sign-in, sign-out or account switch). When the account differs, the old account's rows leave the screen and its waiting reports stay on the phone, to be sent only while that account is signed in; every send re-confirms the account first. Keep `adapters` stable where you can; a parent that rebuilds it every render is safe, only slower (one `identity()` call per render).
|
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
// THE MODULE'S OWN VOICE FIELD, for hosted apps that have no golden one. A text area with
|
|
4
|
-
//
|
|
3
|
+
// THE MODULE'S OWN VOICE FIELD, for hosted apps that have no golden one. A text area with its microphone
|
|
4
|
+
// INSIDE the box (bottom right; centred at the right of a one-line input), in one [data-voice-field]
|
|
5
|
+
// container with nothing else typeable in it. A take is
|
|
5
6
|
// recorded with the module's recorder (written to the phone's database as it is made). Transcription is
|
|
6
7
|
// not done here: the take goes to `upload`, and the receiving Mac writes the transcript. A voice note
|
|
7
8
|
// alone is a whole report, so a field with no words and one take is a complete answer.
|
|
8
9
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
10
|
+
// A tap starts a take that keeps recording until the next tap (or the time limit); a press held longer
|
|
11
|
+
// than a tap records until it is let go, as hold-to-talk always did. Leaving the button, or a pointer the
|
|
12
|
+
// browser cancels (iOS Safari does when its microphone prompt appears), never ends a tapped take. The
|
|
13
|
+
// recorder is asked synchronously inside the press, with nothing awaited first, so iOS Safari treats the
|
|
14
|
+
// microphone request as part of the gesture. Space or Enter on the focused button starts and stops a take.
|
|
15
|
+
// (The operator, 2026-10-26: a tap on the hold-only button did nothing, because the release ended the take
|
|
16
|
+
// before the microphone had answered.)
|
|
11
17
|
import { useEffect, useRef, useState } from 'react';
|
|
12
18
|
import { startTake as defaultStartTake } from './recorder.js';
|
|
13
19
|
import { toast } from './toast.js';
|
|
14
20
|
import { DEFAULT_STRINGS } from './words.js';
|
|
21
|
+
/** A press shorter than this is a tap: it starts a take that a second tap stops. */
|
|
22
|
+
const TAP_MS = 350;
|
|
15
23
|
export function DefaultField(props) {
|
|
16
24
|
const { rt, id, label, value, onChange, placeholder, multiline = false, type = 'text', upload, onHeard, onKept, onHearing, disabled, startTake = defaultStartTake, maxMs, labels, scope } = props;
|
|
17
25
|
const L = labels ?? DEFAULT_STRINGS;
|
|
@@ -21,6 +29,8 @@ export function DefaultField(props) {
|
|
|
21
29
|
const take = useRef(null);
|
|
22
30
|
const wanted = useRef(false);
|
|
23
31
|
const timer = useRef(null);
|
|
32
|
+
// The press in progress that started a take: when it began. Null between presses, and for a press that stopped one.
|
|
33
|
+
const press = useRef(null);
|
|
24
34
|
const latest = useRef(value);
|
|
25
35
|
latest.current = value;
|
|
26
36
|
const cb = useRef({ onChange, upload, onHeard, onKept, onHearing });
|
|
@@ -85,8 +95,32 @@ export function DefaultField(props) {
|
|
|
85
95
|
}
|
|
86
96
|
useEffect(() => () => { wanted.current = false; if (timer.current)
|
|
87
97
|
clearTimeout(timer.current); void take.current?.cancel(); }, []);
|
|
88
|
-
const control = (_jsx("button", { type: "button", "data-voice-control": "", className: "sac-record", "aria-pressed": recording, "aria-label": canRecord ? `${recording ? L.stopTalking : L.holdToTalk}: ${label}` : `${L.voiceUnavailable}: ${label}`, disabled: disabled || !canRecord, onPointerDown: (e) => {
|
|
89
|
-
|
|
98
|
+
const control = (_jsx("button", { type: "button", "data-voice-control": "", className: "sac-record", "aria-pressed": recording, "aria-label": canRecord ? `${recording ? L.stopTalking : L.holdToTalk}: ${label}` : `${L.voiceUnavailable}: ${label}`, disabled: disabled || !canRecord, onPointerDown: (e) => {
|
|
99
|
+
e.preventDefault();
|
|
100
|
+
// A take already going (a tapped one): this press stops it.
|
|
101
|
+
if (take.current || wanted.current) {
|
|
102
|
+
press.current = null;
|
|
103
|
+
void end();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
press.current = Date.now();
|
|
107
|
+
void begin(); // startTake is called before begin's first await: still inside the gesture
|
|
108
|
+
}, onPointerUp: () => {
|
|
109
|
+
const at = press.current;
|
|
110
|
+
press.current = null;
|
|
111
|
+
// A tap keeps recording until the next tap; a hold stops on release.
|
|
112
|
+
if (at !== null && Date.now() - at >= TAP_MS)
|
|
113
|
+
void end();
|
|
114
|
+
}, onPointerLeave: () => {
|
|
115
|
+
// Sliding off a held press stops it, as hold-to-talk did; a tapped take, or one still starting, goes on.
|
|
116
|
+
const at = press.current;
|
|
117
|
+
press.current = null;
|
|
118
|
+
if (at !== null && take.current && Date.now() - at >= TAP_MS)
|
|
119
|
+
void end();
|
|
120
|
+
}, onPointerCancel: () => {
|
|
121
|
+
// The browser took the pointer (a permission prompt, a scroll): the take carries on as a tapped one.
|
|
122
|
+
press.current = null;
|
|
123
|
+
}, onContextMenu: (e) => e.preventDefault(), onKeyDown: (e) => {
|
|
90
124
|
if (e.key !== ' ' && e.key !== 'Enter')
|
|
91
125
|
return;
|
|
92
126
|
e.preventDefault();
|
|
@@ -95,5 +129,5 @@ export function DefaultField(props) {
|
|
|
95
129
|
void (take.current || wanted.current ? end() : begin());
|
|
96
130
|
}, children: _jsx("span", { "aria-hidden": "true", className: "sac-record-dot" }) }));
|
|
97
131
|
const shared = { id: `sac-field-${id}`, value, placeholder, disabled, className: 'sac-input', onChange: (e) => onChange(e.target.value) };
|
|
98
|
-
return (_jsxs("div", { "data-voice-field": "", className: "sac-voice", "data-recording": recording ? '' : undefined, children: [
|
|
132
|
+
return (_jsxs("div", { "data-voice-field": "", className: "sac-voice", "data-recording": recording ? '' : undefined, children: [_jsx("label", { htmlFor: `sac-field-${id}`, className: "sac-sr", children: label }), _jsxs("div", { className: `sac-voice-box ${multiline ? 'sac-voice-multi' : 'sac-voice-single'}`, children: [multiline ? _jsx("textarea", { rows: 3, ...shared }) : _jsx("input", { type: type, ...shared }), control] })] }));
|
|
99
133
|
}
|
package/dist/client/sheet.js
CHANGED
|
@@ -85,6 +85,9 @@ export function SuggestSheet({ adapters, where, onClose, initial }) {
|
|
|
85
85
|
const [ctx, setCtx] = useState(null);
|
|
86
86
|
const [contact, setContact] = useState(null);
|
|
87
87
|
const [choice, setChoice] = useState({});
|
|
88
|
+
// The itemized list of what goes, with its tick boxes, is folded until the person opens it: a wall of checkboxes was
|
|
89
|
+
// "a little bit disturbing" (the operator, 2026-10-26). Where it files, and a public repo's warning, are never folded.
|
|
90
|
+
const [open, setOpen] = useState(false);
|
|
88
91
|
// Where it files, asked once when the sheet opens; until it answers (or with none) the one-line note stays.
|
|
89
92
|
useEffect(() => {
|
|
90
93
|
if (!adapters.destination)
|
|
@@ -325,6 +328,10 @@ export function SuggestSheet({ adapters, where, onClose, initial }) {
|
|
|
325
328
|
const goes = dest ? rows : null;
|
|
326
329
|
const destWords = dest ? destinationWords(dest) : null;
|
|
327
330
|
const optionalRow = (g) => (_jsx("li", { "data-goes": g.key, className: "sac-goes-opt", children: _jsxs("label", { children: [_jsx("input", { type: "checkbox", checked: isOn(g.key), onChange: (e) => setChoice((c) => ({ ...c, [g.key]: e.target.checked })) }), ' ', _jsx("span", { className: "sac-goes-label", children: g.text }), g.value ? _jsxs(_Fragment, { children: [": ", _jsx("span", { className: "sac-goes-value", children: g.value })] }) : null, ' ', _jsxs("span", { className: "sac-muted sac-goes-why", children: ["(", g.why, g.reason ? `; off by default because ${g.reason}` : '', ")"] })] }) }, g.key));
|
|
331
|
+
// What the fold's toggle counts: everything that will go, the always rows plus each ticked one.
|
|
332
|
+
const goingCount = (list) => list.filter((g) => !g.optional || isOn(g.key)).length;
|
|
333
|
+
const foldId = `${titleId}-fold`;
|
|
334
|
+
const foldToggle = (label) => (_jsxs("button", { type: "button", className: "sac-button sac-fold", "aria-expanded": open, "aria-controls": foldId, "data-sac-fold": "", onClick: () => setOpen((o) => !o), children: [_jsx("span", { className: "sac-fold-mark", "aria-hidden": "true" }), label] }));
|
|
328
335
|
const hasAnything = Boolean(words.trim() || voice.length || media.length);
|
|
329
336
|
async function send() {
|
|
330
337
|
if (!rt || !who || !key || !hasAnything)
|
|
@@ -399,5 +406,5 @@ export function SuggestSheet({ adapters, where, onClose, initial }) {
|
|
|
399
406
|
return (_jsx("div", { className: "sac-backdrop", "data-suggest-sheet": "", children: _jsxs("div", { ref: box, className: "sac-sheet", role: "dialog", "aria-modal": "true", "aria-labelledby": titleId, tabIndex: -1, children: [_jsxs("header", { className: "sac-header", children: [_jsx("h2", { id: titleId, className: "sac-title", children: S.sheetTitle }), onClose ? _jsx("button", { type: "button", className: "sac-button sac-close", onClick: onClose, children: S.close }) : null] }), _jsx("div", { className: "sac-sr", "data-sac-live": "", role: "status", "aria-live": "polite", "aria-atomic": "true", children: announce }), who === undefined ? _jsx("p", { children: S.loading }) : who === null ? _jsx("p", { role: "alert", children: S.signedOut }) : !rt ? _jsx("p", { children: S.loading }) : sent ? (_jsxs("div", { className: "sac-sent", children: [_jsx(Receipt, { adapters: adapters, reportId: sent.id, quiet: true, onLine: setAnnounce }), sent.files.length ? (_jsx("ul", { className: "sac-list", "aria-label": S.attachmentsLabel, children: sent.files.map((f) => {
|
|
400
407
|
const p = rt.part(sent.id, f.file);
|
|
401
408
|
return (_jsxs("li", { className: "sac-item", "data-attachment-state": p.state, children: [_jsxs("span", { className: "sac-item-name", children: [f.name, f.bytes ? `, ${sizeWords(f.bytes)}` : ''] }), _jsxs("span", { className: "sac-item-state", children: [p.state === 'sending' ? _jsx("progress", { "aria-label": `${S.sending} ${f.name}` }) : null, p.state === 'waiting' ? S.waitingToSend : p.state === 'sending' ? S.sending : p.state === 'sent' ? S.sentWord : S.didNotSend(p.reason ?? '')] }), p.state === 'failed' ? _jsx("button", { type: "button", className: "sac-button", onClick: () => void rt.retry(sent.id), children: S.retry }) : null] }, f.file));
|
|
402
|
-
}) })) : null, onClose ? _jsx("button", { type: "button", className: "sac-button sac-primary", onClick: onClose, children: S.done }) : null] })) : (_jsxs("form", { className: "sac-form", onSubmit: (e) => { e.preventDefault(); void send(); }, children: [_jsxs("div", { className: "sac-kind", children: [_jsx("label", { htmlFor: `${titleId}-kind`, children: S.kindLegend }), _jsx("select", { id: `${titleId}-kind`, className: "sac-select", value: kind, onChange: (e) => setKind(e.target.value), children: kinds.map((k) => _jsx("option", { value: k.id, children: k.label }, k.id)) })] }), _jsxs("fieldset", { className: "sac-group sac-sayit", children: [_jsx("legend", { children: S.sayItLegend }), fieldFor('words', S.sayItLabel, words, setWords, { placeholder: kind === 'broken' ? S.sayItPlaceholderBroken : kind === 'feedback' ? S.sayItPlaceholderFeedback : S.sayItPlaceholder }), voice.length ? (_jsx("ul", { className: "sac-list", "aria-label": S.voiceNotesLabel, children: voice.map((v, i) => (_jsxs("li", { className: "sac-item", children: [_jsx("span", { className: "sac-item-name", children: S.voiceNote(i + 1, lengthWords(v.ms)) }), _jsx("button", { type: "button", className: "sac-button", "aria-label": `${S.remove} ${S.voiceNote(i + 1, lengthWords(v.ms))}`, onClick: () => void removeVoice(v), children: S.remove })] }, v.key))) })) : null] }), _jsxs("fieldset", { className: "sac-group", children: [_jsx("legend", { children: S.showItLegend }), _jsxs("div", { className: "sac-actions", children: [_jsx("button", { type: "button", className: "sac-button", onClick: () => fileInput.current?.click(), children: S.addImages }), _jsx("button", { type: "button", className: "sac-button", onClick: () => videoInput.current?.click(), children: S.addVideo }), adapters.snapshot ? _jsx("button", { type: "button", className: "sac-button", onClick: () => void takeScreenshot(), children: S.takeScreenshot }) : null] }), _jsx("input", { ref: fileInput, type: "file", accept: "image/*", multiple: true, hidden: true, "aria-label": S.addImages, "data-sac-images": "", onChange: (e) => { const f = [...(e.target.files ?? [])]; e.target.value = ''; void addMedia(f, 'image'); } }), _jsx("input", { ref: videoInput, type: "file", accept: "video/*", hidden: true, "aria-label": S.addVideo, "data-sac-video": "", onChange: (e) => { const f = [...(e.target.files ?? [])]; e.target.value = ''; void addMedia(f, 'video'); } }), media.length ? (_jsx("ul", { className: "sac-list", "aria-label": S.attachmentsLabel, children: media.map((m) => (_jsxs("li", { className: "sac-item", children: [_jsxs("span", { className: "sac-item-name", children: [m.name, ", ", sizeWords(m.bytes)] }), _jsx("button", { type: "button", className: "sac-button", "aria-label": `${S.remove} ${m.name}`, onClick: () => void removeMedia(m), children: S.remove })] }, m.key))) })) : null] }), goes && destWords ? (_jsxs("div", { className: "sac-goes sac-fyi", "data-sac-goes": "", children: [_jsxs("p", { className: "sac-goes-head",
|
|
409
|
+
}) })) : null, onClose ? _jsx("button", { type: "button", className: "sac-button sac-primary", onClick: onClose, children: S.done }) : null] })) : (_jsxs("form", { className: "sac-form", onSubmit: (e) => { e.preventDefault(); void send(); }, children: [_jsxs("div", { className: "sac-kind", children: [_jsx("label", { htmlFor: `${titleId}-kind`, children: S.kindLegend }), _jsx("select", { id: `${titleId}-kind`, className: "sac-select", value: kind, onChange: (e) => setKind(e.target.value), children: kinds.map((k) => _jsx("option", { value: k.id, children: k.label }, k.id)) })] }), _jsxs("fieldset", { className: "sac-group sac-sayit", children: [_jsx("legend", { children: S.sayItLegend }), fieldFor('words', S.sayItLabel, words, setWords, { placeholder: kind === 'broken' ? S.sayItPlaceholderBroken : kind === 'feedback' ? S.sayItPlaceholderFeedback : S.sayItPlaceholder }), voice.length ? (_jsx("ul", { className: "sac-list", "aria-label": S.voiceNotesLabel, children: voice.map((v, i) => (_jsxs("li", { className: "sac-item", children: [_jsx("span", { className: "sac-item-name", children: S.voiceNote(i + 1, lengthWords(v.ms)) }), _jsx("button", { type: "button", className: "sac-button", "aria-label": `${S.remove} ${S.voiceNote(i + 1, lengthWords(v.ms))}`, onClick: () => void removeVoice(v), children: S.remove })] }, v.key))) })) : null] }), _jsxs("fieldset", { className: "sac-group", children: [_jsx("legend", { children: S.showItLegend }), _jsxs("div", { className: "sac-actions", children: [_jsx("button", { type: "button", className: "sac-button", onClick: () => fileInput.current?.click(), children: S.addImages }), _jsx("button", { type: "button", className: "sac-button", onClick: () => videoInput.current?.click(), children: S.addVideo }), adapters.snapshot ? _jsx("button", { type: "button", className: "sac-button", onClick: () => void takeScreenshot(), children: S.takeScreenshot }) : null] }), _jsx("input", { ref: fileInput, type: "file", accept: "image/*", multiple: true, hidden: true, "aria-label": S.addImages, "data-sac-images": "", onChange: (e) => { const f = [...(e.target.files ?? [])]; e.target.value = ''; void addMedia(f, 'image'); } }), _jsx("input", { ref: videoInput, type: "file", accept: "video/*", hidden: true, "aria-label": S.addVideo, "data-sac-video": "", onChange: (e) => { const f = [...(e.target.files ?? [])]; e.target.value = ''; void addMedia(f, 'video'); } }), media.length ? (_jsx("ul", { className: "sac-list", "aria-label": S.attachmentsLabel, children: media.map((m) => (_jsxs("li", { className: "sac-item", children: [_jsxs("span", { className: "sac-item-name", children: [m.name, ", ", sizeWords(m.bytes)] }), _jsx("button", { type: "button", className: "sac-button", "aria-label": `${S.remove} ${m.name}`, onClick: () => void removeMedia(m), children: S.remove })] }, m.key))) })) : null] }), goes && destWords ? (_jsxs("div", { className: "sac-goes sac-fyi", "data-sac-goes": "", children: [_jsxs("p", { className: "sac-goes-head", "data-sac-where": "", children: [S.filesOn(dest.repo), ' (', _jsx("span", { className: destWords.danger ? 'sac-danger' : 'sac-muted', children: destWords.danger ? S.publicShort : S.privateShort }), ")"] }), foldToggle(S.carriesToggle(goingCount(goes))), open ? (_jsxs("div", { id: foldId, className: "sac-fold-body", children: [_jsx("p", { className: "sac-muted", children: S.carriesLead }), _jsx("ul", { className: "sac-goes-list", children: goes.filter((g) => !g.optional).map((g) => _jsx("li", { "data-goes": g.key, children: g.text }, g.key)) }), goes.some((g) => g.optional) ? _jsx("ul", { className: "sac-goes-opts", "aria-label": S.contextLegend, children: goes.filter((g) => g.optional).map(optionalRow) }) : null, _jsx("p", { className: "sac-muted", children: destWords.account })] })) : null] })) : (_jsxs("div", { className: "sac-fyi", children: [_jsx("p", { className: "sac-muted sac-fyi", "data-sac-summary": "", children: summary }), rows.some((g) => g.optional) ? foldToggle(S.alsoSharedToggle(rows.filter((g) => g.optional && isOn(g.key)).length)) : null, open && rows.some((g) => g.optional) ? (_jsx("div", { id: foldId, className: "sac-fold-body", children: _jsx("ul", { className: "sac-goes-opts", "aria-label": S.contextLegend, children: rows.filter((g) => g.optional).map(optionalRow) }) })) : null] })), S.destinationNote ? _jsx("p", { className: "sac-note", "data-sac-note": "", children: S.destinationNote }) : null, error ? _jsx("p", { role: "alert", className: "sac-error", children: error }) : null, _jsxs("div", { className: "sac-actions", children: [_jsx("button", { type: "submit", className: "sac-button sac-primary", disabled: !hasAnything, "aria-describedby": hasAnything ? undefined : `${titleId}-hint`, children: S.send }), hasAnything ? null : _jsx("span", { id: `${titleId}-hint`, className: "sac-muted", children: S.sendHint })] })] })), _jsx(Toasts, {})] }) }));
|
|
403
410
|
}
|
package/dist/client/words.d.ts
CHANGED
|
@@ -57,6 +57,7 @@ export interface Strings {
|
|
|
57
57
|
mineTitle: string;
|
|
58
58
|
mineEmpty: string;
|
|
59
59
|
back: string;
|
|
60
|
+
/** The microphone's name while idle. The key keeps its old name; a tap starts it, and holding works too. */
|
|
60
61
|
holdToTalk: string;
|
|
61
62
|
stopTalking: string;
|
|
62
63
|
micDenied: string;
|
|
@@ -106,6 +107,18 @@ export interface Strings {
|
|
|
106
107
|
/** The app's own note, shown under the what-goes list (or the one line) and before Send, never instead of it: what
|
|
107
108
|
* only the app can say about where a report goes and what it carries. Empty shows nothing. */
|
|
108
109
|
destinationNote: string;
|
|
110
|
+
/** The one line above Send that says where a suggestion files, before its privacy word ("Files an issue on acme/notes"). */
|
|
111
|
+
filesOn: (repo: string) => string;
|
|
112
|
+
/** The privacy word after it for a private repo. */
|
|
113
|
+
privateShort: string;
|
|
114
|
+
/** The same for a public repo, in the danger colour and never folded: the one fact that must not be missed. */
|
|
115
|
+
publicShort: string;
|
|
116
|
+
/** The toggle that opens the itemized list of what the issue will carry, with how many things go. */
|
|
117
|
+
carriesToggle: (n: number) => string;
|
|
118
|
+
/** The toggle without a destination, opening the context tick boxes, with how many are ticked. */
|
|
119
|
+
alsoSharedToggle: (n: number) => string;
|
|
120
|
+
/** The lead-in of the list once it is open. */
|
|
121
|
+
carriesLead: string;
|
|
109
122
|
}
|
|
110
123
|
export declare const DEFAULT_STRINGS: Strings;
|
|
111
124
|
export declare function stringsFor(words: Words): Strings;
|
package/dist/client/words.js
CHANGED
|
@@ -20,9 +20,9 @@ export const DEFAULT_STRINGS = {
|
|
|
20
20
|
optional: 'optional',
|
|
21
21
|
sayItLegend: 'Say it',
|
|
22
22
|
sayItLabel: 'Your suggestion',
|
|
23
|
-
sayItPlaceholder: 'What would you change? Type it, or
|
|
24
|
-
sayItPlaceholderBroken: 'What happened, what you expected, and how to make it happen again. Type it, or
|
|
25
|
-
sayItPlaceholderFeedback: 'Whatever is on your mind. Type it, or
|
|
23
|
+
sayItPlaceholder: 'What would you change? Type it, or tap the microphone and say it',
|
|
24
|
+
sayItPlaceholderBroken: 'What happened, what you expected, and how to make it happen again. Type it, or tap the microphone and say it',
|
|
25
|
+
sayItPlaceholderFeedback: 'Whatever is on your mind. Type it, or tap the microphone and say it',
|
|
26
26
|
showItLegend: 'Show it',
|
|
27
27
|
addImages: 'Add a screenshot or photo',
|
|
28
28
|
addVideo: 'Add a short video',
|
|
@@ -51,8 +51,8 @@ export const DEFAULT_STRINGS = {
|
|
|
51
51
|
mineTitle: 'My suggestions',
|
|
52
52
|
mineEmpty: 'You have not sent a suggestion yet.',
|
|
53
53
|
back: 'Back',
|
|
54
|
-
holdToTalk: '
|
|
55
|
-
stopTalking: '
|
|
54
|
+
holdToTalk: 'Tap to talk',
|
|
55
|
+
stopTalking: 'Tap to stop',
|
|
56
56
|
micDenied: 'The microphone could not be used. You can type it instead.',
|
|
57
57
|
voiceUnavailable: 'Voice notes cannot be added here',
|
|
58
58
|
loading: 'Loading',
|
|
@@ -92,6 +92,12 @@ export const DEFAULT_STRINGS = {
|
|
|
92
92
|
issueLink: (n) => `Issue #${n}`,
|
|
93
93
|
contextLegend: 'Also shared, so it can be fixed (untick anything to leave it out)',
|
|
94
94
|
destinationNote: '',
|
|
95
|
+
filesOn: (repo) => `Files an issue on ${repo}`,
|
|
96
|
+
privateShort: 'private',
|
|
97
|
+
publicShort: 'public: anyone can read it',
|
|
98
|
+
carriesToggle: (n) => `What it carries (${n})`,
|
|
99
|
+
alsoSharedToggle: (n) => `Also shared (${n})`,
|
|
100
|
+
carriesLead: 'It will carry:',
|
|
95
101
|
};
|
|
96
102
|
export function stringsFor(words) {
|
|
97
103
|
return { ...DEFAULT_STRINGS, ...words.text };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supersuit/suggest-a-change",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"description": "The report format and flow behind a Suggest a change button: what a report is, who acts at each step, the screens, and the check every app runs. The contract has zero dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
'use client'
|
|
2
|
-
// THE MODULE'S OWN VOICE FIELD, for hosted apps that have no golden one. A text area with
|
|
3
|
-
//
|
|
2
|
+
// THE MODULE'S OWN VOICE FIELD, for hosted apps that have no golden one. A text area with its microphone
|
|
3
|
+
// INSIDE the box (bottom right; centred at the right of a one-line input), in one [data-voice-field]
|
|
4
|
+
// container with nothing else typeable in it. A take is
|
|
4
5
|
// recorded with the module's recorder (written to the phone's database as it is made). Transcription is
|
|
5
6
|
// not done here: the take goes to `upload`, and the receiving Mac writes the transcript. A voice note
|
|
6
7
|
// alone is a whole report, so a field with no words and one take is a complete answer.
|
|
7
8
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
9
|
+
// A tap starts a take that keeps recording until the next tap (or the time limit); a press held longer
|
|
10
|
+
// than a tap records until it is let go, as hold-to-talk always did. Leaving the button, or a pointer the
|
|
11
|
+
// browser cancels (iOS Safari does when its microphone prompt appears), never ends a tapped take. The
|
|
12
|
+
// recorder is asked synchronously inside the press, with nothing awaited first, so iOS Safari treats the
|
|
13
|
+
// microphone request as part of the gesture. Space or Enter on the focused button starts and stops a take.
|
|
14
|
+
// (The operator, 2026-10-26: a tap on the hold-only button did nothing, because the release ended the take
|
|
15
|
+
// before the microphone had answered.)
|
|
10
16
|
import { useEffect, useRef, useState } from 'react'
|
|
11
17
|
import type { FieldProps } from './adapters.ts'
|
|
12
18
|
import { startTake as defaultStartTake } from './recorder.ts'
|
|
@@ -15,6 +21,8 @@ import { DEFAULT_STRINGS } from './words.ts'
|
|
|
15
21
|
|
|
16
22
|
|
|
17
23
|
type Take = Awaited<ReturnType<typeof defaultStartTake>>
|
|
24
|
+
/** A press shorter than this is a tap: it starts a take that a second tap stops. */
|
|
25
|
+
const TAP_MS = 350
|
|
18
26
|
|
|
19
27
|
export function DefaultField(props: FieldProps) {
|
|
20
28
|
const { rt, id, label, value, onChange, placeholder, multiline = false, type = 'text', upload, onHeard, onKept, onHearing, disabled, startTake = defaultStartTake, maxMs, labels, scope } = props
|
|
@@ -25,6 +33,8 @@ export function DefaultField(props: FieldProps) {
|
|
|
25
33
|
const take = useRef<Take | null>(null)
|
|
26
34
|
const wanted = useRef(false)
|
|
27
35
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
36
|
+
// The press in progress that started a take: when it began. Null between presses, and for a press that stopped one.
|
|
37
|
+
const press = useRef<number | null>(null)
|
|
28
38
|
const latest = useRef(value)
|
|
29
39
|
latest.current = value
|
|
30
40
|
const cb = useRef({ onChange, upload, onHeard, onKept, onHearing })
|
|
@@ -75,10 +85,29 @@ export function DefaultField(props: FieldProps) {
|
|
|
75
85
|
aria-pressed={recording}
|
|
76
86
|
aria-label={canRecord ? `${recording ? L.stopTalking : L.holdToTalk}: ${label}` : `${L.voiceUnavailable}: ${label}`}
|
|
77
87
|
disabled={disabled || !canRecord}
|
|
78
|
-
onPointerDown={(e) => {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
88
|
+
onPointerDown={(e) => {
|
|
89
|
+
e.preventDefault()
|
|
90
|
+
// A take already going (a tapped one): this press stops it.
|
|
91
|
+
if (take.current || wanted.current) { press.current = null; void end(); return }
|
|
92
|
+
press.current = Date.now()
|
|
93
|
+
void begin() // startTake is called before begin's first await: still inside the gesture
|
|
94
|
+
}}
|
|
95
|
+
onPointerUp={() => {
|
|
96
|
+
const at = press.current
|
|
97
|
+
press.current = null
|
|
98
|
+
// A tap keeps recording until the next tap; a hold stops on release.
|
|
99
|
+
if (at !== null && Date.now() - at >= TAP_MS) void end()
|
|
100
|
+
}}
|
|
101
|
+
onPointerLeave={() => {
|
|
102
|
+
// Sliding off a held press stops it, as hold-to-talk did; a tapped take, or one still starting, goes on.
|
|
103
|
+
const at = press.current
|
|
104
|
+
press.current = null
|
|
105
|
+
if (at !== null && take.current && Date.now() - at >= TAP_MS) void end()
|
|
106
|
+
}}
|
|
107
|
+
onPointerCancel={() => {
|
|
108
|
+
// The browser took the pointer (a permission prompt, a scroll): the take carries on as a tapped one.
|
|
109
|
+
press.current = null
|
|
110
|
+
}}
|
|
82
111
|
onContextMenu={(e) => e.preventDefault()}
|
|
83
112
|
onKeyDown={(e) => {
|
|
84
113
|
if (e.key !== ' ' && e.key !== 'Enter') return
|
|
@@ -93,9 +122,11 @@ export function DefaultField(props: FieldProps) {
|
|
|
93
122
|
const shared = { id: `sac-field-${id}`, value, placeholder, disabled, className: 'sac-input', onChange: (e: { target: { value: string } }) => onChange(e.target.value) }
|
|
94
123
|
return (
|
|
95
124
|
<div data-voice-field="" className="sac-voice" data-recording={recording ? '' : undefined}>
|
|
96
|
-
{control}
|
|
97
125
|
<label htmlFor={`sac-field-${id}`} className="sac-sr">{label}</label>
|
|
98
|
-
|
|
126
|
+
<div className={`sac-voice-box ${multiline ? 'sac-voice-multi' : 'sac-voice-single'}`}>
|
|
127
|
+
{multiline ? <textarea rows={3} {...shared} /> : <input type={type} {...shared} />}
|
|
128
|
+
{control}
|
|
129
|
+
</div>
|
|
99
130
|
</div>
|
|
100
131
|
)
|
|
101
132
|
}
|
package/src/client/sheet.tsx
CHANGED
|
@@ -92,6 +92,9 @@ export function SuggestSheet({ adapters, where, onClose, initial }: { adapters:
|
|
|
92
92
|
const [ctx, setCtx] = useState<ReportContext | null>(null)
|
|
93
93
|
const [contact, setContact] = useState<Contact | null>(null)
|
|
94
94
|
const [choice, setChoice] = useState<Record<string, boolean>>({})
|
|
95
|
+
// The itemized list of what goes, with its tick boxes, is folded until the person opens it: a wall of checkboxes was
|
|
96
|
+
// "a little bit disturbing" (the operator, 2026-10-26). Where it files, and a public repo's warning, are never folded.
|
|
97
|
+
const [open, setOpen] = useState(false)
|
|
95
98
|
// Where it files, asked once when the sheet opens; until it answers (or with none) the one-line note stays.
|
|
96
99
|
useEffect(() => {
|
|
97
100
|
if (!adapters.destination) return
|
|
@@ -325,6 +328,15 @@ export function SuggestSheet({ adapters, where, onClose, initial }: { adapters:
|
|
|
325
328
|
</li>
|
|
326
329
|
)
|
|
327
330
|
|
|
331
|
+
// What the fold's toggle counts: everything that will go, the always rows plus each ticked one.
|
|
332
|
+
const goingCount = (list: GoesItem[]) => list.filter((g) => !g.optional || isOn(g.key)).length
|
|
333
|
+
const foldId = `${titleId}-fold`
|
|
334
|
+
const foldToggle = (label: string) => (
|
|
335
|
+
<button type="button" className="sac-button sac-fold" aria-expanded={open} aria-controls={foldId} data-sac-fold="" onClick={() => setOpen((o) => !o)}>
|
|
336
|
+
<span className="sac-fold-mark" aria-hidden="true" />{label}
|
|
337
|
+
</button>
|
|
338
|
+
)
|
|
339
|
+
|
|
328
340
|
const hasAnything = Boolean(words.trim() || voice.length || media.length)
|
|
329
341
|
|
|
330
342
|
async function send() {
|
|
@@ -466,16 +478,26 @@ export function SuggestSheet({ adapters, where, onClose, initial }: { adapters:
|
|
|
466
478
|
|
|
467
479
|
{goes && destWords ? (
|
|
468
480
|
<div className="sac-goes sac-fyi" data-sac-goes="">
|
|
469
|
-
<p className="sac-goes-head"
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
481
|
+
<p className="sac-goes-head" data-sac-where="">{S.filesOn(dest!.repo)}{' ('}<span className={destWords.danger ? 'sac-danger' : 'sac-muted'}>{destWords.danger ? S.publicShort : S.privateShort}</span>)</p>
|
|
482
|
+
{foldToggle(S.carriesToggle(goingCount(goes)))}
|
|
483
|
+
{open ? (
|
|
484
|
+
<div id={foldId} className="sac-fold-body">
|
|
485
|
+
<p className="sac-muted">{S.carriesLead}</p>
|
|
486
|
+
<ul className="sac-goes-list">{goes.filter((g) => !g.optional).map((g) => <li key={g.key} data-goes={g.key}>{g.text}</li>)}</ul>
|
|
487
|
+
{goes.some((g) => g.optional) ? <ul className="sac-goes-opts" aria-label={S.contextLegend}>{goes.filter((g) => g.optional).map(optionalRow)}</ul> : null}
|
|
488
|
+
<p className="sac-muted">{destWords.account}</p>
|
|
489
|
+
</div>
|
|
490
|
+
) : null}
|
|
474
491
|
</div>
|
|
475
492
|
) : (
|
|
476
493
|
<div className="sac-fyi">
|
|
477
494
|
<p className="sac-muted sac-fyi" data-sac-summary="">{summary}</p>
|
|
478
|
-
{rows.some((g) => g.optional) ?
|
|
495
|
+
{rows.some((g) => g.optional) ? foldToggle(S.alsoSharedToggle(rows.filter((g) => g.optional && isOn(g.key)).length)) : null}
|
|
496
|
+
{open && rows.some((g) => g.optional) ? (
|
|
497
|
+
<div id={foldId} className="sac-fold-body">
|
|
498
|
+
<ul className="sac-goes-opts" aria-label={S.contextLegend}>{rows.filter((g) => g.optional).map(optionalRow)}</ul>
|
|
499
|
+
</div>
|
|
500
|
+
) : null}
|
|
479
501
|
</div>
|
|
480
502
|
)}
|
|
481
503
|
|
package/src/client/styles.css
CHANGED
|
@@ -37,11 +37,22 @@
|
|
|
37
37
|
.sac-select { font: inherit; font-size: 16px; min-height: 36px; padding: 4px 10px; color: inherit; background: var(--sac-bg); border: 1px solid var(--sac-border); border-radius: var(--sac-radius); }
|
|
38
38
|
/* Saying it is the sheet's biggest thing, whatever field the host puts in it. */
|
|
39
39
|
.sac-sayit textarea { min-height: 9rem; }
|
|
40
|
-
.sac-voice {
|
|
40
|
+
.sac-voice { min-width: 0; }
|
|
41
41
|
.sac-input, .sac-voice textarea, .sac-voice input { flex: 1; min-width: 0; min-height: 44px; font: inherit; font-size: 16px; color: inherit; background: var(--sac-bg); border: 1px solid var(--sac-border); border-radius: var(--sac-radius); padding: 10px 12px; box-sizing: border-box; }
|
|
42
42
|
.sac-record { flex: none; width: 48px; height: 48px; min-width: 44px; min-height: 44px; border-radius: 50%; border: 1px solid var(--sac-border); background: var(--sac-bg); display: grid; place-items: center; touch-action: none; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; cursor: pointer; }
|
|
43
43
|
.sac-record-dot { width: 18px; height: 18px; border-radius: 50%; background: var(--sac-danger); }
|
|
44
44
|
.sac-voice[data-recording] .sac-record { border-color: var(--sac-danger); box-shadow: 0 0 0 4px color-mix(in srgb, var(--sac-danger) 25%, transparent); }
|
|
45
|
+
/* Recording: the dot becomes a stop square, so a tapped take reads as "tap to stop". */
|
|
46
|
+
.sac-voice[data-recording] .sac-record-dot { border-radius: 3px; width: 16px; height: 16px; }
|
|
47
|
+
/* The microphone sits inside its text box: bottom right of a text area, centred at the right of a one-line input.
|
|
48
|
+
The box keeps its text clear of it. */
|
|
49
|
+
.sac-voice-box { position: relative; display: flex; min-width: 0; }
|
|
50
|
+
.sac-voice-box > textarea, .sac-voice-box > input { padding-right: 60px; }
|
|
51
|
+
.sac-voice-multi > textarea { padding-bottom: 56px; }
|
|
52
|
+
.sac-voice-single > input { min-height: 56px; }
|
|
53
|
+
.sac-voice-box > .sac-record { position: absolute; right: 6px; }
|
|
54
|
+
.sac-voice-multi > .sac-record { bottom: 6px; }
|
|
55
|
+
.sac-voice-single > .sac-record { top: 50%; transform: translateY(-50%); }
|
|
45
56
|
.sac-button, .sac-entry, .sac-row-button { font: inherit; font-size: 16px; min-height: 44px; min-width: 44px; padding: 8px 14px; border-radius: var(--sac-radius); border: 1px solid var(--sac-border); background: var(--sac-bg); color: var(--sac-fg); cursor: pointer; }
|
|
46
57
|
.sac-primary { background: var(--sac-accent); color: var(--sac-accent-fg); border-color: var(--sac-accent); }
|
|
47
58
|
.sac-button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
@@ -61,6 +72,13 @@
|
|
|
61
72
|
.sac-note { font-size: 0.8rem; margin: 0; color: var(--sac-fg); }
|
|
62
73
|
.sac-goes-list { margin: 2px 0; padding: 0; list-style: none; display: flex; flex-wrap: wrap; gap: 2px 0; }
|
|
63
74
|
.sac-goes-list li:not(:last-child)::after { content: " \00b7 "; white-space: pre; color: var(--sac-muted); }
|
|
75
|
+
/* The fold over the itemized list of what goes: a quiet text-weight toggle, still a 44px target. */
|
|
76
|
+
.sac-fold { align-self: flex-start; display: inline-flex; align-items: center; gap: 6px; border-color: transparent; background: transparent; padding-left: 0; color: var(--sac-muted); font-size: 0.9rem; margin: 2px 0 0; }
|
|
77
|
+
.sac-fold-mark { width: 0; height: 0; border-top: 5px solid transparent; border-bottom: 5px solid transparent; border-left: 6px solid currentColor; }
|
|
78
|
+
.sac-fold[aria-expanded="true"] .sac-fold-mark { transform: rotate(90deg); }
|
|
79
|
+
.sac-fold-body { display: flex; flex-direction: column; gap: 4px; }
|
|
80
|
+
.sac-goes-opts { margin: 0; padding: 0; list-style: none; }
|
|
81
|
+
.sac-goes-opt label { display: block; min-height: 44px; padding: 4px 0; box-sizing: border-box; }
|
|
64
82
|
.sac-issue-link { color: var(--sac-accent); font-size: 0.9rem; }
|
|
65
83
|
.sac-receipt-line { font-weight: 600; margin: 0; }
|
|
66
84
|
.sac-strip { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; }
|
|
@@ -79,4 +97,5 @@
|
|
|
79
97
|
.sac-entry-why { margin-left: 8px; color: var(--sac-muted); font-size: 0.9rem; }
|
|
80
98
|
.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; }
|
|
81
99
|
@media (min-width: 640px) { .sac-backdrop { align-items: center; } .sac-sheet { border-radius: var(--sac-radius); } }
|
|
82
|
-
@
|
|
100
|
+
@keyframes sac-pulse { 0%, 100% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--sac-danger) 25%, transparent); } 50% { box-shadow: 0 0 0 8px color-mix(in srgb, var(--sac-danger) 10%, transparent); } }
|
|
101
|
+
@media (prefers-reduced-motion: no-preference) { .sac-voice[data-recording] .sac-record { transition: box-shadow 0.15s; animation: sac-pulse 1.4s ease-in-out infinite; } .sac-fold-mark { transition: transform 0.15s; } }
|
package/src/client/words.ts
CHANGED
|
@@ -63,6 +63,7 @@ export interface Strings {
|
|
|
63
63
|
mineTitle: string
|
|
64
64
|
mineEmpty: string
|
|
65
65
|
back: string
|
|
66
|
+
/** The microphone's name while idle. The key keeps its old name; a tap starts it, and holding works too. */
|
|
66
67
|
holdToTalk: string
|
|
67
68
|
stopTalking: string
|
|
68
69
|
micDenied: string
|
|
@@ -112,6 +113,18 @@ export interface Strings {
|
|
|
112
113
|
/** The app's own note, shown under the what-goes list (or the one line) and before Send, never instead of it: what
|
|
113
114
|
* only the app can say about where a report goes and what it carries. Empty shows nothing. */
|
|
114
115
|
destinationNote: string
|
|
116
|
+
/** The one line above Send that says where a suggestion files, before its privacy word ("Files an issue on acme/notes"). */
|
|
117
|
+
filesOn: (repo: string) => string
|
|
118
|
+
/** The privacy word after it for a private repo. */
|
|
119
|
+
privateShort: string
|
|
120
|
+
/** The same for a public repo, in the danger colour and never folded: the one fact that must not be missed. */
|
|
121
|
+
publicShort: string
|
|
122
|
+
/** The toggle that opens the itemized list of what the issue will carry, with how many things go. */
|
|
123
|
+
carriesToggle: (n: number) => string
|
|
124
|
+
/** The toggle without a destination, opening the context tick boxes, with how many are ticked. */
|
|
125
|
+
alsoSharedToggle: (n: number) => string
|
|
126
|
+
/** The lead-in of the list once it is open. */
|
|
127
|
+
carriesLead: string
|
|
115
128
|
}
|
|
116
129
|
|
|
117
130
|
export const DEFAULT_STRINGS: Strings = {
|
|
@@ -131,9 +144,9 @@ export const DEFAULT_STRINGS: Strings = {
|
|
|
131
144
|
optional: 'optional',
|
|
132
145
|
sayItLegend: 'Say it',
|
|
133
146
|
sayItLabel: 'Your suggestion',
|
|
134
|
-
sayItPlaceholder: 'What would you change? Type it, or
|
|
135
|
-
sayItPlaceholderBroken: 'What happened, what you expected, and how to make it happen again. Type it, or
|
|
136
|
-
sayItPlaceholderFeedback: 'Whatever is on your mind. Type it, or
|
|
147
|
+
sayItPlaceholder: 'What would you change? Type it, or tap the microphone and say it',
|
|
148
|
+
sayItPlaceholderBroken: 'What happened, what you expected, and how to make it happen again. Type it, or tap the microphone and say it',
|
|
149
|
+
sayItPlaceholderFeedback: 'Whatever is on your mind. Type it, or tap the microphone and say it',
|
|
137
150
|
showItLegend: 'Show it',
|
|
138
151
|
addImages: 'Add a screenshot or photo',
|
|
139
152
|
addVideo: 'Add a short video',
|
|
@@ -162,8 +175,8 @@ export const DEFAULT_STRINGS: Strings = {
|
|
|
162
175
|
mineTitle: 'My suggestions',
|
|
163
176
|
mineEmpty: 'You have not sent a suggestion yet.',
|
|
164
177
|
back: 'Back',
|
|
165
|
-
holdToTalk: '
|
|
166
|
-
stopTalking: '
|
|
178
|
+
holdToTalk: 'Tap to talk',
|
|
179
|
+
stopTalking: 'Tap to stop',
|
|
167
180
|
micDenied: 'The microphone could not be used. You can type it instead.',
|
|
168
181
|
voiceUnavailable: 'Voice notes cannot be added here',
|
|
169
182
|
loading: 'Loading',
|
|
@@ -203,6 +216,12 @@ export const DEFAULT_STRINGS: Strings = {
|
|
|
203
216
|
issueLink: (n) => `Issue #${n}`,
|
|
204
217
|
contextLegend: 'Also shared, so it can be fixed (untick anything to leave it out)',
|
|
205
218
|
destinationNote: '',
|
|
219
|
+
filesOn: (repo) => `Files an issue on ${repo}`,
|
|
220
|
+
privateShort: 'private',
|
|
221
|
+
publicShort: 'public: anyone can read it',
|
|
222
|
+
carriesToggle: (n) => `What it carries (${n})`,
|
|
223
|
+
alsoSharedToggle: (n) => `Also shared (${n})`,
|
|
224
|
+
carriesLead: 'It will carry:',
|
|
206
225
|
}
|
|
207
226
|
|
|
208
227
|
export function stringsFor(words: Words): Strings {
|