From 19a0d0af1870a15162a7451242516fff85818463 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:15:53 +1000 Subject: [PATCH 1/5] Ask for an optional contact email, with separate update and follow-up consent Problem reports arrive with no way to reply. People can now leave an email address with two separate opt-ins: occasional update notices, and follow-up questions from the maintainer. - ui/frame_contact.py keeps the address and choices locally and sends each change privately to PostHog as a contact_consent event under its own random contact id; removing the address sends a withdrawal without it. Changes made offline wait and are retried. - A one-time, dismissible prompt appears after the Frame first connects; No thanks and showing it once are both remembered. - Privacy & updates gains a Contact email section to add, change or remove it. - The report form's contact field now goes with a report only when "may contact me with follow-up questions" is ticked (contact_followup). - frame_report.py contacts [updates|followup] lists who agreed to what, using the newest event per copy. - docs/privacy.md says what is collected, why, where and how to remove it. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/privacy.md | 46 ++++++++++- tests/test_contact.py | 173 ++++++++++++++++++++++++++++++++++++++++ tests/test_telemetry.py | 8 +- ui/frame_contact.py | 166 ++++++++++++++++++++++++++++++++++++++ ui/frame_report.py | 67 ++++++++++++++-- ui/index.html | 107 +++++++++++++++++++++++-- ui/server.py | 5 ++ 7 files changed, 555 insertions(+), 17 deletions(-) create mode 100644 tests/test_contact.py create mode 100644 ui/frame_contact.py diff --git a/docs/privacy.md b/docs/privacy.md index db7004f..4edda2e 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -103,8 +103,10 @@ privately to Frame Control's PostHog project as a `problem_report` event, the same way as the analytics above, so only the maintainer can read it and nothing is published. It works whatever the analytics settings are, because the person sends it deliberately. The report has the kind, title and text you -wrote, how to reach you if you gave it, a short reference shown after sending, -and the diagnostics below. It has its own random id, so it isn't linked to +wrote, a short reference shown after sending, and the diagnostics below. Your +email address goes with it only if you tick **The maintainer may contact me +with follow-up questions** (the report then carries `contact_followup: true`); +it's filled in from **Contact email** below when you've agreed there. It has its own random id, so it isn't linked to your analytics events. With **Include diagnostics** ticked (the default), the report adds: @@ -128,6 +130,46 @@ The maintainer reads reports on the Frame Control dashboard in PostHog, or with `python3 ui/frame_report.py inbox [days]`, which uses the same personal API key as `frame_compat_db.py sync`. +## Contact email (optional) + +Frame Control never needs an email address. If you'd like to leave one, there +are two separate choices, both off until you tick them: + +| Choice | What it's for | +|---|---| +| **Email me about Frame Control updates** | Occasional notices about new releases and updates | +| **The maintainer may contact me with follow-up questions** | Questions about problem reports you send, mostly | + +You're asked once, in a bar at the top of the page, after the Frame has +connected for the first time. **No thanks** hides it for good, and it isn't +shown again even if you ignore it. **Contact email** in **Privacy & updates** +is where you add, change or remove the address and either choice at any time. + +**What's sent, and where.** The address and the two choices go privately to +Frame Control's PostHog project, the same place as problem reports, as a +`contact_consent` event with `email`, `updates`, `followup`, `action` (`set` +or `withdraw`) and the common properties above. Only the maintainer can read +that project, and nothing in it is published or shared. It's sent only when +you save, whatever the analytics settings are, because you chose to. It +carries its own random contact id, not the analytics id, so it isn't linked +to your usage events. On this computer the address and choices are kept in +`contact/contact.json` in Frame Control's data folder. An address is only +kept with at least one choice ticked. + +**Removing it.** **Remove my email** (or clearing the address and saving) +deletes it from this computer and sends a `withdraw` event with no address in +it. The maintainer's list only uses the newest event from each copy, so from +then on the address isn't listed for either choice. Unticking one choice +works the same way for that choice. If you're offline, the change waits on +this computer and is sent when PostHog can be reached. The earlier event +stays in PostHog until its data retention removes it; to have it deleted +sooner, ask the maintainer (for example in a problem report). + +Nothing sends email yet: this only records who agreed to what. The +maintainer lists the addresses with +`python3 ui/frame_report.py contacts [updates|followup]`, which uses the same +personal API key as `inbox`. + ## Turning it all off Untick the boxes, or set `DO_NOT_TRACK=1` or `FRAME_CONTROL_TELEMETRY=0` in diff --git a/tests/test_contact.py b/tests/test_contact.py new file mode 100644 index 0000000..c775541 --- /dev/null +++ b/tests/test_contact.py @@ -0,0 +1,173 @@ +"""A contact email (ui/frame_contact.py): kept only with a matching choice, sent privately, +withdrawn when removed, never lost offline, and the one-time prompt stays dismissed. + +Run: python3 -m unittest discover -s tests +""" +import sandbox # noqa: F401 (first: keeps tests off real data and services) +import sys +import unittest +from pathlib import Path +from unittest import mock + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "ui")) +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import frame_compat_db as db # noqa: E402 +import frame_contact as fc # noqa: E402 +import frame_report as fr # noqa: E402 +import frame_telemetry as tm # noqa: E402 +from test_telemetry import Base, ReportProblem # noqa: E402 + +REPORT = {"title": "RDP not working", "message": "It never connects on Windows."} + + +class Contact(Base): + """Base's temp telemetry state, ReportProblem's PostHog stand-in, and a temp contact file.""" + serve = ReportProblem.serve + + def setUp(self): + super().setUp() + for name, value in (("STATE", tm.STATE / "contact"), ("FILE", tm.STATE / "contact" / "contact.json")): + p = mock.patch.object(fc, name, value) + p.start() + self.addCleanup(p.stop) + self.got = self.serve() + + def events(self): + return [body["batch"][0] for _, body in self.got] + + def offline(self): + return mock.patch.object(tm, "post", side_effect=tm.SendError("couldn't reach PostHog")) + + # ---- storage and consent flags + + def test_nothing_is_kept_or_sent_until_chosen(self): + s = fc.state() + self.assertEqual((s["email"], s["updates"], s["followup"], s["waiting"]), ("", False, False, False)) + self.assertFalse(fc.FILE.exists()) + self.assertEqual(self.got, []) + + def test_an_address_needs_a_choice_and_a_real_address(self): + with self.assertRaisesRegex(ValueError, "tick"): + fc.save({"email": "me@example.com"}) + with self.assertRaisesRegex(ValueError, "email address"): + fc.save({"email": "not an address", "updates": True}) + self.assertEqual(fc.load()["email"], "") + self.assertEqual(self.got, []) + + def test_each_choice_is_sent_privately_on_its_own(self): + fc.save({"email": " me@example.com ", "updates": True}) + fc.save({"email": "me@example.com", "updates": False, "followup": True}) + first, second = self.events() + self.assertEqual(first["event"], "contact_consent") + self.assertEqual({k: first["properties"][k] for k in ("email", "updates", "followup", "action")}, + {"email": "me@example.com", "updates": True, "followup": False, "action": "set"}) + self.assertEqual((second["properties"]["updates"], second["properties"]["followup"]), (False, True)) + self.assertEqual(first["distinct_id"], second["distinct_id"]) # one contact id, newest wins + self.assertNotEqual(first["distinct_id"], tm.settings()["id"]) # not the analytics id + self.assertEqual((first["properties"]["$process_person_profile"], first["properties"]["$geoip_disable"]), + (False, True)) + self.assertEqual([e["event"] for e in tm._read_lines(tm.SENT)], ["contact_consent"] * 2) + + def test_sent_whatever_the_analytics_settings(self): + tm.update_settings({"usage": False}) + fc.save({"email": "me@example.com", "followup": True}) + self.assertEqual(len(self.got), 1) + + def test_saving_the_same_choice_again_sends_nothing(self): + fc.save({"email": "me@example.com", "updates": True}) + fc.save({"email": "me@example.com", "updates": True}) + self.assertEqual(len(self.got), 1) + + # ---- withdrawal + + def test_removing_the_address_sends_a_withdrawal_without_it(self): + fc.save({"email": "me@example.com", "updates": True, "followup": True}) + s = fc.save({"email": "", "updates": True, "followup": True}) + self.assertEqual((s["email"], s["updates"], s["followup"]), ("", False, False)) + withdrawal = self.events()[-1]["properties"] + self.assertEqual((withdrawal["action"], withdrawal["email"], withdrawal["updates"], withdrawal["followup"]), + ("withdraw", "", False, False)) + self.assertNotIn("me@example.com", fc.FILE.read_text()) + + def test_an_address_still_waiting_is_withdrawn_too(self): + with self.offline(): + fc.save({"email": "me@example.com", "updates": True}) # may already be on its way + with mock.patch.object(tm, "post") as post: + fc.save({"email": ""}) + self.assertEqual([c.args[0][0]["properties"]["action"] for c in post.call_args_list], ["withdraw"]) + self.assertFalse(fc.state()["waiting"]) + + def test_offline_the_newest_choice_waits_and_a_withdrawal_is_never_lost(self): + fc.save({"email": "me@example.com", "updates": True}) + with self.offline(): + s = fc.save({"email": ""}) + self.assertTrue(s["waiting"]) + self.assertFalse(fc._send_pending()) + self.assertEqual(fc.load()["pending"]["properties"]["action"], "withdraw") + self.assertTrue(fc._send_pending()) + self.assertFalse(fc.state()["waiting"]) + self.assertEqual([e["properties"]["action"] for e in self.events()], ["set", "withdraw"]) + + # ---- the one-time prompt + + def test_the_prompt_waits_for_a_working_setup_then_stays_dismissed(self): + self.assertFalse(fc.state()["showPrompt"]) # a new install: the Frame hasn't connected yet + tm.frame_seen("20260901.1", "3.8") + self.assertTrue(fc.state()["showPrompt"]) + fc.prompt({"prompt": "dismissed"}) + fc.prompt({"prompt": "shown"}) # a later session can't bring it back + self.assertEqual(fc.load()["prompt"], "dismissed") + self.assertFalse(fc.state()["showPrompt"]) + self.assertEqual(self.got, []) # No thanks sends nothing + with self.assertRaises(ValueError): + fc.prompt({"prompt": "reset"}) + + def test_the_prompt_is_shown_once_and_saving_answers_it(self): + tm.frame_seen("20260901.1", "3.8") + fc.prompt({"prompt": "shown"}) + self.assertFalse(fc.state()["showPrompt"]) + fc.save({"email": "me@example.com", "followup": True, "fromPrompt": True}) + self.assertEqual(fc.load()["prompt"], "answered") + + # ---- reports and the maintainer's list + + def test_a_report_carries_the_address_only_with_follow_up_consent(self): + fr.send({**REPORT, "contact": "me@example.com"}) + fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True}) + without, with_ = (e["properties"] for e in self.events()) + self.assertEqual((without["contact"], without["contact_followup"]), ("", False)) + self.assertEqual((with_["contact"], with_["contact_followup"]), ("me@example.com", True)) + with self.assertRaisesRegex(ValueError, "email address"): + fr.send({**REPORT, "contact": "discord:me", "contactFollowup": True}) + + def test_contacts_lists_the_newest_choice_per_copy_by_consent(self): + rows = [["a", "both@example.com", True, "true", "2026-09-01T10:00:00Z"], + ["b", "news@example.com", "true", False, "2026-09-02T10:00:00Z"], + ["c", "", False, False, "2026-09-03T10:00:00Z"], # withdrawn + ["d", "not-an-address", True, True, "2026-09-03T10:00:00Z"], ["short"]] + with mock.patch.object(db, "_posthog_query", return_value={"results": rows}) as q: + found = fr.contacts() + self.assertIn("argMax", q.call_args.args[0]) + self.assertEqual(found, {"updates": [("both@example.com", "2026-09-01"), ("news@example.com", "2026-09-02")], + "followup": [("both@example.com", "2026-09-01")]}) + with mock.patch.object(fr, "contacts", return_value=found), \ + mock.patch.object(sys, "argv", ["frame_report.py", "contacts", "followup"]), \ + mock.patch("builtins.print") as out: + fr.main() + printed = " ".join(str(c.args[0]) for c in out.call_args_list if c.args) + self.assertIn("both@example.com", printed) + self.assertNotIn("news@example.com", printed) + + def test_the_page_can_reach_it(self): + import server + self.assertIs(server.POST["/api/contact"], fc.save) + self.assertIs(server.POST["/api/contact/prompt"], fc.prompt) + + +# Run these once, in test_telemetry, not again through the import above. +del Base, ReportProblem + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index 4b400c6..71c9f7e 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -392,13 +392,14 @@ class ReportProblem(Base): got = self.serve() tm.update_settings({"usage": False}) # analytics off: a deliberate report still goes res = fr.send({"kind": "idea", "title": "Live view stops", "message": "It stops after a minute.", - "contact": "me@example.com"}) + "contact": "me@example.com", "contactFollowup": True}) path, body = got[0] event = body["batch"][0] self.assertEqual((path, body["api_key"], event["event"]), ("/batch/", "phc_test", "problem_report")) props = event["properties"] self.assertEqual((props["kind"], props["title"], props["message"], props["contact"], props["report_id"]), ("idea", "Live view stops", "It stops after a minute.", "me@example.com", res["id"])) + self.assertIs(props["contact_followup"], True) self.assertEqual((props["$process_person_profile"], props["$geoip_disable"]), (False, True)) self.assertNotEqual(event["distinct_id"], tm.settings()["id"]) # not linked to the analytics self.assertIn(res["id"], res["message"]) @@ -421,8 +422,9 @@ class ReportProblem(Base): def test_the_inbox_skips_malformed_reports(self): good = ["2026-09-28T09:50:00Z", "AB12CD34", "bug", "Live view stops", "It stops.", None, - "0.4.0", "macOS", "", ""] - rows = [["2026-09-28T10:00:00Z", "X", "bug", "Hand-made", None, None, None, None, None, None], ["short"], good] + "0.4.0", "macOS", "", "", None] + rows = [["2026-09-28T10:00:00Z", "X", "bug", "Hand-made", None, None, None, None, None, None, None], + ["short"], good] with mock.patch.object(db, "_posthog_query", return_value={"results": rows}), \ mock.patch.object(sys, "argv", ["frame_report.py", "inbox"]), \ mock.patch("builtins.print") as out: diff --git a/ui/frame_contact.py b/ui/frame_contact.py new file mode 100644 index 0000000..dbe313b --- /dev/null +++ b/ui/frame_contact.py @@ -0,0 +1,166 @@ +"""An email address the person chooses to leave, and what it may be used for. Python stdlib only. + +Two separate opt-in choices, both off until ticked: + +- updates: occasional notices about Frame Control releases and updates +- followup: the maintainer may ask follow-up questions, mainly about problem reports + +The address and the choices are kept on this computer (frame_host.data_dir('contact')) and +sent privately to Frame Control's PostHog project as a `contact_consent` event, the same way +as problem reports (frame_report.py), so only the maintainer can read them. Every change +sends a new event under this copy's own random contact id (not the analytics id), and the +newest event for an id is the one that counts: removing the address sends a withdrawal with +no address in it. The maintainer lists who agreed to what with +`python3 ui/frame_report.py contacts`. Nothing here sends email. + +A change that can't be sent (offline) waits in the state file and is retried in the +background, so a withdrawal is never lost. The page's one-time prompt is remembered here +too: once it has been shown or dismissed it never comes back. +""" +import json +import os +import re +import threading +import time +import uuid + +import frame_host +import frame_telemetry + +STATE = frame_host.data_dir('contact') +FILE = STATE / 'contact.json' +EMAIL_MAX = 254 +EMAIL_RE = re.compile(r'[^@\s]+@[^@\s]+\.[^@\s.]+') +PROMPTS = ('new', 'shown', 'dismissed', 'answered') +RETRY_EVERY = 600 + +_lock = threading.RLock() +_retrier = None + + +def _defaults(): + return {'id': str(uuid.uuid4()), 'email': '', 'updates': False, 'followup': False, + 'prompt': 'new', 'pending': None} + + +def load(): + with _lock: + s = _defaults() + try: + with open(FILE) as f: + saved = json.load(f) + if isinstance(saved, dict): + s.update({k: v for k, v in saved.items() if k in s}) + except (OSError, ValueError): + pass + return s + + +def _save(s): + STATE.mkdir(parents=True, exist_ok=True) + tmp = FILE.with_suffix('.tmp') + tmp.write_text(json.dumps(s, indent=1)) + os.replace(tmp, FILE) + + +def valid_email(email): + return len(email) <= EMAIL_MAX and bool(EMAIL_RE.fullmatch(email)) + + +def state(): + """What the page shows. showPrompt: the one-time prompt hasn't been shown or answered yet, + and the Frame has connected at least once (setup worked), so it never greets a new install.""" + s = load() + set_up = bool(frame_telemetry.settings().get('frames_seen')) + return {'email': s['email'], 'updates': s['updates'], 'followup': s['followup'], + 'waiting': s['pending'] is not None, 'showPrompt': s['prompt'] == 'new' and set_up} + + +def _event(s): + email = s['email'] if s['updates'] or s['followup'] else '' + return {'event': 'contact_consent', 'distinct_id': s['id'], 'uuid': str(uuid.uuid4()), + 'timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), + 'properties': {**frame_telemetry.common(), 'email': email, 'updates': bool(email and s['updates']), + 'followup': bool(email and s['followup']), + 'action': 'set' if email else 'withdraw', 'level': 'contact'}} + + +def _send_pending(): + """Send the waiting change. True if nothing is left waiting.""" + with _lock: + s = load() + event = s['pending'] + if event is None: + return True + try: + frame_telemetry.post([event], timeout=30) + except frame_telemetry.SendError: + return False + with _lock: + s = load() + if s['pending'] and s['pending'].get('uuid') == event['uuid']: # not replaced meanwhile + s['pending'] = None + _save(s) + try: + frame_telemetry.record_sent([event]) + except OSError: + pass + return True + + +def save(body): + """Set, change or remove the address and the two choices. An address needs at least one + choice ticked; an empty address (or neither ticked) removes it and withdraws both.""" + email = str(body.get('email') or '').strip() + updates, followup = bool(body.get('updates')), bool(body.get('followup')) + if email and not valid_email(email): + raise ValueError("that doesn't look like an email address") + if email and not (updates or followup): + raise ValueError('tick what the address may be used for, or remove it') + if not email: + updates = followup = False + with _lock: + s = load() + changed = (email, updates, followup) != (s['email'], s['updates'], s['followup']) + s.update(email=email, updates=updates, followup=followup) + if body.get('fromPrompt') or email: + s['prompt'] = 'answered' + if changed: + # Only the newest choice matters, so it replaces anything still waiting. A withdrawal + # is sent even for an address still waiting here: its send may already be under way. + s['pending'] = _event(s) + _save(s) + if changed: + _send_pending() + return state() + + +def prompt(body): + """The one-time prompt was shown, or dismissed with No thanks. Either way it stays gone.""" + action = body.get('prompt') + if action not in ('shown', 'dismissed'): + raise ValueError('unknown prompt action') + with _lock: + s = load() + if s['prompt'] in ('new', 'shown'): + s['prompt'] = action + _save(s) + return state() + + +def start(): + """Retry a change that couldn't be sent, from now on in the background.""" + global _retrier + if _retrier: + return + + def loop(): + while True: + try: + _send_pending() + except Exception: + pass + time.sleep(RETRY_EVERY) + + _retrier = threading.Thread(target=loop, name='contact', daemon=True) + _retrier.start() diff --git a/ui/frame_report.py b/ui/frame_report.py index ffcfa9d..9134456 100644 --- a/ui/frame_report.py +++ b/ui/frame_report.py @@ -6,6 +6,10 @@ project as a `problem_report` event: only the maintainer can read it, and nothing is published. It is sent whatever the analytics settings are, because the person sends it deliberately. Diagnostics are scrubbed first (frame_telemetry.scrub); the person's own words are sent as written. + +An email address goes with a report only when the person ticks "may contact me with +follow-up questions" (contact_followup). Standing choices made in Settings are +frame_contact.py's `contact_consent` events; `contacts` lists them. """ import os import platform @@ -13,6 +17,7 @@ import sys import time import uuid +import frame_contact import frame_host import frame_telemetry @@ -107,9 +112,13 @@ def send(body): """Send the report to PostHog. Returns {"id", "message"}; raises ReportError.""" kind = body.get('kind') if body.get('kind') in KINDS else 'bug' title, text, diag = compose(body) + followup = bool(body.get('contactFollowup')) + contact = str(body.get('contact') or '').strip() if followup else '' + if followup and not frame_contact.valid_email(contact): + raise ValueError('add your email address for follow-up questions, or untick that box') ref = uuid.uuid4().hex[:8].upper() props = {**frame_telemetry.common(), 'kind': kind, 'title': title, 'message': text, - 'contact': str(body.get('contact') or '').strip()[:120], 'diagnostics': diag, + 'contact': contact, 'contact_followup': followup, 'diagnostics': diag, 'report_id': ref, 'steamos': str(frame.get('build') or '')[:120], 'level': 'report'} # Its own random id: a report can carry contact details, so it isn't linked to this copy's analytics. event = {'event': 'problem_report', 'distinct_id': str(uuid.uuid4()), 'uuid': str(uuid.uuid4()), @@ -135,22 +144,68 @@ def inbox(days=30): import frame_compat_db res = frame_compat_db._posthog_query( "SELECT timestamp, properties.report_id, properties.kind, properties.title, properties.message, " - "properties.contact, properties.app_version, properties.os, properties.steamos, properties.diagnostics " + "properties.contact, properties.app_version, properties.os, properties.steamos, properties.diagnostics, " + "properties.contact_followup " f"FROM events WHERE event = 'problem_report' AND timestamp > now() - INTERVAL {int(days)} DAY " "ORDER BY timestamp DESC LIMIT 200") return res.get('results') or [] +def _yes(v): + return v is True or str(v).lower() in ('true', '1') + + +def contacts(): + """{'updates': [(email, since)], 'followup': [...]}: the addresses whose newest + contact_consent event agrees to each, oldest first. A withdrawal, or a change to another + address, replaces what came before, so withdrawn addresses are never listed.""" + import frame_compat_db + res = frame_compat_db._posthog_query( + "SELECT distinct_id, argMax(properties.email, timestamp), argMax(properties.updates, timestamp), " + "argMax(properties.followup, timestamp), max(timestamp) FROM events WHERE event = 'contact_consent' " + "GROUP BY distinct_id ORDER BY max(timestamp) LIMIT 100000") + out = {'updates': [], 'followup': []} + for row in res.get('results') or []: + if not isinstance(row, list) or len(row) != 5: + continue + _, email, updates, followup, ts = row + email = str(email or '').strip() + if not frame_contact.valid_email(email): + continue + for kind, agreed in (('updates', updates), ('followup', followup)): + if _yes(agreed): + out[kind].append((email, str(ts or '')[:10])) + return out + + +USAGE = 'usage: frame_report.py inbox [days] | contacts [updates|followup]' + + def main(): cmd, *args = sys.argv[1:] or ['inbox'] + if cmd == 'contacts': + kinds = args[:1] or ['updates', 'followup'] + if not set(kinds) <= {'updates', 'followup'}: + sys.exit(USAGE) + found = contacts() + for kind in kinds: + print(f"== {'Release and update notices' if kind == 'updates' else 'Follow-up questions'}" + f" ({len(found[kind])})") + for email, since in found[kind]: + print(f" {email} (since {since})") + print() + return if cmd != 'inbox': - sys.exit('usage: frame_report.py inbox [days]') + sys.exit(USAGE) for row in inbox(*(args[:1] or [30])): - if not isinstance(row, list) or len(row) != 10: + if not isinstance(row, list) or len(row) != 11: continue - ts, ref, kind, title, text, contact, version, osname, steamos, diag = (str(v or '') for v in row) + ts, ref, kind, title, text, contact, version, osname, steamos, diag = (str(v or '') for v in row[:10]) + # Reports from before contact_followup existed only carried an address given for a reply. + reply = contact and (row[10] is None or _yes(row[10])) print(f"== {ts[:16].replace('T', ' ')} {ref} [{kind}] {title}") - print(f" {version} on {osname}, SteamOS {steamos or 'unknown'}{', reply to ' + contact if contact else ''}") + print(f" {version} on {osname}, SteamOS {steamos or 'unknown'}" + f"{', may follow up at ' + contact if reply else ''}") print(' ' + text.replace('\n', '\n ')) if diag: print(' --- diagnostics\n ' + diag.replace('\n', '\n ')) diff --git a/ui/index.html b/ui/index.html index 0d7a686..09512bd 100644 --- a/ui/index.html +++ b/ui/index.html @@ -175,7 +175,7 @@ .seg { display: inline-flex; background: rgba(0,0,0,.3); border-radius: 3px; padding: 2px; } .seg button { background: transparent; height: 28px; font-size: 12.5px; letter-spacing: .6px; text-transform: uppercase; } .seg button.on { background: var(--btn-hi); color: var(--bright); } - input[type=text], input[type=search], input[type=url], input[type=password], textarea { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; + input[type=text], input[type=search], input[type=url], input[type=password], input[type=email], textarea { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px; padding: 9px 11px; font: inherit; } textarea { resize: vertical; min-height: 76px; } input:focus, textarea:focus { outline: none; border-color: var(--blue); background: rgba(0,0,0,.4); } @@ -296,6 +296,11 @@ background: rgba(26,159,255,.12); border-left: 3px solid var(--blue); font-size: 13.5px; line-height: 1.5; } .notice .grow { flex: 1; min-width: 260px; } .notice .progress { width: 160px; margin-top: 0; display: block; } + .contact-opts { display: flex; gap: 6px 18px; flex-wrap: wrap; margin-top: 8px; } + .contact-opts label { display: inline-flex; gap: 7px; align-items: center; cursor: pointer; } + .contact-opts input { width: 15px; height: 15px; margin: 0; accent-color: var(--blue); } + #contactNotice input[type=email] { width: min(320px, 100%); margin-top: 8px; padding: 7px 10px; } + #cEmail { max-width: 420px; } .popt { display: grid; grid-template-columns: auto 1fr; gap: 4px 10px; align-items: start; margin: 0 0 14px; cursor: pointer; } .popt input { margin: 3px 0 0; width: 16px; height: 16px; accent-color: var(--blue); } .popt b { font-weight: 600; color: var(--text); } @@ -674,6 +679,17 @@ +