GoogleGPT 🤖

Google Search에 AI 답변을 추가합니다(Google Gemma + GPT-4o 제공!)

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name                     GoogleGPT 🤖
// @name:zh-CN               GoogleGPT 🤖
// @description              Add AI answers to Google Search (powered by Google Gemma + GPT-4o!)
// @description:af           Voeg AI-antwoorde by Google Search (aangedryf deur Google Gemma + GPT-4o!)
// @description:am           የ Google Search ውስጥ AI መልቀቅን አድርግ፣ (Google Gemma + GPT-4o በመሣሪያዎቹ ውስጥ!)
// @description:ar           يضيف إجابات AI إلى Google Search (مدعوم بواسطة Google Gemma + GPT-4o!)
// @description:as           Google Search-লৈ AI উত্তৰ যোগ দিয়ে (Google Gemma + GPT-4o দ্বাৰা পাওৱা হৈছে!)
// @description:az           Google Search-ya AI cavablarını əlavə edir (Google Gemma + GPT-4o tərəfindən dəstəklənir!)
// @description:be           Дадае ІА адказы на Google Search (падтрымліваецца Google Gemma + GPT-4o!)
// @description:bg           Добавя ИИ отговори в Google Search (поддържан от Google Gemma + GPT-4o!)
// @description:bn           Google Search-ত AI উত্তর যোগ করে (Google Gemma + GPT-4o দ্বারা প্রচালিত!)
// @description:bs           Dodaje AI odgovore na Google Search (pokreće Google Gemma + GPT-4o!)
// @description:ca           Afegeix respostes d'IA a Google Search (impulsat per Google Gemma + GPT-4o!)
// @description:ceb          Nagdugang ug mga tubag AI ngadto sa Google Search (gipadagan sa Google Gemma + GPT-4o!)
// @description:co           Aggiunge risposte AI a Google Search (supportate da Google Gemma + GPT-4o!)
// @description:cs           Přidává AI odpovědi do Google Search (poháněno Google Gemma + GPT-4o!)
// @description:cy           Ychwanegu atebion AI i Google Search (a yrrir gan Google Gemma + GPT-4o!)
// @description:da           Tilføjer AI-svar til Google Search (drevet af Google Gemma + GPT-4o!)
// @description:de           Fügt AI-Antworten zu Google Search hinzu (betrieben von Google Gemma + GPT-4o!)
// @description:el           Προσθέτει απαντήσεις AI στο Google Search (τροφοδοτούμενο από Google Gemma + GPT-4o!)
// @description:en           Add AI answers to Google Search (powered by Google Gemma + GPT-4o!)
// @description:eo           Aldonas AI-respondojn al Google Search (ebligita de Google Gemma + GPT-4o!)
// @description:es           Añade respuestas de IA a Google Search (impulsado por Google Gemma + GPT-4o!)
// @description:et           Lisab AI-vastused Google Search'le (juhitud Google Gemma + GPT-4o-ga!)
// @description:eu           Gehitu IA erantzunak Google Search-n (Google Gemma + GPT-4o-k bultzatuta!)
// @description:fa           پاسخهای هوشمصنوعی به Google Search اضافه میشود (توسط Google Gemma + GPT-4o پشتیبانی میشود!)
// @description:fi           Lisää tekoälyvastauksia Google Search:hun (ohjattu Google Gemma + GPT-4o:lla!)
// @description:fil          Nagdaragdag ng mga sagot ng AI sa Google Search (pinapagana ng Google Gemma + GPT-4o!)
// @description:fo           Bætir AI svar við Google Search (drifin af Google Gemma + GPT-4o!)
// @description:fr           Ajoute des réponses IA à Google Search (propulsé par Google Gemma + GPT-4o!)
// @description:fr-CA        Ajoute des réponses IA à Google Search (propulsé par Google Gemma + GPT-4o!)
// @description:fy           Foeget AI-antwurden ta oan Google Search (dreaun troch Google Gemma + GPT-4o!)
// @description:ga           Cuirtear freagraí AI le Google Search (dírítear ag Google Gemma + GPT-4o!)
// @description:gd           Cur freagairtichean AI ris an Google Search (air a thug seachad le Google Gemma + GPT-4o!)
// @description:gl           Engade respostas de IA a Google Search (impulsado por Google Gemma + GPT-4o!)
// @description:gu           Google Search માટે AI જવાબો ઉમેરે છે (Google Gemma + GPT-4o દ્વારા પોવરેડ!)
// @description:ha           Ƙaddara takardun AI zu Google Search (da aka fi Google Gemma + GPT-4o!)
// @description:haw          Hoʻohui aku i nā hoʻopiʻi AI iā Google Search (hoʻohui ʻia e Google Gemma + GPT-4o!)
// @description:he           מוסיף תשובות AI ל-Google Search (מופעל על ידי Google Gemma + GPT-4o!)
// @description:hi           Google Search में AI उत्तर जोड़ता है (Google Gemma + GPT-4o द्वारा संचालित!)
// @description:hmn          Ntxig AI nruab nruab rau Google Search (pab cuam Google Gemma + GPT-4o!)
// @description:hr           Dodaje AI odgovore na Google Search (pokreće Google Gemma + GPT-4o!)
// @description:ht           Ajoute repons AI nan Google Search (pòte pa Google Gemma + GPT-4o!)
// @description:hu           AI válaszokat ad hozzá a Google Search-hoz (Google Gemma + GPT-4o által hajtva!)
// @description:hy           Ավելացնում է AI պատասխաններ Google Search-ում (աջակցված է Google Gemma + GPT-4o-ով!)
// @description:ia           Adde responas AI a Google Search (propulsate per Google Gemma + GPT-4o!)
// @description:id           Menambahkan jawaban AI ke Google Search (didukung oleh Google Gemma + GPT-4o!)
// @description:ig           Tinye ihe ndekọ AI n'ụzọ ọgụgụ Google Search (n'efu na Google Gemma + GPT-4o!)
// @description:ii           Google Search ᐸᔦᒪᔪᐃᓃᑦ AI ᓇᑕᐅᒪᐃᑦᓯ (Google Gemma + GPT-4o ᓂᑕᔪᑦᓯᐏᑦᑕᒥᔭ!)
// @description:is           Bætir AI svar við Google Search (keyrir á Google Gemma + GPT-4o!)
// @description:it           Aggiunge risposte AI a Google Search (alimentato da Google Gemma + GPT-4o!)
// @description:iu           Google Search ᑲᑎᒪᔪᖅᑐᖅᑐᐃᓐᓇᓂᒃ AI ᑎᑎᕋᖃᕐᓯᒪᓂᖏᓐ (Google Gemma + GPT-4o ᑐᑭᒧᑦᑖᑦ!)
// @description:ja           Google Search に AI 回答を追加します (Google Gemma + GPT-4o で動作!)
// @description:jv           Nambéhi pirangga AI nganti Google Search (diduweni déning Google Gemma + GPT-4o!)
// @description:ka           ამატებს AI პასუხებს Google Search-ს (იმართება Google Gemma + GPT-4o!)
// @description:kk           Google Search-ға AI жауаптарын қосады (Google Gemma + GPT-4o арқылы жұмыс істейді!)
// @description:kl           Google Search-mi AI-t Kalaallit Nunaanni iluani (Google Gemma + GPT-4o! -nip ilaanni!)
// @description:km           បន្ថែមចម្លើយ AI ទៅ Google Search (ដំណើរការដោយ Google Gemma + GPT-4o!)
// @description:kn           Google Search ಗೆ AI ಉತ್ತರಗಳನ್ನು ಸೇರಿಸುತ್ತದೆ (Google Gemma + GPT-4o ನಿಂದ ನಡೆಸಲ್ಪಡುತ್ತಿದೆ!)
// @description:ko           Google Search에 AI 답변을 추가합니다(Google Gemma + GPT-4o 제공!)
// @description:ku           Bersivên AI-ê li Google Search zêde dike (ji hêla Google Gemma + GPT-4o ve hatî hêzdar kirin!)
// @description:ky           Google Search'го AI жоопторун кошот (Google Gemma + GPT-4o тарабынан иштейт!)
// @description:la           Addit AI responsa Google Search (powered per Google Gemma + GPT-4o!)
// @description:lb           Füügt AI Äntwerten op Google Search (ugedriwwen duerch Google Gemma + GPT-4o!)
// @description:lg           Yambula emisomo ey'ensobi ku Google Search (enkuuma Google Gemma + GPT-4o!)
// @description:ln           Ebakisi biyano ya AI na Google Search (ezali na nguya ya Google Gemma + GPT-4o!)
// @description:lo           ເພີ່ມຄໍາຕອບ AI ໃຫ້ກັບ Google Search (ຂັບເຄື່ອນໂດຍ Google Gemma + GPT-4o!)
// @description:lt           Prideda AI atsakymus į „Google Search“ (maitina Google Gemma + GPT-4o!)
// @description:lv           Pievieno AI atbildes Google Search (darbina Google Gemma + GPT-4o!)
// @description:mg           Manampy valiny AI amin'ny Google Search (nampiasain'ny Google Gemma + GPT-4o!)
// @description:mi           Ka taapirihia nga whakautu AI ki a Google Search (whakamahia e Google Gemma + GPT-4o!)
// @description:mk           Додава одговори со вештачка интелигенција на Google Search (напојувано од Google Gemma + GPT-4o!)
// @description:ml           Google Search-യിലേക്ക് AI ഉത്തരങ്ങൾ ചേർക്കുന്നു (Google Gemma + GPT-4o നൽകുന്നതാണ്!)
// @description:mn           Google Search-д AI хариултуудыг нэмдэг (Google Gemma + GPT-4o-оор ажилладаг!)
// @description:mr           Google Search ला AI उत्तरे जोडते (Google Gemma + GPT-4o द्वारे समर्थित!)
// @description:ms           Menambahkan jawapan AI pada Google Search (dikuasakan oleh Google Gemma + GPT-4o!)
// @description:mt           Iżżid it-tweġibiet AI għal Google Search (mħaddma minn Google Gemma + GPT-4o!)
// @description:my           Google Search (Google Gemma + GPT-4o ဖြင့် စွမ်းဆောင်ထားသည့်) တွင် AI အဖြေများကို ပေါင်းထည့်သည်
// @description:na           Aeta AI teroma i Google Search (ira Google Gemma + GPT-4o reke akea!)
// @description:nb           Legger til AI-svar på Google Search (drevet av Google Gemma + GPT-4o!)
// @description:nd           Iyatholakala amaswelelo e-AI kuGoogle Search (kuyatholakala ngokulawula uGoogle Gemma + GPT-4o!)
// @description:ne           Google Search मा AI जवाफहरू थप्छ (Google Gemma + GPT-4o द्वारा संचालित!)
// @description:ng           Ondjova mbelelo dha AI moGoogle Search (uumbuli nguGoogle Gemma + GPT-4o!)
// @description:nl           Voegt AI-antwoorden toe aan Google Search (mogelijk gemaakt door Google Gemma + GPT-4o!)
// @description:nn           Legg til AI-svar på Google Search (drevet av Google Gemma + GPT-4o!)
// @description:no           Legger til AI-svar til Google Search (drevet av Google Gemma + GPT-4o!)
// @description:nso          Ya go etela ditshenyegi tsa AI mo Google Search (e dirwang ke Google Gemma + GPT-4o!)
// @description:ny           Imawonjezera mayankho a AI ku Google Search (yoyendetsedwa ndi Google Gemma + GPT-4o!)
// @description:oc           Ajusta de respòstas d'IA a Google Search (amb Google Gemma + GPT-4o!)
// @description:om           Deebii AI Google Search (Google Gemma + GPT-4o'n kan hojjetu!) irratti dabalata.
// @description:or           Google Search କୁ AI ଉତ୍ତର ଯୋଗ କରେ (Google Gemma + GPT-4o ଦ୍ୱାରା ଚାଳିତ!)
// @description:pa           Google Search (Google Gemma + GPT-4o ਦੁਆਰਾ ਸੰਚਾਲਿਤ!) ਵਿੱਚ AI ਜਵਾਬ ਸ਼ਾਮਲ ਕਰਦਾ ਹੈ
// @description:pl           Dodaje odpowiedzi AI do Google Search (obsługiwane przez Google Gemma + GPT-4o!)
// @description:ps           Google Search ته د AI ځوابونه اضافه کوي (د Google Gemma + GPT-4o لخوا پرمخ وړل کیږي!)
// @description:pt           Adiciona respostas de IA ao Google Search (desenvolvido por Google Gemma + GPT-4o!)
// @description:pt-BR        Adiciona respostas de IA ao Google Search (desenvolvido por Google Gemma + GPT-4o!)
// @description:qu           Google Search (Google Gemma + GPT-4o nisqawan kallpachasqa!) nisqaman AI kutichiykunata yapan.
// @description:rm           Agiuntescha respostas d'IA a Google Search (propulsà da Google Gemma + GPT-4o!)
// @description:rn           Abafasha inyandiko z'IA ku Google Search (yashyizweho na Google Gemma + GPT-4o!)
// @description:ro           Adaugă răspunsuri AI la Google Search (alimentat de Google Gemma + GPT-4o!)
// @description:ru           Добавляет ответы ИИ в Google Search (на базе Google Gemma + GPT-4o!)
// @description:rw           Ongeraho ibisubizo bya AI kuri Google Search (ikoreshwa na Google Gemma + GPT-4o!)
// @description:sa           Google Search (Google Gemma + GPT-4o द्वारा संचालितम्!) इत्यत्र AI उत्तराणि योजयति ।
// @description:sat          Google Search ar AI jawab khon ojantok (Google Gemma + GPT-4o! sebadha manju)
// @description:sc           Agiungit rispostas de IA a Google Search (motorizadu da Google Gemma + GPT-4o!)
// @description:sd           شامل ڪري ٿو AI جوابن کي Google Search (Google Gemma + GPT-4o پاران طاقتور!)
// @description:se           Lávdegáhtii AI vástid Google Search (Google Gemma + GPT-4o! vuosttas!)
// @description:sg           Nâ tî-kûzâ mái vêdáara AI mbi Google Search (ngâ Google Gemma + GPT-4o!)
// @description:si           Google Search වෙත AI පිළිතුරු එක් කරයි (Google Gemma + GPT-4o මගින් බලගන්වයි!)
// @description:sk           Pridáva odpovede AI do Google Search (poháňané Google Gemma + GPT-4o!)
// @description:sl           Dodaja odgovore AI v Google Search (poganja Google Gemma + GPT-4o!)
// @description:sm           Faʻaopoopo tali AI ile Google Search (faʻamalosia e Google Gemma + GPT-4o!)
// @description:sn           Inowedzera mhinduro dzeAI kuGoogle Search (inofambiswa neGoogle Gemma + GPT-4o!)
// @description:so           Waxay ku dartay jawaabaha AI Google Search (waxaa ku shaqeeya Google Gemma + GPT-4o!)
// @description:sq           Shton përgjigjet e AI në Google Search (mundësuar nga Google Gemma + GPT-4o!)
// @description:sr           Додаје АИ одговоре у Google Search (покреће ГПТ-4о!)
// @description:ss           Iphendvulela izindlela zezilungiselelo ku-Google Search (izenzakalo nge-Google Gemma + GPT-4o!)
// @description:st           E kopanetse diqoqo tsa AI ka Google Search (ka sebelisoa ke Google Gemma + GPT-4o!)
// @description:su           Nambahkeun jawaban AI kana Google Search (dikuatkeun ku Google Gemma + GPT-4o!)
// @description:sv           Lägger till AI-svar till Google Search (driven av Google Gemma + GPT-4o!)
// @description:sw           Inaongeza majibu ya AI kwa Google Search (inaendeshwa na Google Gemma + GPT-4o!)
// @description:ta           Google Search க்கு AI பதில்களைச் சேர்க்கிறது (Google Gemma + GPT-4o மூலம் இயக்கப்படுகிறது!)
// @description:te           Google Searchకి AI సమాధానాలను జోడిస్తుంది (Google Gemma + GPT-4o ద్వారా ఆధారితం!)
// @description:tg           Ба Google Search ҷавобҳои AI илова мекунад (аз ҷониби Google Gemma + GPT-4o!)
// @description:th           เพิ่มคำตอบ AI ให้กับ Google Search (ขับเคลื่อนโดย Google Gemma + GPT-4o!)
// @description:ti           ናብ Google Search (ብGoogle Gemma + GPT-4o ዝሰርሕ!) ናይ AI መልስታት ይውስኸሉ።
// @description:tk           Google Search-a AI jogaplaryny goşýar (Google Gemma + GPT-4o bilen işleýär!)
// @description:tl           Nagdadagdag ng mga sagot ng AI sa Google Search (pinapatakbo ng Google Gemma + GPT-4o!)
// @description:tn           O amogela dipotso tsa AI mo Google Search (e a nang le Google Gemma + GPT-4o!)
// @description:to           Tambisa mabizo a AI ku Google Search (mukutenga na Google Gemma + GPT-4o!)
// @description:tr           Google Search'ya yapay zeka yanıtları ekler (Google Gemma + GPT-4o tarafından desteklenmektedir!)
// @description:ts           Ku engetela tinhlamulo ta AI eka Google Search (leyi fambiwaka hi Google Gemma + GPT-4o!)
// @description:tt           Google Search'ка AI җаваплары өсти (Google Gemma + GPT-4o белән эшләнгән!)
// @description:tw           Ɔde AI mmuae ka Google Search (a Google Gemma + GPT-4o na ɛma ahoɔden!) ho.
// @description:ug           Google Search ۋەبسېتكە AI جاۋابلار قوشۇدۇ (Google Gemma + GPT-4o تەكشۈرگۈچى بىلەن!)
// @description:uk           Додає відповіді штучного інтелекту в Google Search (на базі Google Gemma + GPT-4o!)
// @description:ur           Google Search میں AI جوابات شامل کرتا ہے (Google Gemma + GPT-4o کے ذریعے تقویت یافتہ!)
// @description:uz           Google Search-ga AI javoblarini qo'shadi (Google Gemma + GPT-4o tomonidan quvvatlanadi!)
// @description:vi           Thêm câu trả lời AI vào Google Search (được cung cấp bởi Google Gemma + GPT-4o!)
// @description:xh           Yongeza iimpendulo ze-AI kwi-Google Search (ixhaswe yi-Google Gemma + GPT-4o!)
// @description:yi           לייגט אַי ענטפֿערס צו Google Search (Powered דורך Google Gemma + GPT-4o!)
// @description:yo           Ṣe afikun awọn idahun AI si Google Search (agbara nipasẹ Google Gemma + GPT-4o!)
// @description:zh           为 Google Search 添加 AI 答案(由 Google Gemma + GPT-4o 提供支持!)
// @description:zh-CN        为 Google Search 添加 AI 答案(由 Google Gemma + GPT-4o 提供支持!)
// @description:zh-HK        為 Google Search 添加 AI 答案(由 Google Gemma + GPT-4o 提供支援!)
// @description:zh-SG        为 Google Search 添加 AI 答案(由 Google Gemma + GPT-4o 提供支持!)
// @description:zh-TW        為 Google Search 添加 AI 答案(由 Google Gemma + GPT-4o 提供支援!)
// @description:zu           Yengeza izimpendulo ze-AI ku-Google Search (inikwa amandla yi-Google Gemma + GPT-4o!)
// @author                   KudoAI
// @namespace                https://kudoai.com
// @version                  2026.1.27.3
// @license                  MIT
// @icon                     data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20width=%22170.667%22%20height=%22170.667%22%3E%3Cstyle%3E:root%7B--fill:%23000%7D@media%20(prefers-color-scheme:dark)%7B:root%7B--fill:%23fff%7D%7D%3C/style%3E%3Cpath%20fill=%22var(--fill)%22%20d=%22M82.346%20159.79c-18.113-1.815-31.78-9.013-45.921-24.184C23.197%20121.416%2017.333%20106.18%2017.333%2086c0-21.982%205.984-36.245%2021.87-52.131C55.33%2017.74%2069.27%2011.867%2091.416%2011.867c17.574%200%2029.679%203.924%2044.309%2014.363l8.57%206.116-8.705%208.705-8.704%208.704-4.288-3.608c-13.91-11.704-35.932-14.167-53.085-5.939-3.4%201.631-9.833%206.601-14.297%2011.045C44.669%2061.753%2040.95%2070.811%2040.95%2086c0%2014.342%203.594%2023.555%2013.26%2033.995%2019.088%2020.618%2048.46%2022.539%2070.457%204.608l5.333-4.348%2011.333%203.844c6.234%202.114%2011.54%203.857%2011.791%203.873.252.015-2.037%203.008-5.087%206.65-6.343%207.577-20.148%2017.217-30.493%2021.295-8.764%203.454-23.358%205.06-35.198%203.873zM92%2086.333V74.667h60.648l-11.41%2011.41-11.411%2011.41-18.914.257L92%2098z%22/%3E%3C/svg%3E
// @icon64                   data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20width=%22170.667%22%20height=%22170.667%22%3E%3Cstyle%3E:root%7B--fill:%23000%7D@media%20(prefers-color-scheme:dark)%7B:root%7B--fill:%23fff%7D%7D%3C/style%3E%3Cpath%20fill=%22var(--fill)%22%20d=%22M82.346%20159.79c-18.113-1.815-31.78-9.013-45.921-24.184C23.197%20121.416%2017.333%20106.18%2017.333%2086c0-21.982%205.984-36.245%2021.87-52.131C55.33%2017.74%2069.27%2011.867%2091.416%2011.867c17.574%200%2029.679%203.924%2044.309%2014.363l8.57%206.116-8.705%208.705-8.704%208.704-4.288-3.608c-13.91-11.704-35.932-14.167-53.085-5.939-3.4%201.631-9.833%206.601-14.297%2011.045C44.669%2061.753%2040.95%2070.811%2040.95%2086c0%2014.342%203.594%2023.555%2013.26%2033.995%2019.088%2020.618%2048.46%2022.539%2070.457%204.608l5.333-4.348%2011.333%203.844c6.234%202.114%2011.54%203.857%2011.791%203.873.252.015-2.037%203.008-5.087%206.65-6.343%207.577-20.148%2017.217-30.493%2021.295-8.764%203.454-23.358%205.06-35.198%203.873zM92%2086.333V74.667h60.648l-11.41%2011.41-11.411%2011.41-18.914.257L92%2098z%22/%3E%3C/svg%3E
// @compatible               brave
// @compatible               chrome
// @compatible               chromebeta
// @compatible               chromecanary
// @compatible               chromedev
// @compatible               edge
// @compatible               edgebeta
// @compatible               edgecanary
// @compatible               edgedev
// @compatible               fennec
// @compatible               firefox
// @compatible               firefoxbeta
// @compatible               firefoxnightly
// @compatible               ghost
// @compatible               iceraven
// @compatible               ironfox
// @compatible               lemur
// @compatible               librewolf
// @compatible               mises
// @compatible               opera after allowing userscript manager access to search page results in opera://extensions
// @compatible               operaair after allowing userscript manager access to search page results in opera://extensions
// @compatible               operagx after allowing userscript manager access to search page results in opera://extensions
// @compatible               qq
// @compatible               quetta
// @compatible               safari
// @compatible               orion
// @compatible               vivaldi
// @compatible               waterfox
// @compatible               whale
// @match                    *://*.google.com/search*
// @match                    *://*.google.ad/search*
// @match                    *://*.google.ae/search*
// @match                    *://*.google.com.af/search*
// @match                    *://*.google.com.ag/search*
// @match                    *://*.google.com.ai/search*
// @match                    *://*.google.al/search*
// @match                    *://*.google.am/search*
// @match                    *://*.google.co.ao/search*
// @match                    *://*.google.com.ar/search*
// @match                    *://*.google.as/search*
// @match                    *://*.google.at/search*
// @match                    *://*.google.com.au/search*
// @match                    *://*.google.az/search*
// @match                    *://*.google.ba/search*
// @match                    *://*.google.com.bd/search*
// @match                    *://*.google.be/search*
// @match                    *://*.google.bf/search*
// @match                    *://*.google.bg/search*
// @match                    *://*.google.com.bh/search*
// @match                    *://*.google.bi/search*
// @match                    *://*.google.bj/search*
// @match                    *://*.google.com.bn/search*
// @match                    *://*.google.com.bo/search*
// @match                    *://*.google.com.br/search*
// @match                    *://*.google.bs/search*
// @match                    *://*.google.bt/search*
// @match                    *://*.google.co.bw/search*
// @match                    *://*.google.by/search*
// @match                    *://*.google.com.bz/search*
// @match                    *://*.google.ca/search*
// @match                    *://*.google.cd/search*
// @match                    *://*.google.cf/search*
// @match                    *://*.google.cg/search*
// @match                    *://*.google.ch/search*
// @match                    *://*.google.ci/search*
// @match                    *://*.google.co.ck/search*
// @match                    *://*.google.cl/search*
// @match                    *://*.google.cm/search*
// @match                    *://*.google.cn/search*
// @match                    *://*.google.com.co/search*
// @match                    *://*.google.co.cr/search*
// @match                    *://*.google.com.cu/search*
// @match                    *://*.google.cv/search*
// @match                    *://*.google.com.cy/search*
// @match                    *://*.google.cz/search*
// @match                    *://*.google.de/search*
// @match                    *://*.google.dj/search*
// @match                    *://*.google.dk/search*
// @match                    *://*.google.dm/search*
// @match                    *://*.google.com.do/search*
// @match                    *://*.google.dz/search*
// @match                    *://*.google.com.ec/search*
// @match                    *://*.google.ee/search*
// @match                    *://*.google.com.eg/search*
// @match                    *://*.google.es/search*
// @match                    *://*.google.com.et/search*
// @match                    *://*.google.fi/search*
// @match                    *://*.google.com.fj/search*
// @match                    *://*.google.fm/search*
// @match                    *://*.google.fr/search*
// @match                    *://*.google.ga/search*
// @match                    *://*.google.ge/search*
// @match                    *://*.google.gg/search*
// @match                    *://*.google.com.gh/search*
// @match                    *://*.google.com.gi/search*
// @match                    *://*.google.gl/search*
// @match                    *://*.google.gm/search*
// @match                    *://*.google.gr/search*
// @match                    *://*.google.com.gt/search*
// @match                    *://*.google.gy/search*
// @match                    *://*.google.com.hk/search*
// @match                    *://*.google.hn/search*
// @match                    *://*.google.hr/search*
// @match                    *://*.google.ht/search*
// @match                    *://*.google.hu/search*
// @match                    *://*.google.co.id/search*
// @match                    *://*.google.ie/search*
// @match                    *://*.google.co.il/search*
// @match                    *://*.google.im/search*
// @match                    *://*.google.co.in/search*
// @match                    *://*.google.iq/search*
// @match                    *://*.google.is/search*
// @match                    *://*.google.it/search*
// @match                    *://*.google.je/search*
// @match                    *://*.google.com.jm/search*
// @match                    *://*.google.jo/search*
// @match                    *://*.google.co.jp/search*
// @match                    *://*.google.co.ke/search*
// @match                    *://*.google.com.kh/search*
// @match                    *://*.google.ki/search*
// @match                    *://*.google.kg/search*
// @match                    *://*.google.co.kr/search*
// @match                    *://*.google.com.kw/search*
// @match                    *://*.google.kz/search*
// @match                    *://*.google.la/search*
// @match                    *://*.google.com.lb/search*
// @match                    *://*.google.li/search*
// @match                    *://*.google.lk/search*
// @match                    *://*.google.co.ls/search*
// @match                    *://*.google.lt/search*
// @match                    *://*.google.lu/search*
// @match                    *://*.google.lv/search*
// @match                    *://*.google.com.ly/search*
// @match                    *://*.google.co.ma/search*
// @match                    *://*.google.md/search*
// @match                    *://*.google.me/search*
// @match                    *://*.google.mg/search*
// @match                    *://*.google.mk/search*
// @match                    *://*.google.ml/search*
// @match                    *://*.google.com.mm/search*
// @match                    *://*.google.mn/search*
// @match                    *://*.google.ms/search*
// @match                    *://*.google.com.mt/search*
// @match                    *://*.google.mu/search*
// @match                    *://*.google.mv/search*
// @match                    *://*.google.mw/search*
// @match                    *://*.google.com.mx/search*
// @match                    *://*.google.com.my/search*
// @match                    *://*.google.co.mz/search*
// @match                    *://*.google.com.na/search*
// @match                    *://*.google.com.ng/search*
// @match                    *://*.google.com.ni/search*
// @match                    *://*.google.ne/search*
// @match                    *://*.google.nl/search*
// @match                    *://*.google.no/search*
// @match                    *://*.google.com.np/search*
// @match                    *://*.google.nr/search*
// @match                    *://*.google.nu/search*
// @match                    *://*.google.co.nz/search*
// @match                    *://*.google.com.om/search*
// @match                    *://*.google.com.pa/search*
// @match                    *://*.google.com.pe/search*
// @match                    *://*.google.com.pg/search*
// @match                    *://*.google.com.ph/search*
// @match                    *://*.google.com.pk/search*
// @match                    *://*.google.pl/search*
// @match                    *://*.google.pn/search*
// @match                    *://*.google.com.pr/search*
// @match                    *://*.google.ps/search*
// @match                    *://*.google.pt/search*
// @match                    *://*.google.com.py/search*
// @match                    *://*.google.com.qa/search*
// @match                    *://*.google.ro/search*
// @match                    *://*.google.ru/search*
// @match                    *://*.google.rw/search*
// @match                    *://*.google.com.sa/search*
// @match                    *://*.google.com.sb/search*
// @match                    *://*.google.sc/search*
// @match                    *://*.google.se/search*
// @match                    *://*.google.com.sg/search*
// @match                    *://*.google.sh/search*
// @match                    *://*.google.si/search*
// @match                    *://*.google.sk/search*
// @match                    *://*.google.com.sl/search*
// @match                    *://*.google.sn/search*
// @match                    *://*.google.so/search*
// @match                    *://*.google.sm/search*
// @match                    *://*.google.sr/search*
// @match                    *://*.google.st/search*
// @match                    *://*.google.com.sv/search*
// @match                    *://*.google.td/search*
// @match                    *://*.google.tg/search*
// @match                    *://*.google.co.th/search*
// @match                    *://*.google.com.tj/search*
// @match                    *://*.google.tl/search*
// @match                    *://*.google.tm/search*
// @match                    *://*.google.tn/search*
// @match                    *://*.google.to/search*
// @match                    *://*.google.com.tr/search*
// @match                    *://*.google.tt/search*
// @match                    *://*.google.com.tw/search*
// @match                    *://*.google.co.tz/search*
// @match                    *://*.google.com.ua/search*
// @match                    *://*.google.co.ug/search*
// @match                    *://*.google.co.uk/search*
// @match                    *://*.google.com.uy/search*
// @match                    *://*.google.co.uz/search*
// @match                    *://*.google.com.vc/search*
// @match                    *://*.google.co.ve/search*
// @match                    *://*.google.vg/search*
// @match                    *://*.google.co.vi/search*
// @match                    *://*.google.com.vn/search*
// @match                    *://*.google.vu/search*
// @match                    *://*.google.ws/search*
// @match                    *://*.google.rs/search*
// @match                    *://*.google.co.za/search*
// @match                    *://*.google.co.zm/search*
// @match                    *://*.google.co.zw/search*
// @match                    *://*.google.cat/search*
// @include                  https://auth0.openai.com
// @connect                  api.binjie.fun
// @connect                  api.openai.com
// @connect                  api11.gptforlove.com
// @connect                  cdn.jsdelivr.net
// @connect                  chat-share.kudoai.workers.dev
// @connect                  chatai.mixerbox.com
// @connect                  chatgpt.com
// @connect                  fanyi.sogou.com
// @connect                  googlegpt.io
// @connect                  raw.githubusercontent.com
// @connect                  localhost
// @connect                  127.0.0.1
// @require                  https://cdn.jsdelivr.net/npm/@kudoai/[email protected]/dist/chatgpt.min.js#sha256-XyrLEk81vg4/zgOeYDWtugRQKJvrWEefACp0EfwMVHE=
// @require                  https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.2.0/crypto-js.min.js#sha256-dppVXeVTurw1ozOPNE3XqhYmDJPOosfbKQcHyQSE58w=
// @require                  https://cdn.jsdelivr.net/npm/[email protected]/dist/index.min.js#sha256-S7ltnVPzgKyAGBlBG4wQhorJqYTehj5WQCrADCKJufE=
// @require                  https://cdn.jsdelivr.net/gh/adamlui/ai-web-extensions@30ce038/assets/js/chatbot/components/buttons.js#sha256-bam0MF6WM+Lyt5Wgop3rfHPYZHmOPXq4ZxbUXort2+M=
// @require                  https://cdn.jsdelivr.net/gh/adamlui/ai-web-extensions@30ce038/assets/js/chatbot/components/icons.js#sha256-nAcuQD4FVFzUN1pS6pOjw2V3IvkDwYV+P3m5IN8nsdo=
// @require                  https://cdn.jsdelivr.net/gh/adamlui/ai-web-extensions@787f07b/assets/js/chatbot/components/menus.js#sha256-YaV3USVJvvChUwPjoF2jwRwbKVBdjoFfLS6ThEnZchE=
// @require                  https://cdn.jsdelivr.net/gh/adamlui/ai-web-extensions@30ce038/assets/js/chatbot/components/replyBubble.js#sha256-zN/oMInc63biUtC+qNfP48vntQiEw2zyCIVszeBxLmg=
// @require                  https://cdn.jsdelivr.net/gh/adamlui/ai-web-extensions@30ce038/assets/js/chatbot/components/tooltip.js#sha256-/xPw7DnS8F9dBH/s0ffMrErweHgFBeKpkUM4tUDy4vo=
// @require                  https://cdn.jsdelivr.net/gh/adamlui/ai-web-extensions@787f07b/assets/js/chatbot/lib/api.js#sha256-9mC3x8yqdVp3WpWMreBsTzunXu1+VSm0bXvVOQz3ODs=
// @require                  https://cdn.jsdelivr.net/gh/adamlui/ai-web-extensions@30ce038/assets/js/chatbot/lib/feedback.js#sha256-ri8OzNa/8sQINDn7bW84F2OuVYZxubMSm/Zpli/cPnQ=
// @require                  https://cdn.jsdelivr.net/gh/adamlui/ai-web-extensions@30ce038/assets/js/chatbot/lib/log.js#sha256-puXwoSKgog6EhgDzlJrAzMnGRM6kLMTT8NF0jYncIt8=
// @require                  https://cdn.jsdelivr.net/gh/adamlui/ai-web-extensions@30ce038/assets/js/chatbot/lib/prompts.js#sha256-Z9QsKpAcqSoclxyPTkPfe8/K3s9eDrbSYgumr/GYyLc=
// @require                  https://cdn.jsdelivr.net/gh/adamlui/ai-web-extensions@30ce038/assets/js/chatbot/lib/session.js#sha256-cH2e3l2bZQRekQHxaeSShdNguqD41evEOkMrrVIydHQ=
// @require                  https://cdn.jsdelivr.net/gh/adamlui/ai-web-extensions@30ce038/assets/js/chatbot/lib/themes.js#sha256-NSiOkXoRC/fF8zdmnbIk9XL5tKWWP5MU2NOfdJ9G0NU=
// @require                  https://cdn.jsdelivr.net/gh/adamlui/ai-web-extensions@30ce038/assets/js/chatbot/lib/ui.js#sha256-+UnPhc4zxrdWEuLU8PFrnpAW9TFS2c1hOGbcby2HlEU=
// @require                  https://cdn.jsdelivr.net/gh/adamlui/ai-web-extensions@30ce038/assets/js/chatbot/lib/userscript.js#sha256-DTD+Tj/9angBw8/Q4e8PMz2SBwueqvNzeY8PwZlMgbs=
// @require                  https://cdn.jsdelivr.net/gh/adamlui/userscripts@ff2baba/assets/js/lib/css.js/dist/css.min.js#sha256-zf9s8C0cZ/i+gnaTIUxa0+RpDYpsJVlyuV5L2q4KUdA=
// @require                  https://cdn.jsdelivr.net/gh/adamlui/userscripts@ff2baba/assets/js/lib/dom.js/dist/dom.min.js#sha256-nTc2by3ZAz6AR7B8fOqjloJNETvjAepe15t2qlghMDo=
// @require                  https://cdn.jsdelivr.net/npm/[email protected]/dist/generate-ip.min.js#sha256-PI9snFGy1YvX4fiT8SJ01RveRSa6vZujEJxk8y/jvVs=
// @require                  https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js#sha256-g3pvpbDHNrUrveKythkPMF2j/J7UFoHbUyFQcFe1yEY=
// @require                  https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.js#sha256-n0UwfFeU7SR6DQlfOmLlLvIhWmeyMnIDp/2RmVmuedE=
// @require                  https://cdn.jsdelivr.net/npm/[email protected]/dist/contrib/auto-render.min.js#sha256-e1fUJ6xicGd9r42DgN7SzHMzb5FJoWe44f4NbvZmBK4=
// @require                  https://cdn.jsdelivr.net/npm/[email protected]/marked.min.js#sha256-Ffq85bZYmLMrA/XtJen4kacprUwNbYdxEKd0SqhHqJQ=
// @require                  https://unpkg.com/[email protected]/build/Tone.js#sha256-4pCVL6Q9mnp4AYKoPG/M9E15y3riy6EC7x8rnZgSTiI=
// @resource ggptIconBlack   https://cdn.jsdelivr.net/gh/KudoAI/googlegpt@9db3bda/assets/images/icons/googlegpt/black/icon64.png.b64#sha256-yiTqggYRNsWcJtyIUDzFrPqrL3yeTaPCrEGAW0QFuPM=
// @resource ggptIconWhite   https://cdn.jsdelivr.net/gh/KudoAI/googlegpt@9db3bda/assets/images/icons/googlegpt/white/icon64.png.b64#sha256-BYRq92cF5knykaKnmNi1U4CrwBC/jK1V+MGfH4NGui4=
// @resource ggptLSlogo      https://cdn.jsdelivr.net/gh/KudoAI/googlegpt@9db3bda/assets/images/logos/googlegpt/flat/black-green/logo480x64.png.b64#sha256-fzSZhLVQQolCLWYr/h29NWfR1Yl4glHv1TcsveYYv+U=
// @resource ggptDSlogo      https://cdn.jsdelivr.net/gh/KudoAI/googlegpt@9db3bda/assets/images/logos/googlegpt/flat/white-green/logo480x64.png.b64#sha256-3qRdGKhF3pojDqVVh/5kODIg7QvYbbLf4zFkEh5xoGc=
// @resource hljsCSS         https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/base16/railscasts.min.css#sha256-nMf0Oxaj3sYJiwGCsfqNpGnBbcofnzk+zz3xTxtdLEQ=
// @resource rpgCSS          https://cdn.jsdelivr.net/gh/adamlui/ai-web-extensions@727feff/assets/styles/rising-particles/dist/gray.min.css#sha256-48sEWzNUGUOP04ur52G5VOfGZPSnZQfrF3szUr4VaRs=
// @resource rpwCSS          https://cdn.jsdelivr.net/gh/adamlui/ai-web-extensions@727feff/assets/styles/rising-particles/dist/white.min.css#sha256-6xBXczm7yM1MZ/v0o1KVFfJGehHk47KJjq8oTktH4KE=
// @grant                    GM_getValue
// @grant                    GM_setValue
// @grant                    GM_deleteValue
// @grant                    GM_cookie
// @grant                    GM_registerMenuCommand
// @grant                    GM_unregisterMenuCommand
// @grant                    GM_getResourceText
// @grant                    GM_xmlhttpRequest
// @grant                    GM.xmlHttpRequest
// @noframes
// @homepageURL              https://www.googlegpt.io
// @supportURL               https://support.googlegpt.io
// @contributionURL          https://github.com/sponsors/KudoAI
// ==/UserScript==

// Dependencies:
// ✓ chatgpt.js (https://chatgpt.js.org) © 2023–2026 KudoAI & contributors under the MIT license
// ✓ dom.js © 2023–2026 Adam Lui under the MIT license
// ✓ generate-ip (https://generate-ip.org) © 2024–2026 Adam Lui & contributors under the MIT license
// ✓ highlight.js (https://highlightjs.org) © 2006 Ivan Sagalaev under the BSD 3-Clause license
// ✓ KaTeX (https://katex.org) © 2013–2020 Khan Academy & other contributors under the MIT license
// ✓ Marked (https://marked.js.org) © 2018+ MarkedJS © 2011–2018 Christopher Jeffrey under the MIT license
// ✓ Tone.js (https://tonejs.github.io) © 2014–2026 Yotam Mann under the MIT license

// Documentation: https://docs.googlegpt.io

(async () => {
    'use strict'

    // 用戶自定義本地端點
    const localHostEndpoint = 'http://localhost:5555/v1/chat/completions'

    // Init DATA
    window.env = {
        browser: { language: chatgpt.getUserLanguage() },
        scriptManager: {
            name: (() => { try { return GM_info.scriptHandler } catch (err) { return 'unknown' }})(),
            version: (() => { try { return GM_info.version } catch (err) { return 'unknown' }})()
        }
    } ; ['Chromium', 'Firefox', 'Chrome', 'Edge', 'Brave', 'Mobile'].forEach(platform =>
        env.browser[`is${ platform == 'Firefox' ? 'FF' : platform }`] = chatgpt.browser['is' + platform]())
    Object.assign(env.browser, { get isCompact() { return innerWidth <= 480 }})
    env.userLocale = location.hostname.endsWith('.com') ? 'us' : location.hostname.split('.').pop()
    env.scriptManager.supportsStreaming = /Tampermonkey|ScriptCat/.test(env.scriptManager.name)
    env.scriptManager.supportsTooltips = env.scriptManager.name == 'Tampermonkey'
                                      && parseInt(env.scriptManager.version.split('.')[0]) >= 5
    window.inputEvents = {} ; ['down', 'move', 'up'].forEach(action =>
        inputEvents[action] = ( window.PointerEvent ? 'pointer' : env.browser.isMobile ? 'touch' : 'mouse' ) + action)
    window.xhr = typeof GM != 'undefined' && GM.xmlHttpRequest || GM_xmlhttpRequest
    window.app = {
        version: GM_info.script.version, chatgptjsVer: /chatgpt\.js@([\d.]+)/.exec(GM_info.scriptMetaStr)[1],
        commitHashes: {
            app: '08a8bf1', // for cached <app|messages>.json
            aiweb: '02c1241' // for cached ai-chat-apis.json5 + <code-languages|katex-delimiters|sogou-tts-lang-codes>.json
        }
    }
    app.urls = { resourceHost: `https://cdn.jsdelivr.net/gh/KudoAI/googlegpt@${app.commitHashes.app}` }
    const remoteData = {
        app: await new Promise(resolve => xhr({
            method: 'GET', url: `${app.urls.resourceHost}/assets/data/app.json`,
            onload: ({ responseText }) => resolve(JSON.parse(responseText))
        })),
        msgs: await new Promise(resolve => {
            const msgHostDir = `${app.urls.resourceHost}/greasemonkey/_locales/`,
                  msgLocaleDir = `${ env.browser.language ? env.browser.language.replace('-', '_') : 'en' }/`
            let msgHref = `${ msgHostDir + msgLocaleDir }messages.json`, msgXHRtries = 0
            function fetchMsgs() { xhr({ method: 'GET', url: msgHref, onload: handleMsgs })}
            function handleMsgs(resp) {
                try { // to return localized messages.json
                    const msgs = JSON.parse(resp.responseText), flatMsgs = {}
                    for (const key in msgs)  // remove need to ref nested keys
                        if (typeof msgs[key] == 'object' && 'message' in msgs[key])
                            flatMsgs[key] = msgs[key].message
                    resolve(flatMsgs)
                } catch (err) { // if bad response
                    msgXHRtries++ ; if (msgXHRtries == 3) return resolve({}) // try original/region-stripped/EN only
                    msgHref = env.browser.language.includes('-') && msgXHRtries == 1 ? // if regional lang on 1st try...
                        msgHref.replace(/(_locales\/[^_]+)_[^_]+(\/)/, '$1$2') // ...strip region before retrying
                            : `${msgHostDir}en/messages.json` // else use default English messages
                    fetchMsgs()
                }
            }
            fetchMsgs()
        })
    }
    Object.assign(app, { ...remoteData.app, urls: { ...app.urls, ...remoteData.app.urls }, msgs: remoteData.msgs })
    app.urls.aiwebAssets = app.urls.aiwebAssets.replace('@latest', `@${app.commitHashes.aiweb}`)
    app.alerts = {
        waitingResponse:  `${app.msgs.alert_waitingFor} ${app.name} ${app.msgs.alert_response}...`,
        login:            `${app.msgs.alert_login} @ `,
        checkCloudflare:  `${app.msgs.alert_checkCloudflare} @ `,
        tooManyRequests:  `${app.msgs.alert_tooManyRequests}.`,
        parseFailed:      `${app.msgs.alert_parseFailed}.`,
        proxyNotWorking:  `${app.msgs.mode_proxy} ${app.msgs.alert_notWorking}.`,
        apiNotWorking:    `API ${app.msgs.alert_notWorking}.`,
        suggestProxy:     `${app.msgs.alert_try} ${app.msgs.alert_switchingOn} ${app.msgs.mode_proxy}`,
        suggestDiffAPI:   `${app.msgs.alert_try} ${app.msgs.alert_selectingDiff} API`,
        suggestOpenAI:    `${app.msgs.alert_try} ${app.msgs.alert_switchingOff} ${app.msgs.mode_proxy}`
    }
    app.katexDelimiters = await new Promise(resolve => xhr({ // used in show.reply()
        method: 'GET', onload: ({ responseText }) => resolve(JSON.parse(responseText)),
        url: `${app.urls.aiwebAssets}/data/katex-delimiters.json`
    }))
    window.apis = Object.assign(Object.create(null), await new Promise(resolve => xhr({
        method: 'GET', onload: ({ responseText }) => resolve(Object.fromEntries(
            Object.entries(JSON5.parse(responseText)).filter(([, api]) => !api.disabled))),
        url: `${app.urls.aiwebAssets}/data/ai-chat-apis.json5`
    })))
    apis.OpenAI = { method: 'POST', endpoint: localHostEndpoint, endpoints: { completions: localHostEndpoint } } // 確保 OpenAI 進入點存在
    apis.AIchatOS.userID = `#/chat/${Date.now()}`

    // Init SETTINGS
    app.config ??= {}
    window.settings = {
        load(...keys) {
            keys.flat().forEach(key =>
                app.config[key] = processKey(key, GM_getValue(`${app.configKeyPrefix}_${key}`, undefined)))
            function processKey(key, val) {
                const ctrl = settings.controls?.[key]
                if (val != undefined && ( // validate stored val
                        (ctrl?.type == 'toggle' && typeof val != 'boolean')
                     || (ctrl?.type == 'slider' && isNaN(parseFloat(val)))
                )) val = undefined
                return val ?? (ctrl?.defaultVal ?? (ctrl?.type == 'slider' ? 100 : false))
            }
        },
        save(key, val) { GM_setValue(`${app.configKeyPrefix}_${key}`, val) ; app.config[key] = val },
        typeIsEnabled(key) {
            const reInvertFlags = /disabled|hidden/i
            return reInvertFlags.test(key) // flag in control key name
                && !reInvertFlags.test(this.controls[key]?.label || '') // but not in label msg key name
                    ? !app.config[key] : app.config[key] // so invert since flag reps opposite type state, else don't
        }
    }
    settings.load('debugMode') ; log.debug('Initializing settings...')
    Object.assign(settings, { controls: { // displays top-to-bottom, left-to-right in Settings modal
        proxyAPIenabled: { type: 'toggle', icon: 'sunglasses', defaultVal: false,
            label: app.msgs.menuLabel_proxyAPImode,
            helptip: app.msgs.helptip_proxyAPImode },
        preferredAPI: { type: 'modal', icon: 'lightning', defaultVal: false,
            label: `${app.msgs.menuLabel_preferred} API`,
            helptip: app.msgs.helptip_preferredAPI },
        streamingDisabled: { type: 'toggle', icon: 'signalStream', defaultVal: false,
            label: app.msgs.mode_streaming,
            helptip: app.msgs.helptip_streamingMode },
        autoGet: { type: 'toggle', icon: 'speechBalloonLasso', defaultVal: true,
            label: app.msgs.menuLabel_autoAnswer,
            helptip: app.msgs.helptip_autoGetAnswers },
        autoSummarize: { type: 'toggle', icon: 'summarize', defaultVal: false,
            label: app.msgs.menuLabel_autoSummarizeResults,
            helptip: app.msgs.helptip_autoSummarizeResults },
        autoFocusChatbarDisabled: { type: 'toggle', mobile: false, icon: 'caretsInward', defaultVal: true,
            label: app.msgs.menuLabel_autoFocusChatbar,
            helptip: app.msgs.helptip_autoFocusChatbar },
        autoScroll: { type: 'toggle', mobile: false, icon: 'arrowsDown', defaultVal: false,
            label: `${app.msgs.mode_autoScroll} (${app.msgs.menuLabel_whenStreaming})`,
            helptip: app.msgs.helptip_autoScroll },
        rqDisabled: { type: 'toggle', icon: 'speechBalloons', defaultVal: false,
            label: `${app.msgs.menuLabel_show} ${app.msgs.menuLabel_relatedQueries}`,
            helptip: app.msgs.helptip_showRelatedQueries },
        prefixEnabled: { type: 'toggle', icon: 'slash', defaultVal: false,
            label: `${app.msgs.menuLabel_require} "/" ${app.msgs.menuLabel_beforeQuery}`,
            helptip: app.msgs.helptip_prefixMode },
        suffixEnabled: { type: 'toggle', icon: 'questionMark', defaultVal: false,
            label: `${app.msgs.menuLabel_require} "?" ${app.msgs.menuLabel_afterQuery}`,
            helptip: app.msgs.helptip_suffixMode },
        widerSidebar: { type: 'toggle', mobile: false, icon: 'widescreenTall', defaultVal: false,
            label: app.msgs.menuLabel_widerSidebar,
            helptip: app.msgs.helptip_widerSidebar },
        stickySidebar: { type: 'toggle', mobile: false, icon: 'sidebar', defaultVal: false,
            label: app.msgs.menuLabel_stickySidebar,
            helptip: app.msgs.helptip_stickySidebar },
        anchored: { type: 'toggle', mobile: false, icon: 'anchor', defaultVal: false,
            label: app.msgs.mode_anchor,
            helptip: app.msgs.helptip_anchorMode },
        bgAnimationsDisabled: { type: 'toggle', icon: 'sparkles', defaultVal: false,
            label: `${app.msgs.menuLabel_background} ${app.msgs.menuLabel_animations}`,
            helptip: app.msgs.helptip_bgAnimations },
        fgAnimationsDisabled: { type: 'toggle', icon: 'sparkles', defaultVal: false,
            label: `${app.msgs.menuLabel_foreground} ${app.msgs.menuLabel_animations}`,
            helptip: app.msgs.helptip_fgAnimations },
        replyLang: { type: 'prompt', icon: 'languageChars',
            label: app.msgs.menuLabel_replyLanguage,
            helptip: app.msgs.helptip_replyLanguage },
        scheme: { type: 'modal', icon: 'scheme',
            label: app.msgs.menuLabel_colorScheme,
            helptip: app.msgs.helptip_colorScheme },
        debugMode: { type: 'toggle', icon: 'bug', defaultVal: false,
            label: app.msgs.mode_debug,
            helptip: app.msgs.helptip_debugMode },
        about: { type: 'modal', icon: 'questionMarkCircle',
            label: `${app.msgs.menuLabel_about} ${app.name}...` }
    }})
    Object.assign(app.config, {
        lineHeightRatio: env.browser.isMobile ? 1.357 : 1.375, maxFontSize: 24, minFontSize: 11 })
    settings.load(Object.keys(settings.controls), 'expanded', 'fontSize', 'minimized', 'aiSafetyWarned')
    if (!app.config.replyLang) settings.save('replyLang', env.browser.language) // init reply language if unset
    if (!app.config.fontSize) settings.save('fontSize', env.browser.isMobile ? 14 : 14.0423) // init reply font size if unset
    if (!env.scriptManager.supportsStreaming) settings.save('streamingDisabled', true) // disable Streaming in unspported env
    log.debug(`Success! app.config = ${log.prettifyObj(app.config)}`)

    // Define UI functions

    window.update = {

        appBottomPos() { app.div.style.bottom = `${ app.config.minimized ? 22 - app.div.offsetHeight : -33 }px` },

        appStyle() { // used in toggle.animations() + update.scheme() + main's app init
            const { scheme: appScheme } = env.ui.app,
                  isParticlizedDS = appScheme == 'dark' && !app.config.bgAnimationsDisabled
            modals.stylize() // update modal styles
            if (!app.styles?.isConnected) document.head.append(app.styles ||= dom.create.style())
            app.styles.textContent = (

                // Init vars
               `:root {
                  --app-bg-color-light-scheme: white ; --app-bg-color-dark-scheme: #1f1f1f ;
                  --pre-bg-color-light-scheme: #b7b7b736 ; --pre-bg-color-dark-scheme: #3a3a3a ;
                  --reply-header-bg-color-light-scheme: #d7d4d4 ;
                  --reply-header-bg-color-dark-scheme: ${ !isParticlizedDS ? '#545454' : '#0e0e0e24' };
                  --reply-header-fg-color-light-scheme: white ; --reply-header-fg-color-dark-scheme: white ;
                  --chatbar-btn-color-light-scheme: lightgrey ; --chatbar-btn-color-dark-scheme: #fff ;
                  --chatbar-btn-hover-color-light-scheme: #638ed4 ; --chatbar-btn-hover-color-dark-scheme: #fff ;
                  --font-color-light-scheme: #4e4e4e ; --font-color-dark-scheme: #e3e3e3 ;
                  --app-border: ${ isParticlizedDS ? 'none'
                        : `1px solid #${ appScheme == 'light' ? 'dadce0' : '3b3b3b' }`};
                  --app-gradient-bg: linear-gradient(180deg, ${
                        appScheme == 'dark' ? '#99a8a6 -245px, black 185px' : '#b6ebff -163px, white 65px' }) ;
                  --app-anchored-shadow: 0 15px 52px rgb(0,0,${ appScheme == 'light' ? '7,0.06' : '11,0.22' }) ;
                  --app-transition: opacity 0.5s ease, transform 0.5s ease, /* for 1st fade-in */
                                    bottom 0.1s cubic-bezier(0,0,0.2,1), /* smoothen Anchor Y min/restore */
                                    width 0.167s cubic-bezier(0,0,0.2,1) ; /* smoothen Anchor X expand/shrink */
                  --standby-btn-zoom: scale(1.015) ; --standby-btn-transition: all 0.18s ease ;
                  --btn-transition: transform 0.15s ease, /* for hover-zoom */
                                    opacity 0.25s ease-in-out ; /* + btn-zoom-fade-out + .app-hover-only shows */
                  --font-size-slider-thumb-transition: transform 0.05s ease ; /* for hover-zoom */
                  --reply-pre-transition: max-height 0.167s cubic-bezier(0, 0, 0.2, 1) ; /* for Anchor changes */
                  --rq-transition: opacity 0.55s ease, transform 0.1s ease !important ; /* for fade-in + hover-zoom */
                  --fade-in-transition: opacity 0.4s ease ;
                  --fade-in-less-transition: opacity 0.2s ease } /* used by Font Size slider + Pin menu */`

                // Animations
             + `.fade-in {
                    opacity: 0 ; transform: translateY(10px) ;
                    transition: var(--fade-in-less-transition) ;
                       -webkit-transition: var(--fade-in-transition) ;
                       -moz-transition: var(--fade-in-transition) ;
                       -o-transition: var(--fade-in-transition) ;
                       -ms-transition: var(--fade-in-transition)
                }
                .fade-in-less {
                    opacity: 0 ;
                    transition: var(--fade-in-less-transition) ;
                       -webkit-transition: var(--fade-in-less-transition) ;
                       -moz-transition: var(--fade-in-less-transition) ;
                       -o-transition: var(--fade-in-less-transition) ;
                       -ms-transition: var(--fade-in-less-transition)
                }
                .fade-in.active, .fade-in-less.active { opacity: 1 ; transform: translateY(0) }
                @keyframes btn-zoom-fade-out {
                    0% { opacity: 1 } 55% { opacity: 0.25 ; transform: scale(1.85) }
                    75% { opacity: 0.05 ; transform: scale(2.15) } 100% { opacity: 0 ; transform: scale(6.85) }}
                @keyframes icon-scroll { 0% { transform: translateX(0) } 100% { transform: translateX(-14px) }}
                @keyframes pulse { 0%, to { opacity: 1 } 50% { opacity: .5 }}
                @keyframes rotate { from { transform: rotate(0deg) } to { transform: rotate(360deg) }}
                @keyframes spinY { 0% { transform: rotateY(0deg) } 100% { transform: rotateY(360deg) }}`

                // Main styles
             + `.no-user-select {
                   -webkit-user-select: none ; -moz-user-select: none ;
                   -ms-user-select: none ; user-select: none }
                .no-mobile-tap-outline { outline: none ; -webkit-tap-highlight-color: transparent }
                /* stylize scrollbars in Chromium/Safari */
                    #${app.slug} *::-webkit-scrollbar { width: 7px }
                    #${app.slug} *::-webkit-scrollbar-thumb { background: #cdcdcd }
                    #${app.slug} *::-webkit-scrollbar-thumb:hover { background: #a6a6a6 }
                    #${app.slug} *::-webkit-scrollbar-track { background: none }
                #${app.slug} * { scrollbar-width: thin } /* make scrollbars thin in Firefox */
                .cursor-overlay { /* for fontSizeSlider.createAppend() drag listeners to show resize cursor everywhere */
                    position: fixed ; top: 0 ; left: 0 ; width: 100% ; height: 100% ;
                    z-index: 9999 ; cursor: ew-resize }
                #${app.slug} { /* main app div */
                    color: var(--font-color-${appScheme}-scheme) ;
                    background: var(--app-bg-color-${appScheme}-scheme) ;
                    position: sticky ; z-index: 101 ; padding: ${ env.browser.isFF ? 20 : 22 }px 26px 6px 26px ;
                    ${ !env.browser.isMobile ? 'margin-top: 55px ;' : '' } /* add top margin on desktop */
                    border-radius: 8px ; height: fit-content ;
                    width: ${ // hard-width to prevent Google's flex-wrap moving app to bottom
                        env.browser.isMobile ? 'auto' : '319px' };
                    ${ env.browser.isMobile ? 'margin: 8px 0 8px' : 'margin-bottom: 30px' }; /* add vertical margins */
                    word-wrap: break-word ; white-space: pre-wrap ;
                    transition: var(--app-transition) ;
                       -webkit-transition: var(--app-transition) ; -moz-transition: var(--app-transition) ;
                       -o-transition: var(--app-transition) ; -ms-transition: var(--app-transition) }
                #${app.slug}:has(.${app.slug}-alert) { /* app alerts */
                    border: var(--app-border) ; background-image: var(--app-gradient-bg) }
                #${app.slug} .app-hover-only { /* hide app-hover-only elems */
                    position: absolute ; left: -9999px ; opacity: 0 ; /* using position to support transitions */
                    width: 0 } /* to support width calcs */
                /* show app-hover-only elems on hover + Font Size button when slider visible */
                #${app.slug}:hover .app-hover-only, #${app.slug}:active .app-hover-only,
                    #${app.slug}:has([id$=font-size-slider-track].active) [id$=font-size-btn] {
                        position: relative ; left: auto ; width: auto ; opacity: 1 }
                #${app.slug} p { margin: 0 }
                #${app.slug} .alert-link {
                    color: ${ appScheme == 'light' ? '#190cb0' : 'white ; text-decoration: underline' }}
                .${app.slug}-name {
                    font-size: 1.35rem ; font-weight: 700 ; text-decoration: none ;
                    color: ${ appScheme == 'dark' ? 'white' : 'black' } !important }
                .byline { /* header byline */
                    font-size: 12px ; margin-left: 7px ; color: #aaa ;
                  --byline-transition: 0.15s ease-in-out ; transition: var(--byline-transition) ;
                       -webkit-transition: var(--byline-transition) ; -moz-transition: var(--byline-transition) ;
                       -o-transition: var(--byline-transition) ; -ms-transition: var(--byline-transition) }
                .byline a, .byline a:visited { color: #aaa ; text-decoration: none !important }
                .byline a:hover {
                    color: ${ appScheme == 'dark' ? 'white' : 'black' };
                    transition: var(--byline-transition) ;
                       -webkit-transition: var(--byline-transition) ; -moz-transition: var(--byline-transition) ;
                       -o-transition: var(--byline-transition) ; -ms-transition: var(--byline-transition) }
                #${app.slug}-header-btns { display: flex ; direction: rtl ; gap: 2px ; float: right }
                .${app.slug}-header-btn {
                    float: right ; cursor: pointer ; position: relative ; top: 6px ;
                    ${ appScheme == 'dark' ? 'fill: white ; stroke: white' : 'fill: #adadad ; stroke: #adadad' }}
                .${app.slug}-header-btn:hover svg { /* highlight/zoom header button on hover */
                    ${ appScheme == 'dark' ? 'fill: #d9d9d9 ; stroke: #d9d9d9' : 'fill: black ; stroke: black' };
                    ${ env.browser.isMobile ? '' : 'transform: scale(1.285)' }}
                ${ app.config.fgAnimationsDisabled ? '' :
                   `.${app.slug}-header-btn, .${app.slug}-header-btn svg { /* smooth header button fade-in + hover-zoom */
                    transition: var(--btn-transition) ;
                       -webkit-transition: var(--btn-transition) ; -moz-transition: var(--btn-transition) ;
                       -o-transition: var(--btn-transition) ; -ms-transition: var(--btn-transition) }`}
                .${app.slug}-header-btn:active {
                    ${ appScheme == 'dark' ? 'fill: #999999 ; stroke: #999999'
                                           : 'fill: #638ed4 ; stroke: #638ed4' }}
                #${app.slug}-logo, .${app.slug}-header-btn svg {
                    filter: drop-shadow(${ appScheme == 'dark' ? '#7171714d 10px' : '#aaaaaa21 7px' } 7px 3px) }
                #${app.slug} .loading {
                    padding-bottom: 15px ; color: #b6b8ba ; fill: #b6b8ba ;
                    animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite }
                #${app.slug} section.loading { padding: 15px 0 14px 5px } /* pad loading status when sending replies */
                #${app.slug}-font-size-slider-track {
                    width: 98% ; height: 7px ; margin: 3px auto ${ env.browser.isCompact ? -6 : -11 }px ;
                    padding: 15px 0 ; background-color: #ccc ; box-sizing: content-box; background-clip: content-box ;
                   -webkit-background-clip: content-box }
                #${app.slug}-font-size-slider-track::before { /* to add finger cursor to unpadded core only */
                    content: "" ; position: absolute ; top: 10px ; left: 0 ; right: 0 ;
                    height: calc(100% - 20px) ; cursor: pointer }
                #${app.slug}-font-size-slider-tip {
                    z-index: 1 ; position: absolute ; bottom: 20px ;
                    border-left: 4.5px solid transparent ; border-right: 4.5px solid transparent ;
                    border-bottom: 16px solid #ccc }
                #${app.slug}-font-size-slider-thumb {
                    z-index: 2 ; width: 7px ; height: 25px ; border-radius: 30% ; position: relative ;
                    top: -7.5px ; cursor: ew-resize ;
                    background-color: ${ appScheme == 'dark' ? 'white' : '#4a4a4a' };
                  --shadow: rgba(0,0,0,0.21) 1px 1px 9px 0 ;
                        box-shadow: var(--shadow) ; -webkit-box-shadow: var(--shadow) ; -moz-box-shadow: var(--shadow) ;
                    ${ app.config.fgAnimationsDisabled ? '' : `transition: var(--font-size-slider-thumb-transition)
                       -webkit-transition: var(--font-size-slider-thumb-transition) ;
                       -moz-transition: var(--font-size-slider-thumb-transition) ;
                       -o-transition: var(--font-size-slider-thumb-transition) ;
                       -ms-transition: var(--font-size-slider-thumb-transition)` }}
                ${ env.browser.isMobile ? '' : `#${app.slug}-font-size-slider-thumb:hover { transform: scale(1.125) }`}
                .${app.slug}-standby-btns { margin: 20px 0 -14px }
                .${app.slug}-standby-btn {
                  --skew: skew(-13deg) ; --counter-skew: skew(13deg) ; border-radius: 8px ;
                  --content-color: ${ appScheme == 'dark' ? 'white' : 'black' };
                    display: flex ; align-items: center ; justify-content: center ; gap: 8px ;
                    width: 90% ; height: 51px ; margin-bottom: 9px ; padding: 12px 0 ;
                    cursor: pointer ; transform: var(--skew) ; border: 1px solid var(--content-color) ;
                    background: none ; box-shadow: #aaaaaa12 7px 7px 3px 0px ; color: var(--content-color) ;
                    ${ app.config.fgAnimationsDisabled ? ''
                        : `will-change: transform ;
                           transition: var(--standby-btn-transition) ;
                               -webkit-transition: var(--standby-btn-transition) ;
                               -moz-transition: var(--standby-btn-transition) ;
                               -o-transition: var(--standby-btn-transition) ;
                               -ms-transition: var(--standby-btn-transition)` }}
                .${app.slug}-standby-btn:hover {
                    color: var(--content-color) ; border-radius: 2px ; transform: var(--skew) var(--standby-btn-zoom) }
                .${app.slug}-standby-btn > span { transform: var(--counter-skew) }
                .${app.slug}-standby-btn > svg {
                    position: relative ; stroke: var(--content-color) ; fill: stroke: var(--content-color) ;
                    transform: var(--counter-skew) }
                .${app.slug}-standby-btn:nth-child(odd) { margin-right: 10% }
                .${app.slug}-standby-btn:nth-child(even) { margin-left: 10% ; margin-bottom: 19px }
                .${app.slug}-standby-btn:first-of-type svg { /* Query button icon */
                    width: 11px ; height: 11px ; top: -1.5px ; right: -1.5px }
                .${app.slug}-standby-btn:nth-of-type(2) svg { /* Summarize button icon */
                    width: 17.5px ; height: 17.5px }`

              // AI reply elem styles
             + `#${app.slug} .reply-tip {
                    content: "" ; position: relative ; border: 7px solid transparent ;
                    float: left ; margin: 3px -15px 0 0 ;
                    left: ${ env.browser.isMobile ? 12 : 6 }px ; /* positioning */
                    border-bottom-style: solid ; border-bottom-width: 20px ; border-top: 0 ; border-bottom-color:
                        ${ // hide reply tip for terminal aesthetic
                            isParticlizedDS ? '#0000' : `var(--reply-header-bg-color-${appScheme}-scheme)` }}
                #${app.slug} .reply-header {
                    display: flex ; align-items: center ; position: relative ; box-sizing: border-box ; width: 100% ;
                    top: 16px ; padding: 16px 14px ; height: 18px ; border-radius: 12px 12px 0 0 ;
                    ${ appScheme == 'light' ? 'border-bottom: 1px solid white'
                          : isParticlizedDS ? 'border: 1px solid ; border-bottom-color: transparent' : '' };
                    background: var(--reply-header-bg-color-${appScheme}-scheme) ;
                    color:      var(--reply-header-fg-color-${appScheme}-scheme) ;
                    fill:       var(--reply-header-fg-color-${appScheme}-scheme) ;
                    stroke:     var(--reply-header-fg-color-${appScheme}-scheme) }
                #${app.slug} .reply-header-txt { flex-grow: 1 ; font-size: 12px ; font-family: monospace }
                #${app.slug} .reply-header-btns { margin: 3.5px -5px 0 }
                #${app.slug} .reply-pre {
                    font-size: ${app.config.fontSize}px ; white-space: pre-wrap ; min-width: 0 ;
                    line-height: ${ app.config.fontSize * app.config.lineHeightRatio }px ; overscroll-behavior: contain ;
                    position: relative ; z-index: 1 ; /* allow top-margin to overlap header in light scheme */
                    margin: ${ appScheme == 'light' ? 13 : 15 }px 0 0 0 ; padding: 1em 1em 0 1em ;
                    border-radius: 0 0 12px 12px ; overflow: auto ;
                    ${ app.config.bgAnimationsDisabled ? // classic opaque bg
                        `background: var(--pre-bg-color-${appScheme}-scheme) ;
                         color: var(--font-color-${appScheme}-scheme)`
                    : appScheme == 'dark' ? // slightly tranluscent bg
                        'background: #2b3a40cf ; color: var(--font-color-dark-scheme) ; border: 1px solid white'
                    : /* light scheme */ `background: var(--pre-bg-color-light-scheme) ;
                            color: var(--font-color-light-scheme) ; border: none` };
                    ${ app.config.fgAnimationsDisabled ? '' : // smoothen Anchor mode expand/shrink
                        `transition: var(--reply-pre-transition) ;
                            -webkit-transition: var(--reply-pre-transition) ;
                            -moz-transition: var(--reply-pre-transition) ;
                            -o-transition: var(--reply-pre-transition) ;
                            -ms-transition: var(--reply-pre-transition)` }}
                #${app.slug} .reply-pre a, #${app.slug} .reply-pre a:visited { color: #4495d4 }
                #${app.slug} .reply-pre a:hover { color: ${ appScheme == 'dark' ? 'white' : '#28a017' }}
                #${app.slug} .code-header {
                    display: flex ; direction: rtl ; gap: 9px ; align-items: center ;
                    height: 11px ; margin: 3px -2px 0 }
                #${app.slug} .code-header btn { cursor: pointer }
                #${app.slug} .code-header svg { height: 13px ; width: 13px ; fill: white }`

              // Rendered markdown styles
             + `#${app.slug} .reply-pre h1 { font-size: 1.25em }
                #${app.slug} .reply-pre h2 { font-size: 1.1em } /* size headings */
                #${app.slug} .reply-pre ol { margin: -5px 0 -8px 7px ; padding-left: 1.58em }
                #${app.slug} .reply-pre ul { /* reduce v-spacing, indent */
                    margin: -10px 0 -6px ; padding-left: 1.5em }
                #${app.slug} .reply-pre li { /* reduce v-spacing, show hollow bullets */
                    margin: -8px 0 ; list-style: circle }
                #${app.slug} .reply-pre ul ul { margin-top: 0 } /* push sub-lists down */
                #${app.slug} .reply-pre ul ul > li { list-style: disc } /* fill sub-bullets */`

              // Rendered code styles
             + `#${app.slug} ${GM_getResourceText('hljsCSS') // color code
                    .replace(/\/\*[^*]+\*\//g, '') // strip comments
                    .trim().replace(/([,}])(.)(?![^{]*\})/g, `$1#${app.slug} $2`)} /* scope selectors to app */
                #${app.slug} pre:has(> code) { padding: 0 } /* remove padded border around code blocks */
                #${app.slug} code { font-size: 0.85em } /* shrink code vs. regular text */`

              // Rendered math styles
             + '.katex-html { display: none } /* hide unrendered math */'

              // Chatbar styles
             + `#${app.slug}-chatbar {
                    border: solid 1px ${ isParticlizedDS ? '#aaa' : appScheme == 'dark' ? '#777' : '#555' };
                    border-radius: 12px 13px 12px 0 ; margin: 12px 0 15px 0 ; padding: 13px 55px 13px 10px ;
                    position: relative ; z-index: 555 ; color: ${ appScheme == 'dark' ? '#eee' : '#222' };
                    height: 16px ; max-height: 200px ; resize: none ;
                    background: ${ appScheme == 'light' ? '#eeeeee9e'
                        : `#515151${ app.config.bgAnimationsDisabled ? '' : '9e' }`};
                    ${ appScheme == 'dark' ? '' :
                        `--chatbar-inset-shadow: 0 1px 2px rgba(15,17,17,0.1) inset ;
                        box-shadow: var(--chatbar-inset-shadow) ; -webkit-box-shadow: var(--chatbar-inset-shadow) ;
                       -moz-box-shadow: var(--chatbar-inset-shadow) ;` }
                        transition: box-shadow 0.15s ease ;
                           -webkit-transition: box-shadow 0.15s ease ; -moz-transition: box-shadow 0.15s ease ;
                           -o-transition: box-shadow 0.15s ease ; -ms-transition: box-shadow 0.15s ease }
                ${ isParticlizedDS ? '' : ` /* chatbar hover styles */
                    #${app.slug}-chatbar:hover:not(:focus),
                    div:has(.${app.slug}-chatbar-btn:hover) #${app.slug}-chatbar:not(:focus) {
                        outline: ${ appScheme == 'light' ? 'black' : 'white' } auto 5px ;
                      --chatbar-hover-inset-shadow: 0 ${
                            appScheme == 'dark' ? '3px 2px' : '1px 7px' } rgba(15,17,17,0.15) inset ;
                        box-shadow: var(--chatbar-hover-inset-shadow) ;
                       -webkit-box-shadow: var(--chatbar-hover-inset-shadow) ;
                       -moz-box-shadow: var(--chatbar-hover-inset-shadow) ;
                        transition: box-shadow 0.25s ease ;
                           -webkit-transition: box-shadow 0.25s ease ; -moz-transition: box-shadow 0.25s ease ;
                           -o-transition: box-shadow 0.25s ease ; -ms-transition: box-shadow 0.25s ease }`}
                #${app.slug}-chatbar:focus-visible { /* fallback outline chatbar + reduce inset shadow on focus */
                    outline: -webkit-focus-ring-color auto 1px ;
                    ${ isParticlizedDS ? '' :
                        `--inset-shadow: 0 ${ appScheme == 'dark' ? '3px -1px' : '1px 2px' } rgba(0,0,0,0.3) inset ;
                        box-shadow: var(--inset-shadow) ;
                       -webkit-box-shadow: var(--inset-shadow) ; -moz-box-shadow: var(--inset-shadow)` }
                }
                .${app.slug}-chatbar-btn {
                    z-index: 560 ; border: none ; float: right ; position: relative ; background: none ;
                    cursor: pointer ; bottom: ${ env.browser.isFF ? 47 : 52 }px ;
                    transform: scale(1.05) ; margin-right: 3px ; /* zoom 'em a bit */
                    color:  var(--chatbar-btn-color-${appScheme}-scheme) ;
                    fill:   var(--chatbar-btn-color-${appScheme}-scheme) ;
                    stroke: var(--chatbar-btn-color-${appScheme}-scheme)
                }
                .${app.slug}-chatbar-btn:hover {
                    color:  var(--chatbar-btn-hover-color-${appScheme}-scheme) ;
                    fill:   var(--chatbar-btn-hover-color-${appScheme}-scheme) ;
                    stroke: var(--chatbar-btn-hover-color-${appScheme}-scheme)
                }`

              // Related Queries styles
             + `.${app.slug}-related-queries {
                    display: flex ; flex-wrap: wrap ; width: 100% ; margin-bottom: 19px ; padding: 0 5px }
                .${app.slug}-related-query {
                    font-size: ${ env.browser.isMobile ? 1 : 0.81 }em ; cursor: pointer ; will-change: transform ;
                    box-sizing: border-box ; width: fit-content ; max-width: 100% ; /* confine to outer div */
                    margin: 5px 12px 7px 0 ; padding: 8px 12px 8px 13px ;
                    color: ${ appScheme == 'dark' ? ( app.config.bgAnimationsDisabled ? '#ccc' : '#f2f2f2' )
                                                  : '#767676' };
                    background: ${ appScheme == 'dark' ? '#7e7e7e4f' : '#fdfdfdb0' };
                    border: 1px solid ${ appScheme == 'dark' ? (
                        app.config.bgAnimationsDisabled ? '#5f5f5f' : '#777' ) : '#d2d2d2' };
                    border-radius: 0 13px 12px 13px ; flex: 0 0 auto ;
                  --rq-shadow: 1px 4px 8px -6px rgba(169,169,169,0.75) ; box-shadow: var(--rq-shadow) ;
                       -webkit-box-shadow: var(--rq-shadow) ; -moz-box-shadow: var(--rq-shadow) ;
                    ${ app.config.fgAnimationsDisabled ? '' : `transition: var(--rq-transition) ;
                       -webkit-transition: var(--rq-transition) ; -moz-transition: var(--rq-transition) ;
                       -o-transition: var(--rq-transition) ; -ms-transition: var(--rq-transition)` }}
                .${app.slug}-related-query:hover, .${app.slug}-related-query:focus {
                    ${ app.config.fgAnimationsDisabled ? '' : 'transform: scale(1.055) !important ;' }
                    background: ${ appScheme == 'dark' ? '#a2a2a270'
                        : '#dae5ffa3 ; color: #000000a8 ; border-color: #a3c9ff' }}
                .${app.slug}-related-query svg { /* related query icon */
                    float: left ; margin: -0.09em 6px 0 0 ;
                    color: ${ appScheme == 'dark' ? '#aaa' : '#c1c1c1' }}`

              // Footer styles
             + `#${app.slug} footer {
                    position: relative ; text-align: right ; font-size: 0.75rem ; line-height: 1.43em ;
                    right: -54px ; margin: ${ env.browser.isFF ? 1 : -2 }px -32px 12px }
                #${app.slug} footer * { color: #aaa ; text-decoration: none }
                #${app.slug} footer a:hover { color: ${ appScheme == 'dark' ? 'white' : 'black' }}`

              // Notif styles
             + `.chatgpt-notif {
                    fill: white ; stroke: white ; font-size: 25px !important ; padding: 13px 14px 13px 13px !important }
                .notif-close-btn { display: none !important } /* hide notif close btn */
                .${app.slug}-menu {
                    position: absolute ; z-index: 12250 ;
                    padding: 3.5px 5px !important ; font-family: "Source Sans Pro", sans-serif ; font-size: 12px }`

              // Menu styles
             + `.${app.slug}-menu ul { margin: 0 ; padding: 0 ; list-style: none }
                .${app.slug}-menu-item { padding: 0 5px ; line-height: 20.5px }
                .${app.slug}-menu-item:not(.${app.slug}-menu-header):hover {
                    cursor: pointer ; background: white ; color: black ; fill: black }`

              // Wider Sidebar styles
             + `#${app.slug}.wider { min-width: 455px }
                #${app.slug}.wider ~ div { min-width: 508px } /* expand side snippets */
                #center_col:has(~ div #${app.slug}.wider),
                    #center_col:has(~ div #${app.slug}.wider) div {
                        max-width: 516px } /* shrink center column/children */
                div:has(> #${app.slug}.wider) { /* shift sidebar left to align w/ skinnier center column */
                    position: relative ; left: -136px }`

              // Sticky Sidebar styles
             + `#${app.slug}.sticky { position: sticky ; top: 87px }
                #${app.slug}.sticky ~ * { display: none }` // hide sidebar contents

              // Anchor Mode styles
             + `#${app.slug}.anchored {
                    position: fixed ; bottom: -7px ; right: 35px ; width: 388px ; z-index: 8888 ;
                    border: var(--app-border) ; box-shadow: var(--app-anchored-shadow) ;
                    ${ app.config.bgAnimationsDisabled ? `background: var(--app-bg-color-${appScheme}-scheme)`
                                                   : 'background-image: var(--app-gradient-bg)' }}
                #${app.slug}.expanded { width: 528px !important }
                #${app.slug}.anchored .anchored-hidden { display: none } /* hide non-Anchor elems in mode */
                #${app.slug}:not(.anchored) .anchored-only { display: none } /* hide Anchor elems outside mode */`

              // Touch device styles
             + `@media (hover: none) {
                    #${app.slug} .app-hover-only { /* show app-hover-only elems */
                        position: relative ; left: auto ; width: auto ; opacity: 1 }
                    #${app.slug} *:hover { transform: none !important } /* disable hover fx */
                }`

              // Phone styles
             + `@media screen and (max-width: 480px) {
                    #${app.slug} #${app.slug}-logo { /* header logo... */
                        top: 0 ; width: calc(100% - 154px) } /* remove y-pos, widen till btns */
                    #${app.slug} .byline { display: none !important } /* hide byline */
                    #${app.slug} [class*=reply-tip] { display: none } /* hide reply tip */
                    .${app.slug}-related-queries { padding: 0 } /* remove RQ parent padding */
                }`
            )
            themes.apply(app.config.theme)
        },

        bylineVisibility() {
            if (env.browser.isCompact) return // since byline hidden by app.styles

            // Init header elems
            const headerElems = { byline: app.div.querySelector('.byline') }
            if (!headerElems.byline) return // since in loading state
            Object.assign(headerElems, {
                appPrefix: app.div.querySelector('#app-prefix'),
                btns: app.div.querySelectorAll(`#${app.slug}-header-btns > btn`),
                logo: app.div.querySelector(`#${app.slug}-logo`)
            })

            // Calc/store widths of app/x-padding + header elems
            const appDivStyle = getComputedStyle(app.div)
            const widths = {
                appDiv: app.div.getBoundingClientRect().width,
                appDivXpadding: parseFloat(appDivStyle.paddingLeft) + parseFloat(appDivStyle.paddingRight)
            }
            Object.entries(headerElems).forEach(([key, elem]) => widths[key] = dom.get.computedWidth(elem))

            // Hide/show byline based on space available
            const availSpace = widths.appDiv - widths.appDivXpadding - widths.appPrefix - widths.logo - widths.btns -16
            Object.assign(headerElems.byline.style, widths.byline > availSpace ?
                { position: 'absolute', left: '-9999px', opacity: 0 } // hide using position to support transition
              : { position: '', left: '', opacity: 1 } // show
            )
        },

        chatbarWidth() {
            const chatbar = app.div.querySelector(`#${app.slug}-chatbar`)
            if (chatbar) chatbar.style.width = `${
                env.browser.isMobile ? 81.4
              : app.config.anchored ? ( app.config.expanded ? 87.4 : 83.3 )
              : app.config.widerSidebar ? ( env.ui.site.hasSidebar ? 85.4 : 85.9 )
                                    : ( env.ui.site.hasSidebar ? 79.3 : 80.1 )}%`
        },

        async footerContent() {

            // Init advertisers data
            const advertisersData = await get.json(
                'https://cdn.jsdelivr.net/gh/KudoAI/ads-library/advertisers/index.json'
            ).catch(err => log.error(err.message)) ; if (!advertisersData) return

            // Pick random advertiser
            let chosenAdvertiser
            for (const [advertiser, details] of shuffle(applyBoosts(Object.entries(advertisersData))))
                if (details.campaigns.text) { chosenAdvertiser = advertiser ; break }
            if (!chosenAdvertiser) return

            // Init chosen advertiser's campaigns data
            const campaignsData = await get.json(
                `https://cdn.jsdelivr.net/gh/KudoAI/ads-library/advertisers/${chosenAdvertiser}/text/campaigns.json`
            ).catch(err => log.error(err.message)) ; if (!campaignsData) return

            // Init vars for ad selection
            const reAppName = new RegExp(app.name.toLowerCase(), 'i')
            const currentDate = (() => { // in YYYYMMDD format
                const today = new Date(), year = today.getFullYear(),
                      month = String(today.getMonth() + 1).padStart(2, '0'),
                      day = String(today.getDate()).padStart(2, '0')
                return year + month + day
            })() ; let adSelected = false

            // Select random, active campaign
            for (const [campaignName, campaign] of shuffle(applyBoosts(Object.entries(campaignsData)))) {
                const campaignIsActive = campaign.active && (!campaign.endDate || currentDate <= campaign.endDate)
                if (!campaignIsActive) continue // to next campaign since campaign inactive

                // Select random active group
                for (const [groupName, adGroup] of shuffle(applyBoosts(Object.entries(campaign.adGroups)))) {

                    // Skip disqualified groups
                    if ( // self-group for other apps
                        /^self$/i.test(groupName) && !reAppName.test(campaignName)
                        || ( // non-self group for this app
                            reAppName.test(campaignName) && !/^self$/i.test(groupName))
                        || adGroup.active == false // group explicitly disabled
                        || adGroup.targetBrowsers && // target browser(s) exist...
                            !adGroup.targetBrowsers.some( // ...but doesn't match user's
                                browser => new RegExp(browser, 'i').test(navigator.userAgent))
                        || adGroup.targetLocations && ( // target locale(s) exist...
                            // ...but user locale is missing or excluded
                            !env.userLocale || !adGroup.targetLocations.some(
                                loc => loc.includes(env.userLocale) || env.userLocale.includes(loc)))
                    ) continue // to next group

                    // Filter out inactive ads, pick random active one
                    const activeAds = adGroup.ads.filter(ad => ad.active != false)
                    if (!activeAds.length) continue // to next group since no ads active
                    const chosenAd = activeAds[Math.floor(chatgpt.randomFloat() * activeAds.length)]

                    // Build destination URL
                    let destinationURL = chosenAd.destinationURL || adGroup.destinationURL || campaign.destinationURL || ''
                    if (destinationURL.includes('http')) { // insert UTM tags
                        const [baseURL, queryString] = destinationURL.split('?'),
                              queryParams = new URLSearchParams(queryString || '')
                        queryParams.set('utm_source', app.name.toLowerCase())
                        queryParams.set('utm_content', 'app_footer_link')
                        destinationURL = `${baseURL}?${queryParams.toString()}`
                    }

                    // Update footer content
                    const newFooterContent = destinationURL ? dom.create.anchor(destinationURL)
                                                            : dom.create.elem('span')
                    app.footerContent.replaceWith(newFooterContent) ; app.footerContent = newFooterContent
                    app.footerContent.textContent = chosenAd.text
                    app.footerContent.setAttribute('title', chosenAd.tooltip || '')
                    adSelected = true ; break // out of group loop
                }
                if (adSelected) break // out of campaign loop
            }

            function shuffle(list) {
                let currentIdx = list.length, tempValue, randomIdx
                while (currentIdx != 0) { // elements remain to be shuffled
                    randomIdx = Math.floor(chatgpt.randomFloat() * currentIdx) ; currentIdx -=1
                    tempValue = list[currentIdx] ; list[currentIdx] = list[randomIdx] ; list[randomIdx] = tempValue
                } return list
            }

            function applyBoosts(list) {
                let boostedList = [...list],
                    boostedListLength = boostedList.length -1 // for applying multiple boosts
                list.forEach(([name, data]) => { // check for boosts
                    if (data.boost) { // boost flagged entry's selection probability
                        const boostPercent = parseInt(data.boost) / 100
                        const entriesNeeded = Math.ceil(boostedListLength / (1 - boostPercent)) // total entries needed
                                            * boostPercent -1 // reduced to boosted entries needed
                        for (let i = 0 ; i < entriesNeeded ; i++) boostedList.push([name, data]) // saturate list
                        boostedListLength += entriesNeeded // update for subsequent calculations
                }})
                return boostedList
            }
        },

        replyPrefix() {
            const firstP = app.div.querySelector('pre p') ; if (!firstP) return
            const prefixNeeded = env.ui.app.scheme == 'dark'
                && !app.config.bgAnimationsDisabled && !/shuffle|summarize/.test(get.reply.src)
            const prefixExists = firstP.textContent.startsWith('>> ')
            if (prefixNeeded && !prefixExists) firstP.prepend('>> ')
            else if (!prefixNeeded && prefixExists) firstP.textContent = firstP.textContent.replace(/^>> /, '')
        },

        risingParticles() {
            ['sm', 'med', 'lg'].forEach(size =>
                document.querySelectorAll(`[id*=particles-${size}]`).forEach(particlesDiv =>
                    particlesDiv.id = app.config.bgAnimationsDisabled ? `particles-${size}-off`
                    : `${ env.ui.app.scheme == 'dark' ? 'white' : 'gray' }-particles-${size}`))
        },

        rqVisibility() {
            const rqsDiv = app.div.querySelector(`.${app.slug}-related-queries`)
            if (rqsDiv) // update visibility based on latest setting
                rqsDiv.style.display = app.config.rqDisabled || app.config.anchored ? 'none' : 'flex'
        },

        scheme(newScheme) {
            env.ui.app.scheme = newScheme ; logos.googlegpt.update() ; icons.googlegpt.update() ; update.appStyle()
            update.risingParticles() ; update.replyPrefix() ; modals.settings.updateSchemeStatus()
        }
    }

    // Define TOGGLE functions

    window.toggle = {

        anchorMode(state = '') {
            const prevState = app.config.anchored // for restraining notif if no change from Pin menu 'Sidebar' click
            let sidebarModeToggled = false // to extend this notif duration

            // Save new state + disable incompatible Sidebar modes
            if (state == 'on' || !state && !app.config.anchored) {
                settings.save('anchored', true)
                ;['sticky', 'wider'].forEach(mode => {
                    if (app.config[`${mode}Sidebar`]) { toggle.sidebar(mode) ; sidebarModeToggled = true }})
            } else {
                settings.save('anchored', false)
                if (app.config.expanded) { toggle.expandedMode('off') ; sidebarModeToggled = true }
            }
            if (prevState == app.config.anchored) return

            // Apply changed state to UI
            app.div.classList.toggle('anchored', app.config.anchored)
            update.rqVisibility() ; replyBubble.updateMaxHeight() ; update.chatbarWidth()
            if (getComputedStyle(app.div).transitionProperty.includes('width')) // update byline visibility
                app.div.addEventListener('transitionend', function onTransitionEnd(event) { // ...after width transition
                    if (event.propertyName == 'width') {
                        update.bylineVisibility() ; app.div.removeEventListener('transitionend', onTransitionEnd)
            }})
            if (modals.settings.get()) { // update visual state of Settings toggle
                const anchorToggle = document.querySelector('[id*=anchor] input')
                if (anchorToggle.checked != app.config.anchored) modals.settings.toggle.switch(anchorToggle)
            }
            feedback.notify(`${app.msgs.mode_anchor} ${menus.toolbar.state.words[+app.config.anchored]}`,
                undefined, sidebarModeToggled ? 2.75 : undefined) // +1s duration if conflicting mode notif shown
        },

        animations(layer) {
            const configKey = `${layer}AnimationsDisabled`
            settings.save(configKey, !app.config[configKey])
            update.appStyle() ; if (layer == 'bg') { update.risingParticles() ; update.replyPrefix() }
            if (layer == 'fg' && modals.settings.get()) { // toggle ticker-scroll of About status label
                const aboutStatusLabel = document.querySelector('#about-settings-entry > span > div')
                aboutStatusLabel.innerHTML = modals.settings.aboutContent[
                    app.config.fgAnimationsDisabled ? 'short' : 'long']
                aboutStatusLabel.style.float = app.config.fgAnimationsDisabled ? 'right' : ''
            }
            feedback.notify(`${settings.controls[configKey].label} ${menus.toolbar.state.words[+!app.config[configKey]]}`)
        },

        autoGen(mode) {
            const validModes = ['get', 'summarize'], modeKey = `auto${log.toTitleCase(mode)}`
            let conflictingModeToggled = false // to extend this notif duration
            settings.save(modeKey, !app.config[modeKey])
            if (app.config[modeKey]) { // this Auto-Gen mode toggled on, disable other one + Manual-Gen + do it
                const otherMode = validModes[+(mode == validModes[0])]
                if (app.config[`auto${log.toTitleCase(otherMode)}`]) {
                    toggle.autoGen(otherMode) ; conflictingModeToggled = true }
                ['prefix', 'suffix'].forEach(mode => {
                    if (app.config[`${mode}Enabled`]) { toggle.manualGen(mode) ; conflictingModeToggled = true }})
                app.div.querySelector(
                    `button[class*=standby]:has(svg.${ mode == 'get' ? 'send' : 'summarize' })`)?.click()
            }
            feedback.notify(`${settings.controls[modeKey].label} ${menus.toolbar.state.words[+app.config[modeKey]]}`,
                undefined, conflictingModeToggled ? 2.75 : undefined) // +1s duration if conflicting mode notif shown
            if (modals.settings.get()) { // update visual state of Settings toggle
                const modeToggle = document.querySelector(`[id*=${modeKey}] input`)
                if (modeToggle.checked != app.config[modeKey]) modals.settings.toggle.switch(modeToggle)
            }
        },

        expandedMode(state = '') {
            const toExpand = state == 'on' || !state && !app.config.expanded
            settings.save('expanded', toExpand) ; app.div.classList.toggle('expanded', toExpand)
            if (app.config.minimized) toggle.minimized('off') // since user wants to see stuff
            update.chatbarWidth()
            if (getComputedStyle(app.div).transitionProperty.includes('width')) // update byline visibility
                app.div.addEventListener('transitionend', function onTransitionEnd(event) { // ...after width transition
                    if (event.propertyName == 'width') {
                        update.bylineVisibility() ; app.div.removeEventListener('transitionend', onTransitionEnd)
            }})
            const expandBtn = app.div.querySelector(`#${app.slug}-arrows-btn`)
            if (expandBtn) expandBtn.firstChild.replaceWith(
                icons.create({ key: `arrowsDiagonal${ app.config.expanded ? 'In' : 'Out' }`, size: 17 }))
        },

        manualGen(mode) { // Prefix/Suffix modes
            const modeKey = `${mode}Enabled`
            let autoGenToggled = false // to extend this notif duration
            settings.save(modeKey, !app.config[modeKey])
            if (app.config[modeKey]) // Manual-Gen toggled on, disable all Auto-Gen
                ['get', 'summarize'].forEach(mode => {
                    if (app.config[`auto${log.toTitleCase(mode)}`]) { toggle.autoGen(mode) ; autoGenToggled = true }})
            feedback.notify(`${settings.controls[modeKey].label} ${menus.toolbar.state.words[+app.config[modeKey]]}`,
                undefined, autoGenToggled ? 2.75 : undefined) // +1s duration if conflicting mode notif shown)
            if (modals.settings.get()) { // update visual state of Settings toggle
                const modeToggle = document.querySelector(`[id*=${modeKey}] input`)
                if (modeToggle.checked != app.config[modeKey]) modals.settings.toggle.switch(modeToggle)
            }
        },

        minimized(state = '') {
            const toMinimize = state == 'on' || !state && !app.config.minimized
            settings.save('minimized', toMinimize)
            const chevronBtn = app.div.querySelector('[id$=chevron-btn]')
            if (chevronBtn) { // update icon
                chevronBtn.textContent = ''
                chevronBtn.append(icons.create({ key: `chevron${ app.config.minimized ? 'Up' : 'Down' }`,
                    size: 22, style: 'position: relative ; top: -1px' }))
                chevronBtn.onclick = () => {
                    if (app.div.querySelector('[id$=font-size-slider-track]')?.classList.contains('active'))
                        fontSizeSlider.toggle('off')
                    toggle.minimized()
                }
            }
            update.appBottomPos() // toggle visual minimization
            tooltip.toggle('off') // hide lingering tooltip
        },

        proxyMode() {
            settings.save('proxyAPIenabled', !app.config.proxyAPIenabled)
            feedback.notify(`${app.msgs.menuLabel_proxyAPImode} ${menus.toolbar.state.words[+app.config.proxyAPIenabled]}`)
            menus.toolbar.refresh()
            if (modals.settings.get()) { // update visual states of Settings toggles
                const proxyToggle = document.querySelector('[id*=proxy] input'),
                      preferredAPIentry = document.querySelector('[id*=preferredAPI]'),
                      streamingToggle = document.querySelector('[id*=streaming] input')
                if (proxyToggle.checked != app.config.proxyAPIenabled) // Proxy state out-of-sync (from using toolbar menu)
                    modals.settings.toggle.switch(proxyToggle)
                preferredAPIentry.classList.toggle('active', app.config.proxyAPIenabled)
                preferredAPIentry.style.pointerEvents = app.config.proxyAPIenabled ? '' : 'none'
                if (streamingToggle.checked && !app.config.proxyAPIenabled // Streaming checked but OpenAI mode
                    || // ...or Streaming unchecked but enabled in Proxy mode
                        !streamingToggle.checked && app.config.proxyAPIenabled && !app.config.streamingDisabled)
                            modals.settings.toggle.switch(streamingToggle)
            }
            const apiBeacon = app.div.querySelector(`#${app.slug} .api-btn`)
            if (apiBeacon) apiBeacon.style.pointerEvents = app.config.proxyAPIenabled ? '' : 'none'
            if (app.div.querySelector(`.${app.slug}-alert`)) // re-send query if user alerted
                get.reply({ msgs: app.msgChain, src: get.reply.src })
        },

        relatedQueries() {
            settings.save('rqDisabled', !app.config.rqDisabled)
            update.rqVisibility()
            if (!app.config.rqDisabled && !app.div.querySelector(`.${app.slug}-related-queries`)) // get related queries for 1st time
                get.related(app.msgChain[app.msgChain.length - 1]?.content || searchQuery)
                    .then(queries => show.related(queries))
                    .catch(err => { log.error(err.message) ; api.tryNew(get.related) })
            replyBubble.updateMaxHeight()
            feedback.notify(`${app.msgs.menuLabel_relatedQueries} ${menus.toolbar.state.words[+!app.config.rqDisabled]}`)
        },

        sidebar(mode, state = '') {
            const configKeyName = mode + 'Sidebar',
                  prevStickyState = app.config.stickySidebar // for hiding notif if no change from Pin menu 'Sidebar' click
            let anchorModeDisabled = false // to extend this notif duration

            // Save new state + disable incompatible Anchor mode
            if (state == 'on' || !state && !app.config[configKeyName]) { // toggle on
                if (mode == 'sticky' && app.config.anchored) { toggle.anchorMode() ; anchorModeDisabled = true }
                settings.save(configKeyName, true)
            } else settings.save(configKeyName, false)

            // Apply new state to UI
            app.div.classList.toggle(mode, app.config[configKeyName])
            replyBubble.updateMaxHeight() ; update.bylineVisibility() ; update.chatbarWidth()
            if (mode == 'wider') // toggle icons everywhere
            document.querySelectorAll(`#${app.slug} svg.widescreenTall, #${app.slug} svg.widescreenWide`)
                .forEach(icon => icon.replaceWith(
                    icons.create({ key: `widescreen${ app.config.widerSidebar ? 'Wide' : 'Tall' }`})))
            if (modals.settings.get()) { // update visual state of Settings toggles
                const sidebarToggle = document.querySelector(`[id*=${mode}] input`)
                if (sidebarToggle.checked != app.config[`${mode}Sidebar`]) modals.settings.toggle.switch(sidebarToggle)
            }

            // Notify of mode change
            if (mode == 'sticky' && prevStickyState == app.config.stickySidebar) return
            feedback.notify(
                `${ app.msgs[`menuLabel_${mode}Sidebar`] || log.toTitleCase(mode) + ' Sidebar' } ${
                    menus.toolbar.state.words[+app.config[configKeyName]]}`,
                undefined, anchorModeDisabled  ? 2.75 : undefined // +1s duration if conflicting mode notif shown
            )
        },

        streaming() {
            if (!env.scriptManager.supportsStreaming) { // alert userscript manager unsupported, suggest TM/SC
                const scLink = (
                    env.browser.isFF ?
                        'https://addons.mozilla.org/firefox/addon/scriptcat/'
                  : env.browser.isEdge ?
                        'https://microsoftedge.microsoft.com/addons/detail/scriptcat/liilgpjgabokdklappibcjfablkpcekh'
                      : 'https://chromewebstore.google.com/detail/scriptcat/ndcooeababalnlpkfedmmbbbgkljhpjf' )
                modals.alert(
                    `${settings.controls.streamingDisabled.label} ${app.msgs.alert_unavailable}`,
                    `${settings.controls.streamingDisabled.label} ${app.msgs.alert_isOnlyAvailFor}`
                        + ` <a target="_blank" rel="noopener" href="https://tampermonkey.net">Tampermonkey</a> ${
                                app.msgs.about_and}`
                        + ` <a target="_blank" rel="noopener" href="${scLink}">ScriptCat</a>.`
                        + ` (${app.msgs.alert_userscriptMgrNoStream}.)`
                )
            } else if (!app.config.proxyAPIenabled) { // alert OpenAI API unsupported, suggest Proxy Mode
                let msg = `${settings.controls.streamingDisabled.label} `
                        + `${app.msgs.alert_isCurrentlyOnlyAvailBy} `
                        + `${app.msgs.alert_switchingOn} ${app.msgs.mode_proxy}. `
                        + `(${app.msgs.alert_openAIsupportSoon}!)`
                const switchPhrase = app.msgs.alert_switchingOn
                msg = msg.replace(switchPhrase, `<a class="alert-link" href="#">${switchPhrase}</a>`)
                const alert = modals.alert(`${app.msgs.mode_streaming} ${app.msgs.alert_unavailable}`, msg)
                alert.querySelector('[href="#"]').onclick = () => {
                    alert.querySelector('.modal-close-btn')?.click() ; toggle.proxyMode() }
            } else { // functional toggle
                settings.save('streamingDisabled', !app.config.streamingDisabled)
                feedback.notify(`${settings.controls.streamingDisabled.label} ${
                                   menus.toolbar.state.words[+!app.config.streamingDisabled]}`)
            }
        }
    }

    // Define GET functions

    window.get = {

        json(url) { // requires lib/json5.js
            return new Promise((resolve, reject) => {
                let retryCnt = 0 ; getData(url)

                function getData(currentURL) {
                    xhr({
                        method: 'GET', url: currentURL,
                        onload: ({ responseText, status }) => {
                            if (status >= 300 && status != 404) {
                                const errType = status < 400 ? 'REDIRECT' : status < 500 ? 'CLIENT' : 'SERVER'
                                return reject(new Error(`${errType} ERROR: ${status}`))
                            }
                            try {
                                resolve(currentURL.endsWith('.json') ? JSON.parse(responseText)
                                                                     : JSON5.parse(responseText))
                            } catch (err) {
                                retryCnt < 1 ? tryAltDataFormat() : reject(new Error(`PARSE ERROR: ${err.message}`)) }
                        },
                        onerror: err => {
                            retryCnt < 1 ? tryAltDataFormat() : reject(new Error(`LOAD ERROR: ${err.message}`)) }
                    })
                }

                function tryAltDataFormat() {
                    retryCnt++ ; getData(url.endsWith('.json') ? url + '5' : url.slice(0, -1)) }
            })
        },
        async related(query) {

            // Init API attempt props
            get.related.status = 'waiting'
            get.related.triedAPIs = get.related.triedAPIs || []
            get.related.attemptCnt = get.related.attemptCnt || 1

            // Pick API
            get.related.api = 'OpenAI'

            // Init OpenAI key
            app.config.openAIkey = 'local-dummy-key'

            // Augment query
            const reqAPI = get.related.api
            let rqPrompt = prompts.create('relatedQueries', { prevQuery: query, mods: 'all' })
            rqPrompt = prompts.augment(rqPrompt, { api: reqAPI })

            // Get related queries
            return new Promise(resolve => {
                const reqMethod = 'POST'
                const reqData = api.createReqData(reqAPI, [{ role: 'user', content: rqPrompt }])
                const xhrConfig = {
                    headers: api.createHeaders(reqAPI), method: reqMethod, responseType: 'text',
                    onerror: err => { log.error(err) ; api.tryNew(get.related) },
                    onload: resp => api.process.text(resp, { caller: get.related, callerAPI: reqAPI }).then(resolve),
                    url: localHostEndpoint
                }
                if (reqMethod == 'POST') xhrConfig.data = JSON.stringify(reqData)
                else if (reqMethod == 'GET') xhrConfig.url += `?q=${reqData}`
                xhr(xhrConfig)
            })
        },

        async reply({ msgs, src = null }) {
            get.reply.src = src ; show.reply.updatedAPIinHeader = false

            // Show loading status
            const rqDiv = app.div.querySelector(`.${app.slug}-related-queries`),
                  loadingSpinner = icons.create({ key: 'arrowsCyclic', size: 15 })
            let loadingElem
            loadingSpinner.style.cssText = 'position: relative ; top: 2px ; margin-right: 6px'
            if (app.div.querySelector('.reply-pre')) { // reply exists, show where chatbar was
                if (!/regen|summarize/i.test(src)) rqDiv?.remove() // clear RQs to re-get later
                app.div.querySelector('footer').textContent = '' // clear footer
                loadingElem = app.div.querySelector('section')
                loadingElem.style.margin = '3px 0 -10px'
                loadingElem.textContent = app.alerts.waitingResponse
                loadingSpinner.style.animation = 'rotate 1s infinite cubic-bezier(0, 1.05, 0.79, 0.44)' // faster ver
            } else { // replace app div w/ alert
                feedback.appAlert('waitingResponse')
                loadingElem = app.div.querySelector(`.${app.slug}-alert`)
                loadingSpinner.style.animation = 'rotate 2s infinite linear' // slower ver
            }
            loadingElem.classList.add('loading', 'no-user-select')
            loadingElem.prepend(loadingSpinner)

            // Init msgs
            msgs = structuredClone(msgs) // deep copy to not affect app.msgChain
            if (msgs.length > 3) msgs = msgs.slice(-3) // keep last 3 only
            msgs.forEach(msg => { // trim agent msgs
                if (msg.role == 'assistant' && msg.content.length > 250)
                    msg.content = msg.content.substring(0, 250) + '...' })

            // Init API attempt props
            get.reply.status = 'waiting'
            get.reply.triedAPIs = get.reply.triedAPIs || []
            get.reply.attemptCnt = get.reply.attemptCnt || 1

            // Pick API
            get.reply.api = 'OpenAI'

            // Init OpenAI key
            app.config.openAIkey = 'local-dummy-key'

            // Augment query
            const reqAPI = get.reply.api, lastUserMsg = msgs[msgs.length - 1]
            lastUserMsg.content = prompts.augment(lastUserMsg.content, { api: reqAPI, caller: get.reply })

            // Get/show answer from AI
            const reqMethod = 'POST'
            const reqData = api.createReqData(reqAPI, msgs)
            const xhrConfig = {
                headers: api.createHeaders(reqAPI), method: reqMethod,
                responseType: app.config.streamingDisabled || !app.config.proxyAPIenabled ? 'text' : 'stream',
                onerror: err => { log.error(err)
                    if (!app.config.proxyAPIenabled)
                        feedback.appAlert(!app.config.openAIkey ? 'login' : ['OpenAI', 'apiNotWorking', 'suggestProxy'])
                    else api.tryNew(get.reply)
                },
                onload: resp => api.process.text(resp, { caller: get.reply, callerAPI: reqAPI }),
                onloadstart: resp => api.process.stream(resp, { caller: get.reply, callerAPI: reqAPI }),
                url: localHostEndpoint
            }
            if (reqMethod == 'POST') xhrConfig.data = JSON.stringify(reqData)
            else if (reqMethod == 'GET') xhrConfig.url += `?q=${reqData}`
            xhr(xhrConfig)

            // Get/show Related Queries if enabled/missing/on 1st get.reply() attempt only
            if (!app.config.rqDisabled && !rqDiv && get.reply.attemptCnt == 1)
                get.related(app.msgChain[app.msgChain.length - 1].content)
                    .then(queries => show.related(queries))
                    .catch(err => { log.error(err.message) ; api.tryNew(get.related) })

            update.footerContent()
        }
    }

    // Define SHOW functions

    window.show = {

        async codeCornerBtns() {
            if (!app.div.querySelector('code')) return

            // Init general language data
            window.codeLangData ||= await get.json(`${app.urls.aiwebAssets}/data/code-languages.json`)
                .catch(err => log.error(err.message))

            // Add buttons to every block
            app.div.querySelectorAll('code').forEach(block => {
                if (block.querySelector('[id$=copy-btn]')) return
                const codeBtnsDiv = dom.create.elem('div', { class: `code-header` })

                // Create Copy button
                const copyBtn = buttons.reply.bubble.copy.cloneNode(true)
                copyBtn.style.cssText = '' // clear app header btn styles
                Object.entries(buttons.reply.bubble.copy.listeners).forEach(
                    ([eventType, handler]) => copyBtn[eventType] = handler)

                // Create Download button
                const downloadBtn = dom.create.elem('btn', { id: `${app.slug}-download-btn` })
                const downloadSVGs = {
                    download: icons.create({ key: 'download' }), downloaded: icons.create({ key: 'checkmarkDouble' })}
                Object.entries(downloadSVGs).forEach(([svgType, svg]) => {
                    svg.id = `${app.slug}-${svgType}-icon`
                    ;['width', 'height'].forEach(attr => svg.setAttribute(attr, 15))
                })
                downloadBtn.append(downloadSVGs.download)
                downloadBtn.onclick = ({ currentTarget }) => { // download code, update icon + tooltip status
                    if (!downloadBtn.firstChild.matches('[id$=download-icon]')) return // since clicking on DL'd icon

                    // Update cursor/icon/tooltip
                    downloadBtn.style.cursor = 'default' // remove finger
                    downloadBtn.firstChild.replaceWith(downloadSVGs.downloaded.cloneNode(true)) // change to DL'd icon
                    tooltip.update(currentTarget) // to 'Code downloaded!'
                    setTimeout(() => { // restore icon/cursor/tooltip after a bit
                        downloadBtn.firstChild.replaceWith(downloadSVGs.download.cloneNode(true))
                        downloadBtn.style.cursor = 'pointer'
                        if (downloadBtn.matches(':hover')) // restore tooltip
                            downloadBtn.dispatchEvent(new Event('mouseenter'))
                    }, 10000)

                    // Init block's language data
                    const codeBlock = downloadBtn.closest('code'),
                          blockLang = { hljsSlug: /language-(\w+)/.exec(codeBlock.className)?.[1] }
                    if (blockLang.hljsSlug && window.codeLangData)
                        for (const [langName, langEntry] of Object.entries(window.codeLangData))
                            if (langEntry.hljsSlug == blockLang.hljsSlug) {
                                [blockLang.name, blockLang.fileExtension] = [langName, langEntry.fileExtension]
                                break
                            }

                    // Download code
                    const code = codeBlock.textContent.replace(/^>> /, '').trim() + '\n'
                    const date = new Date().toISOString().split('T')[0] // YYYY-MM-DD
                    const download = `${app.slug}_${blockLang.name.toLowerCase() || 'code'}_${
                        date}_${Date.now().toString(36)}${blockLang.fileExtension ? '.' + blockLang.fileExtension : ''}`
                    const href = URL.createObjectURL(new Blob([code], { type: 'text/plain' }))
                    const a = dom.create.anchor(href, '', { download, style: 'display: none' })
                    document.body.append(a) ; a.click() ; a.remove() ; URL.revokeObjectURL(href)
                }
                downloadBtn.onmouseenter = downloadBtn.onmouseleave = tooltip.toggle

                // Assemble elems
                codeBtnsDiv.append(copyBtn, downloadBtn) ; block.prepend(codeBtnsDiv)
            })
        },

        related(queries) {
            log.caller = 'show.related()'
            if (get.reply.status == 'waiting') // recurse until get.reply() finishes showing answer
                return setTimeout(() => show.related(queries), 500, queries)

            // Re-get.related() if current reply is question to suggest answers
            const currentReply = app.div.querySelector(`#${app.slug} .reply-pre`)?.textContent.trim()
            if (!/shuffle|summarize/i.test(get.reply.src)
                    && !get.related.replyIsQuestion && /[??]/.test(currentReply)) {
                log.debug('Re-getting related queries to answer reply question...')
                get.related.replyIsQuestion = true
                get.related(currentReply).then(queries => show.related(queries))
                    .catch(err => { log.error(err.message) ; api.tryNew(get.related) })
            }

            // Show the queries
            else if (queries && !app.div.querySelector(`.${app.slug}-related-queries`)) {

                // Create/classify/append parent div
                const rqsDiv = dom.create.elem('div', { class: `${app.slug}-related-queries anchored-hidden` })
                app.div.append(rqsDiv)

                // Fill each child div, add attributes + icon + listener
                queries.forEach((query, idx) => {
                    const rqDiv = dom.create.elem('div', {
                        title: app.msgs.tooltip_sendRelatedQuery, tabindex: 0,
                        class: `${app.slug}-related-query fade-in no-user-select no-mobile-tap-outline` })
                    rqDiv.textContent = query ; rqDiv.prepend(icons.create({ key: 'arrowDownRight' }))
                    rqsDiv.append(rqDiv)
                    setTimeout(() => { // add fade + listeners
                        rqDiv.classList.add('active')
                        rqDiv.onclick = rqDiv.onkeydown = event => {
                            const keys = [' ', 'Spacebar', 'Enter', 'Return'], keyCodes = [32, 13]
                            if (keys.includes(event.key) || keyCodes.includes(event.keyCode) || event.type == 'click') {
                                event.preventDefault() // prevent scroll on space taps
                                const chatbar = app.div.querySelector('textarea') ; if (!chatbar) return
                                const relatedQuery = event.target.textContent ; chatbar.value = relatedQuery
                                if (/\[[^[\]]+\]/.test(relatedQuery)) { // highlight 1st bracleted placeholder
                                    chatbar.focus()
                                    ui.addListeners.replySection.chatbarAutoSizer() // since query not auto-sent
                                    chatbar.setSelectionRange(relatedQuery.indexOf('['), relatedQuery.indexOf(']') +1)
                                } else // send placeholder-free related query
                                    chatbar.dispatchEvent(new KeyboardEvent('keydown',
                                        { key: 'Enter', bubbles: true, cancelable: true }))
                            }
                        }
                    }, (idx+1) *50)
                })

                replyBubble.updateMaxHeight() ; get.related.replyIsQuestion = null
            }
        },

        reply({ content, standby = false, apiUsed = null }) {
            show.reply.shareURL = null // reset to regen using longer app.msgChain
            tooltip.toggle('off') // hide lingering tooltip if cursor was on corner button
            const regenSVGwrapper = app.div.querySelector('[id$=regen-btn]')?.firstChild
            if (regenSVGwrapper?.style?.animation) { // remove animation, restore cursor/tooltip
                regenSVGwrapper.style.animation = regenSVGwrapper.style.cursor = ''
                const regenBtn = regenSVGwrapper.closest('btn')
                if (regenBtn.matches(':hover')) // restore tooltip
                    regenBtn.dispatchEvent(new Event('mouseenter'))
            }

            // Build answer interface up to reply section if missing
            if (!app.div.querySelector('.reply-pre')) {
                app.div.textContent = '' ; css.addRisingParticles(app.div)

                // Create/append header div
                const appHeaderDiv = dom.create.elem('div', { class: 'app-header', style: 'margin: -8px 0 0 0' })
                app.div.append(appHeaderDiv)

                // Create/append title
                const appPrefixSpan = dom.create.elem('span', {
                    id: 'app-prefix', class: 'no-user-select',
                    style: `margin-right: -2px ; font-size: ${ env.browser.isMobile ? '1.7rem' : '1.1rem' }`})
                appPrefixSpan.textContent = '🤖 ' ; appHeaderDiv.append(appPrefixSpan)
                const appHeaderLogo = logos.googlegpt.create()
                appHeaderLogo.width = env.browser.isMobile ? 177 : env.browser.isFF ? 124 : 122
                appHeaderLogo.style.cssText = (
                    `position: relative ; top: ${ env.browser.isMobile ? 4 : env.browser.isFF ? 3 : 2 }px`
                  + ( env.browser.isMobile ? '; margin-left: 1px' : '' ))
                const appTitleAnchor = dom.create.anchor(app.urls.app, appHeaderLogo)
                appTitleAnchor.classList.add(`${app.slug}-name`, 'no-user-select')
                appHeaderDiv.append(appTitleAnchor)

                // Create/append header buttons div
                const headerBtnsDiv = dom.create.elem('div', {
                    id: `${app.slug}-header-btns`, class: 'no-mobile-tap-outline', style: 'margin-top: 1px' })
                appHeaderDiv.append(headerBtnsDiv)

                // Create/append Chevron button
                if (!env.browser.isMobile) {
                    var chevronBtn = dom.create.elem('btn', {
                        id: `${app.slug}-chevron-btn`, class: `${app.slug}-header-btn anchored-only`,
                        style: 'margin: -3.5px 1px 0 11px' })
                    chevronBtn.append(icons.create({ key: `chevron${ app.config.minimized ? 'Up' : 'Down' }`,
                        size: 22, style: 'position: relative ; top: -1px' }))
                    headerBtnsDiv.append(chevronBtn)
                }

                // Create/append About button
                const aboutBtn = dom.create.elem('btn', {
                    id: `${app.slug}-about-btn`, class: `${app.slug}-header-btn`,
                    style: `margin-top: ${ env.browser.isMobile ? 0.25 : -0.15 }rem`})
                aboutBtn.append(icons.create({ key: 'questionMarkCircle' })) ; headerBtnsDiv.append(aboutBtn)

                // Create/append Settings button
                const settingsBtn = dom.create.elem('btn',{
                    id: `${app.slug}-settings-btn`, class: `${app.slug}-header-btn`,
                    style: `margin: ${ env.browser.isMobile ? 6 : 0 }px 10px 0 4.5px` })
                settingsBtn.append(icons.create({ key: 'sliders', size: 17 })) ; headerBtnsDiv.append(settingsBtn)

                // Create/append Font Size button
                if (!standby) {
                    var fontSizeBtn = dom.create.elem('btn', {
                        id: `${app.slug}-font-size-btn`, class: `${app.slug}-header-btn app-hover-only`,
                        style: `margin: ${ env.browser.isMobile ? 5 : -2 }px 9px 0 0` })
                    fontSizeBtn.append(icons.create({ key: 'fontSize' })) ; headerBtnsDiv.append(fontSizeBtn)
                }

                // Create/append Pin button
                if (!env.browser.isMobile) {
                    var pinBtn = dom.create.elem('btn', {
                        id: `${app.slug}-pin-btn`, class: `${app.slug}-header-btn app-hover-only`,
                        style: 'margin: -1.55px 9.5px 0 0' })
                    pinBtn.append(icons.create({ key: 'pin', size: 16.5, style: 'position: relative ; top: 2px' }))
                    headerBtnsDiv.append(pinBtn)

                // Create/append Wider Sidebar button
                    var wsbBtn = dom.create.elem('btn', {
                        id: `${app.slug}-wsb-btn`, class: `${app.slug}-header-btn app-hover-only anchored-hidden`,
                        style: 'margin: -0.5px 12px 0 0' })
                    wsbBtn.append(icons.create({ key: `widescreen${ app.config.widerSidebar ? 'Wide' : 'Tall' }`}))
                    headerBtnsDiv.append(wsbBtn)

                // Create/append Expand/Shrink button
                    var arrowsBtn = dom.create.elem('btn', {
                        id: `${app.slug}-arrows-btn`, class: `${app.slug}-header-btn app-hover-only anchored-only`,
                        style: 'margin: 0 11.5px 0 0' })
                    arrowsBtn.append(icons.create({
                        key: `arrowsDiagonal${ app.config.expanded ? 'In' : 'Out' }`, size: 17 }))
                    headerBtnsDiv.append(arrowsBtn)
                }

                // Add app header button listeners
                ui.addListeners.btns.appHeader()

                // Create/append 'by KudoAI' if it fits
                if (!env.browser.isMobile) {
                    const bylineSpan = dom.create.elem('span', { class: 'byline no-user-select', textContent: 'by ' })
                    bylineSpan.append(dom.create.anchor(app.urls.publisher, 'KudoAI'))
                    appHeaderDiv.querySelector(`.${app.slug}-name`).insertAdjacentElement('afterend', bylineSpan)
                    update.bylineVisibility()
                }

                // Show standby state if prefix/suffix mode on
                if (standby) {
                    const standbyBtnsDiv = dom.create.elem('div', {
                        class: `${app.slug}-standby-btns`, style: 'will-change: transform' })
                    ;['query', 'summarize'].forEach(btnType => {
                        const btn = {
                            node: dom.create.elem('button', {
                                class: `${app.slug}-standby-btn no-mobile-tap-outline` }),
                            icon: icons.create({ key: btnType == 'query' ? 'send' : 'summarize' }),
                            textSpan: dom.create.elem('span')
                        }
                        btn.textSpan.textContent = btnType == 'query' ?
                            `${app.msgs.btnLabel_sendSearchQueryTo} ${app.name}` : app.msgs.tooltip_summarizeResults
                        btn.node.onclick = () => {
                            show.reply.userInteracted = true ; show.reply.chatbarFocused = false
                            app.msgChain.push({ role: 'user', content:
                                btnType == 'summarize' ? prompts.create('summarizeResults')
                                                       : new URL(location.href).searchParams.get('q') })
                            get.reply({ msgs: app.msgChain, src: btnType })
                        }
                        btn.node.append(btn.icon, btn.textSpan) ; standbyBtnsDiv.append(btn.node)
                    })
                    app.div.append(standbyBtnsDiv)

                // Otherwise create/append answer bubble section
                } else replyBubble.insert()
            }

            // Build reply section if missing
            if (!app.div.querySelector(`#${app.slug}-chatbar`)) {

                // Init/clear user reply section content/classes/style
                const replySection = app.div.querySelector('section') || dom.create.elem('section')
                if (replySection.className.includes('loading'))
                    replySection.textContent = replySection.className = replySection.style = ''

                // Create/append section elems
                const replyForm = dom.create.elem('form')
                const continueChatDiv = dom.create.elem('div')
                const chatTextarea = dom.create.elem('textarea', {
                    id: `${app.slug}-chatbar`, rows: 1,
                    placeholder: `${app.msgs[standby ? 'placeholder_askSomethingElse' : 'tooltip_sendReply']}...`
                })
                continueChatDiv.append(chatTextarea)
                replyForm.append(continueChatDiv) ; replySection.append(replyForm)
                app.div.querySelector('.reply-bubble, [class*=standby-btns]').after(replySection)

                // Create/append chatbar buttons
                ;['send', 'shuffle', 'summarize'].forEach((btnType, idx) => {
                    if (btnType == 'summarize' && app.div.querySelector('[class*=standby-btn]'))
                        return // since big Summarize button exists
                    const btn = dom.create.elem('button', {
                        id: `${app.slug}-${btnType}-btn`, class: `${app.slug}-chatbar-btn no-mobile-tap-outline` })
                    btn.style.right = `${ idx == 0 ? 3 : idx == 1 ? -3 : -5 }px`
                    btn.append(icons.create({ key: btnType, size: btnType == 'send' ? 14 : 18 }))
                    continueChatDiv.append(btn)
                })

                // Init/fill/append footer
                app.footer = app.div.querySelector('footer') || dom.create.elem('footer')
                app.footer.append(app.footerContent)
                if (!app.div.querySelector('footer')) app.div.append(app.footer)

                // Add listeners
                ui.addListeners.replySection()

                // Scroll to top on mobile if user interacted
                if (env.browser.isMobile && show.reply.userInteracted) {
                    document.body.scrollTop = 0 // Safari
                    document.documentElement.scrollTop = 0 // Chromium/FF/IE
                }
            }

            // Render/show answer if query sent
            if (!standby) {

                // Show API used in bubble header
                if (!show.reply.updatedAPIinHeader) {
                    show.reply.updatedAPIinHeader = true
                    const preHeaderLabel = app.div.querySelector('.reply-header-txt')
                    const apiBeacon = dom.create.elem('span',
                        { class: 'api-btn', style: 'cursor: pointer', textContent: '⦿' })
                    apiBeacon.onmouseenter = apiBeacon.onmouseleave = apiBeacon.onclick = menus.hover.toggle
                    apiBeacon.style.pointerEvents = app.config.proxyAPIenabled ? '' : 'none'
                    preHeaderLabel.replaceChildren(
                        apiBeacon, ` API ${app.msgs.componentLabel_used}: `, dom.create.elem('b'))
                    setTimeout(() => type(apiUsed, preHeaderLabel.lastChild, { speed: 1.5 }), 150)
                    function type(text, targetElem, { speed = 1 } = {}) {
                        targetElem.textContent = '' ; let i = 0;
                        (function typeNextChar() {
                            if (i < text.length) {
                                targetElem.textContent += text[i] ; i++ ; setTimeout(typeNextChar, 50 / speed) }
                        })()
                    }
                }

                // Render MD, highlight code
                const replyPre = app.div.querySelector('.reply-pre')
                try { // to render markdown
                    replyPre.innerHTML = marked.parse(content) } catch (err) { log.error(err.message) }
                hljs.highlightAll() // highlight code
                replyPre.querySelectorAll('code').forEach(codeBlock => { // add linebreaks after semicolons
                    codeBlock.innerHTML = codeBlock.innerHTML.replace(/;\s*/g, ';<br>') })
                update.replyPrefix() // prepend '>> ' if dark scheme w/ bg animations to emulate terminal

                // Typeset math
                ;[replyPre, ...replyPre.querySelectorAll('*')].forEach(elem =>
                    renderMathInElement(elem, { delimiters: app.katexDelimiters, throwOnError: false }))

                if (app.config.stickySidebar) replyBubble.updateMaxHeight()

                // Auto-scroll if active
                if (app.config.autoScroll && !env.browser.isMobile && app.config.proxyAPIenabled
                    && !app.config.streamingDisabled)
                {
                    if (app.config.stickySidebar || app.config.anchored) replyPre.scrollTop = replyPre.scrollHeight
                    else scrollBy({ top: app.div.querySelector(`#${app.slug}-chatbar`)
                        .getBoundingClientRect().bottom - innerHeight +13 })
                }
            }

            // Focus chatbar conditionally
            if (!show.reply.chatbarFocused // do only once
                && !env.browser.isMobile // exclude mobile devices to not auto-popup OSD keyboard
                && ((!app.config.autoFocusChatbarDisabled && ( app.config.anchored // include Anchored mode if AF enabled
                        // ...or un-Anchored if fully above fold
                        || ( app.div.offsetHeight < innerHeight - app.div.getBoundingClientRect().top )))
                    // ...or Anchored if AF disabled & user interacted
                    || (app.config.autoFocusChatbarDisabled && app.config.anchored && show.reply.userInteracted))
            ) { app.div.querySelector(`#${app.slug}-chatbar`).focus() ; show.reply.chatbarFocused = true }

            // Update styles
            if (app.config.anchored) update.appBottomPos() // restore minimized/restored state if anchored
            update.chatbarWidth()

            show.reply.userInteracted = false
        }
    }

    // Define COMPONENTS

    window.fontSizeSlider = { // requires lib/<dom|settings>.js + <app|env|inputEvents>
        fadeInDelay: 5, // ms
        hWheelDistance: 10, // px

        createAppend() { // requires lib/<dom|settings>.js + <app|env|inputEvents>

            // Create/ID/classify slider elems
            fontSizeSlider.cursorOverlay = dom.create.elem('div', { class: 'cursor-overlay' })
            const slider = dom.create.elem('div',
                { id: `${app.slug}-font-size-slider-track`, class: 'fade-in-less', style: 'display: none' })
            const sliderThumb = dom.create.elem('div',
                { title: Math.floor(app.config.fontSize *10) /10 + 'px', id: `${app.slug}-font-size-slider-thumb` })
            const sliderTip = dom.create.elem('div', { id: `${app.slug}-font-size-slider-tip` })

            // Assemble/insert elems
            slider.append(sliderThumb, sliderTip)
            app.div.insertBefore(slider, app.div.querySelector(`.${app.slug}-tooltip,` // desktop
                                                           + '.reply-bubble')) // mobile
            // Init thumb pos
            setTimeout(() => {
                const iniLeft = (app.config.fontSize - app.config.minFontSize)
                              / (app.config.maxFontSize - app.config.minFontSize)
                              * (slider.offsetWidth - sliderThumb.offsetWidth) // slider width
                sliderThumb.style.left = iniLeft + 'px'
            }, fontSizeSlider.fadeInDelay) // to ensure visibility for accurate dimension calcs

            // Add event listeners for dragging thumb
            let isDragging = false, startX, startLeft
            sliderThumb.addEventListener(inputEvents.down, event => {
                if (event.button != 0) return // prevent non-left-click drag
                event.preventDefault() // prevent text selection
                isDragging = true ; startX = event.clientX ; startLeft = sliderThumb.offsetLeft
                document.body.append(fontSizeSlider.cursorOverlay)
            })
            document.addEventListener(inputEvents.move, ({ clientX }) => {
                if (isDragging) moveThumb(startLeft + clientX - startX) })
            document.addEventListener(inputEvents.up, () => {
                isDragging = false
                if (fontSizeSlider.cursorOverlay?.isConnected) fontSizeSlider.cursorOverlay.remove()
            })

            // Add event listener for wheel-scrolling thumb
            if (!env.browser.isMobile) slider.onwheel = event => {
                event.preventDefault()
                moveThumb(sliderThumb.offsetLeft - Math.sign(event.deltaY) * fontSizeSlider.hWheelDistance)
            }

            // Add event listener for seek/dragging by inputEvents.down on track
            slider.addEventListener(inputEvents.down, event => {
                if (event.button != 0) return // prevent non-left-click drag
                event.preventDefault() // prevent text selection
                const clientX = event.clientX || event.touches?.[0]?.clientX
                moveThumb(clientX - slider.getBoundingClientRect().left - sliderThumb.offsetWidth / 2)
                isDragging = true ; startX = clientX ; startLeft = sliderThumb.offsetLeft // manually init dragging
                document.body.append(fontSizeSlider.cursorOverlay)
            })

            function moveThumb(newLeft) {

                // Bound thumb
                const sliderWidth = slider.offsetWidth - sliderThumb.offsetWidth
                if (newLeft < 0) newLeft = 0
                if (newLeft > sliderWidth) newLeft = sliderWidth

                // Move thumb
                sliderThumb.style.left = newLeft + 'px'

                // Adjust font size based on thumb position
                const replyPre = app.div.querySelector('.reply-pre')
                const fontSizePercent = newLeft / sliderWidth
                const fontSize = app.config.minFontSize + fontSizePercent
                               *(app.config.maxFontSize - app.config.minFontSize)
                replyPre.style.fontSize = fontSize + 'px'
                replyPre.style.lineHeight = fontSize * app.config.lineHeightRatio + 'px'
                settings.save('fontSize', fontSize)
                sliderThumb.title = Math.floor(app.config.fontSize *10) /10 + 'px'
            }

            return slider
        },

        toggle(state = '') { // requires app
            const slider = document.getElementById(`${app.slug}-font-size-slider-track`)
                         || fontSizeSlider.createAppend()
            const replyTip = app.div.querySelector('.reply-tip')
            const sliderTip = document.getElementById(`${app.slug}-font-size-slider-tip`)

            // Show slider
            if (state == 'on' || (!state && slider.style.display == 'none')) {

                // Position slider tip
                const btnSpan = document.getElementById(`${app.slug}-font-size-btn`)
                const rects = { appDiv: app.div.getBoundingClientRect(), btnSpan: btnSpan.getBoundingClientRect() }
                sliderTip.style.right = `${
                    rects.appDiv.right - ( rects.btnSpan.left + rects.btnSpan.right )/2 -35.5 }px`

                // Show slider, hide reply tip
                slider.style.display = sliderTip.style.display = '' ; if (replyTip) replyTip.style.display = 'none'
                setTimeout(() => slider.classList.add('active'), fontSizeSlider.fadeInDelay)

            // Hide slider
            } else if (state == 'off' || (!state && slider.style.display != 'none')) {
                slider.classList.remove('active') ; if (replyTip) replyTip.style.display = ''
                sliderTip.style.display = slider.style.display = 'none'
            }
        }
    }

    const logos = { // requires dom.js + <app|env> + GM_getResourceText()
        googlegpt: {

            create() { // requires dom.js + app
                const googlegptlogo = dom.create.elem('img', { id: `${app.slug}-logo`, class: 'no-mobile-tap-outline' })
                logos.googlegpt.update(googlegptlogo)
                return googlegptlogo
            },

            update(...targetLogos) { // requires <app|env> + GM_getResourceText()
                targetLogos = targetLogos.flat() // flatten array args nested by spread operator
                if (!targetLogos.length) targetLogos = document.querySelectorAll(`#${app.slug}-logo`)
                targetLogos.forEach(logo =>
                    logo.src = GM_getResourceText(`ggpt${ env.ui.app.scheme == 'dark' ? 'DS' : 'LS' }logo`))
            }
        }
    }

    window.modals = {
        stack: [], // of types of undismissed modals
        class: `${app.slug}-modal`,

        about() {

            // Show modal
            const labelStyles = 'text-transform: uppercase ; font-size: 16px ; font-weight: bold ;'
                              + `color: ${ env.ui.app.scheme == 'dark' ? 'white' : '#494141' }`
            const aboutModal = modals.alert(
                `${app.symbol} ${app.msgs.appName}`, // title
                `<span style="${labelStyles}">🧠 ${app.msgs.about_author}:</span> `
                    + `<a href="${app.author[0].url}" target="_blank" rel="nopener">${app.author[0].name}</a> `
                        + `${app.msgs.about_and} <a href="${app.urls.contributors}" target="_blank" rel="nopener">`
                        + `${app.msgs.about_contributors}</a>\n`
                + `<span style="${labelStyles}">🏷️ ${app.msgs.about_version}:</span> `
                    + `<span class="about-em">${app.version}</span>\n`
                + `<span style="${labelStyles}">📜 ${app.msgs.about_openSourceCode}:</span> `
                    + `<a href="${app.urls.github}" target="_blank" rel="nopener">`
                        + app.urls.github + '</a>\n'
                + `<span style="${labelStyles}">🚀 ${app.msgs.about_latestChanges}:</span> `
                    + `<a href="${app.urls.github}/commits" target="_blank" rel="nopener">`
                        + `${app.urls.github}/commits</a>\n`
                + `<span style="${labelStyles}">⚡ ${app.msgs.about_poweredBy}:</span> `
                    + `<a href="${app.urls.chatgptjs}" target="_blank" rel="noopener">chatgpt.js</a>`
                        + ` v${app.chatgptjsVer}`,
                [ // buttons
                    function checkForUpdates() { userscript.updateCheck() },
                    function getSupport(){},
                    function rateUs(){},
                    function moreAIextensions(){}
                ], '', 629 // modal width
            )

            // Add logo
            const aboutHeaderLogo = logos.googlegpt.create() ; aboutHeaderLogo.width = 405
            aboutHeaderLogo.style.cssText = `max-width: 98% ; margin: 15px ${
                env.browser.isMobile ? 'auto' : '14.5%' } -1px`
            aboutModal.firstChild.nextElementSibling.before(aboutHeaderLogo) // after close btn

            // Center text
            aboutModal.querySelector('h2').remove() // remove empty title h2
            aboutModal.querySelector('p').style.cssText = `
                overflow-wrap: anywhere ; line-height: 1.55 ;
                margin: ${ env.browser.isCompact ? '21px 0 -20px' : '15px 0 -28px 17px' }`

            // Hack buttons
            aboutModal.querySelectorAll('button').forEach(btn => {
                btn.style.cssText = 'height: 50px ; min-width: 136px'

                // Replace link buttons w/ clones that don't dismiss modal
                if (/support|rate|extensions/i.test(btn.textContent)) {
                    btn.replaceWith(btn = btn.cloneNode(true))
                    btn.onclick = () => modals.safeWinOpen(
                        btn.textContent.includes(app.msgs.btnLabel_getSupport) ? app.urls.support
                      : app.urls.relatedExtensions)
                }

                // Prepend emoji + localize labels
                if (/updates/i.test(btn.textContent))
                    btn.textContent = `🚀 ${app.msgs.btnLabel_checkForUpdates}`
                else if (/support/i.test(btn.textContent))
                    btn.textContent = `🧠 ${app.msgs.btnLabel_getSupport}`
                else if (/rate/i.test(btn.textContent))
                    btn.textContent = `⭐ ${app.msgs.btnLabel_rateUs}`
                else if (/extensions/i.test(btn.textContent))
                    btn.textContent = `🤖 ${app.msgs.btnLabel_moreAIextensions}`

                // Hide Dismiss button
                else btn.style.display = 'none'
            })

            return aboutModal
        },

        alert(title = '', msg = '', btns = '', checkbox = '', width = '') { // generic one from chatgpt.alert()
            const alertID = chatgpt.alert(title, msg, btns, checkbox, width),
                  alert = document.getElementById(alertID).firstChild
            this.init(alert) // add classes/listeners/hack bg
            return alert
        },

        api() { // requires lib/feedback.js + <apis|app|get|settings>

            // Show modal
            const modalBtns = [app.msgs.menuLabel_random, ...Object.keys(apis).filter(api => api != 'OpenAI')]
                .map(api => { // to btn callback/label
                    function onclick() {
                        settings.save('preferredAPI', api == app.msgs.menuLabel_random ? false : api)
                        if (modals.settings.get()) { // update status of Preferred API entry
                            const preferredAPIstatus = document.querySelector('[id*=preferredAPI] > span')
                            if (preferredAPIstatus.textContent != api) preferredAPIstatus.textContent = api
                        }
                        feedback.notify(`${app.msgs.menuLabel_preferred} API ${app.msgs.menuLabel_saved.toLowerCase()}`,
                            `${ app.config.anchored ? 'top' : 'bottom' }-right`)
                        if (app.div.querySelector(`.${app.slug}-alert`) && app.config.proxyAPIenabled)
                            get.reply({ msgs: app.msgChain, src: get.reply.src }) // re-send query if user alerted
                    }
                    Object.defineProperty(onclick, 'name', { value: api.toLowerCase() })
                    return onclick
                })
            const apiModal = modals.alert(`${app.msgs.menuLabel_preferred} API:`, '', modalBtns, '', 503)

            // Re-style elems
            apiModal.querySelector('h2').style.justifySelf = 'center' // center title
            const btnsDiv = apiModal.querySelector('.modal-buttons')
            btnsDiv.style.cssText = `margin: 18px 0px 4px !important ; ${ env.browser.isCompact ? ''
                : 'flex-wrap: wrap ; justify-content: center ; gap: 9px' }` // gridify wide view btns
            btnsDiv.querySelectorAll('button').forEach((btn, idx) => {
                if (idx == 0) btn.style.display = 'none' // hide Dismiss button
                else btn.classList.toggle('primary-modal-btn', // emphasize preferred API
                    app.config.preferredAPI && app.config.preferredAPI.toLowerCase() == btn.textContent.toLowerCase()
                        || btn.textContent == app.msgs.menuLabel_random && !app.config.preferredAPI)
            })

            return apiModal
        },

        handlers: {

            dismiss: { // to dismiss native modals
                click(event) {
                    const clickedElem = event.target,
                          settingsCrown = document.querySelector(`#${app.slug}-settings > img`)
                    if (settingsCrown) { // return if cursor within bounds
                        const crownRect = settingsCrown.getBoundingClientRect()
                        if (event.clientX >= crownRect.left && event.clientX <= crownRect.right
                            && event.clientY >= crownRect.top && event.clientY <= crownRect.bottom
                        ) return
                    }
                    if (clickedElem == event.currentTarget || clickedElem.closest('[class*=-close-btn]'))
                        modals.hide((clickedElem.closest('[class*=-modal-bg]') || clickedElem).firstChild)
                },

                key(event) {
                    if (event.key.startsWith('Esc') || event.keyCode == 27)
                        modals.hide(document.querySelector('[class$=-modal]'))
                }
            },

            drag: {

                mousedown(event) { // find modal, update styles, attach listeners, init XY offsets
                    if ( // prevent drag when...
                        event.button != 0 // non-left-click
                        || !/auto|default/.test(getComputedStyle(event.target).cursor) // cursor changed
                        || event.target.closest('ul') // entry elem
                    ) return
                    modals.draggingModal = event.currentTarget
                    event.preventDefault() // prevent sub-elems like icons being draggable
                    Object.assign(modals.draggingModal.style, { // update styles
                        transform: 'scale(1.05)',
                        transition: '0.1s', '-webkit-transition': '0.1s', '-moz-transition': '0.1s',
                            '-o-transition': '0.1s', '-ms-transition': '0.1s'
                    })
                    document.body.style.cursor = 'grabbing' // update cursor
                    ;[...modals.draggingModal.children] // prevent hover FX if drag lags behind cursor
                        .forEach(child => child.style.pointerEvents = 'none')
                    ;['mousemove', 'mouseup'].forEach(eventType => // add listeners
                        document.addEventListener(eventType, modals.handlers.drag[eventType]))
                    const draggingModalRect = modals.draggingModal.getBoundingClientRect()
                    modals.handlers.drag.offsetX = event.clientX - draggingModalRect.left +21
                    modals.handlers.drag.offsetY = event.clientY - draggingModalRect.top +12
                },

                mousemove(event) { // drag modal
                    if (modals.draggingModal) {
                        const newX = event.clientX - modals.handlers.drag.offsetX,
                              newY = event.clientY - modals.handlers.drag.offsetY
                        Object.assign(modals.draggingModal.style, { left: `${newX}px`, top: `${newY}px` })
                    }
                },

                mouseup() { // restore styles/pointer events, remove listeners, reset modals.draggingModal
                    Object.assign(modals.draggingModal.style, { // restore styles
                        cursor: 'inherit', transform: 'scale(1)',
                        transition: 'inherit', '-webkit-transition': 'inherit', '-moz-transition': 'inherit',
                            '-o-transition': 'inherit', '-ms-transition': 'inherit'
                    })
                    document.body.style.cursor = '' // restore cursor
                    ;[...modals.draggingModal.children] // restore pointer events
                        .forEach(child => child.style.pointerEvents = '')
                    ;['mousemove', 'mouseup'].forEach(eventType => // remove listeners
                        document.removeEventListener(eventType, modals.handlers.drag[eventType]))
                    modals.draggingModal = null
                }
            }
        },

        hide(modal) {
            const modalContainer = modal?.parentNode ; if (!modalContainer) return
            modalContainer.style.animation = 'modal-zoom-fade-out 0.165s ease-out'
            modalContainer.onanimationend = () => modalContainer.remove()
        },

        init(modal) { // requires lib/dom.js
            this.stylize()
            modal.classList.add('no-user-select', this.class) ; modal.parentNode.classList.add(`${this.class}-bg`)

            // Add listeners
            modal.onwheel = modal.ontouchmove = event => event.preventDefault() // disable wheel/swipe scrolling
            modal.onmousedown = this.handlers.drag.mousedown // enable click-dragging
            if (!modal.parentNode.className.includes('chatgpt-modal')) { // enable click-dismissing native modals
                const dismissElems = [modal.parentNode, modal.querySelector('[class*=-close-btn]')]
                dismissElems.forEach(elem => elem.onclick = this.handlers.dismiss.click)
            }

            // Hack BG
            css.addRisingParticles(modal)
            setTimeout(() => { // dim bg
                modal.parentNode.style.backgroundColor = `rgba(67,70,72,${
                    env.ui.app.scheme == 'dark' ? 0.62 : 0.33 })`
                modal.parentNode.classList.add('animated')
            }, 100) // delay for transition fx
        },

        observeRemoval(modal, modalType, modalSubType) { // to maintain stack for proper nav
            const modalBG = modal.parentNode
            new MutationObserver(([mutation], obs) => {
                mutation.removedNodes.forEach(removedNode => { if (removedNode == modalBG) {
                    if (modals.stack[0].includes(modalSubType || modalType)) { // new modal not launched so nav back
                        modals.stack.shift() // remove this modal type from stack 1st
                        const prevModalType = modals.stack[0]
                        if (prevModalType) { // open it
                            modals.stack.shift() // remove type from stack since re-added on open
                            modals.open(prevModalType)
                        }
                    }
                    obs.disconnect()
                }})
            }).observe(modalBG.parentNode, { childList: true, subtree: true })
        },

        open(modalType, modalSubType) { // custom ones
            const modal = modalSubType ? modals[modalType][modalSubType]()
                        : (modals[modalType].show || modals[modalType])()
            if (!modal) return // since no div returned
            if (settings.controls[modalType]?.type != 'prompt') { // add to stack
                this.stack.unshift(modalSubType ? `${modalType}_${modalSubType}` : modalType)
                log.debug(`Modal stack: ${JSON.stringify(modals.stack)}`)
            }
            this.init(modal) // add classes/listeners/hack bg
            this.observeRemoval(modal, modalType, modalSubType) // to maintain stack for proper nav
            if (!modals.handlers.dismiss.key.added) { // add key listener to dismiss modals
                document.addEventListener('keydown', modals.handlers.dismiss.key)
                modals.handlers.dismiss.key.added = true
            }
        },

        replyLang() { // requires <app|env|log|modals|settings>
            let replyLang = prompt(`${app.msgs.prompt_updateReplyLang}:`, app.config.replyLang)
            if (replyLang == null) return // user cancelled so do nothing
            else if (!/\d/.test(replyLang)) {
                replyLang = ( // auto-case for menu/alert aesthetics
                    replyLang.length < 4 || replyLang.includes('-') ? replyLang.toUpperCase()
                        : log.toTitleCase(replyLang) )
                settings.save('replyLang', replyLang || env.browser.language)
                modals.alert(`${app.msgs.alert_langUpdated}!`, // title
                    `${app.name} ${ // msg
                        app.msgs.alert_willReplyIn} ${ replyLang || app.msgs.alert_yourSysLang }.`,
                    '', '', 330) // modal width
                if (modals.settings.get()) // update settings menu status label
                    document.querySelector('#replyLang-settings-entry span').textContent = replyLang
            }
        },

        safeWinOpen(url) { open(url, '_blank', 'noopener') }, // to prevent backdoor vulnerabilities

        scheme() {

            // Show modal
            const schemeModal = modals.alert(`${
                app.name } ${( app.msgs.menuLabel_colorScheme ).toLowerCase() }:`, '', // title
                [ function auto(){}, function light(){}, function dark(){} ] // buttons
            )

            // Center title/button cluster
            schemeModal.querySelector('h2').style.justifySelf = 'center'
            schemeModal.querySelector('.modal-buttons').style.cssText = `
                justify-content: center ; margin: 20px 0 9px !important`

            // Hack buttons
            const schemeEmojis = { 'light': '☀️', 'dark': '🌘', 'auto': '🌗'}
            schemeModal.querySelectorAll('button').forEach(btn => {
                const btnScheme = btn.textContent.toLowerCase()

                // Emphasize active scheme
                btn.classList.toggle('primary-modal-btn',
                    app.config.scheme == btn.textContent.toLowerCase() || (btn.textContent == 'Auto' && !app.config.scheme))

                // Prepend emoji + localize labels
                if (Object.prototype.hasOwnProperty.call(schemeEmojis, btnScheme))
                    btn.textContent = `${schemeEmojis[btnScheme]} ${ // emoji
                        app.msgs['scheme_' + btnScheme] || app.msgs['menuLabel_' + btnScheme]
                            || btnScheme.toUpperCase() }`
                else btn.style.display = 'none' // hide Dismiss button

                // Clone button to replace listener to not dismiss modal on click
                btn.replaceWith(btn = btn.cloneNode(true))
                btn.onclick = () => {
                    const newScheme = btnScheme == 'auto' ? ui.getScheme() : btnScheme
                    settings.save('scheme', btnScheme == 'auto' ? false : newScheme)
                    schemeModal.querySelectorAll('button').forEach(btn =>
                        btn.classList.remove('primary-modal-btn')) // clear prev emphasized active scheme
                    btn.classList.add('primary-modal-btn') // emphasize newly active scheme
                    btn.style.cssText = 'pointer-events: none' // disable hover fx to show emphasis
                    setTimeout(() => { btn.style.pointerEvents = 'auto' }, // re-enable hover fx
                        100) // ...after 100ms to flicker emphasis
                    update.scheme(newScheme) ; schemeNotify(btnScheme)
                }
            })

            function schemeNotify(scheme) {

                // Show notification
                feedback.notify(`${app.msgs.menuLabel_colorScheme}: ${(
                    scheme == 'light' ? app.msgs.scheme_light || 'Light'
                  : scheme == 'dark'  ? app.msgs.scheme_dark  || 'Dark'
                                      : app.msgs.menuLabel_auto
                ).toUpperCase()}`)

                // Append scheme icon
                const notifs = document.querySelectorAll('.chatgpt-notif'), notif = notifs[notifs.length -1]
                notif.append(icons.create({
                    key: scheme == 'light' ? 'sun' : scheme == 'dark' ? 'moon' : 'arrowsCyclic',
                    style: 'width: 23px ; height: 23px ; position: relative ; top: 1px ; margin-left: 6px'
                }))
            }
            return schemeModal
        },

        settings: {

            createAppend() {

                // Init master elems
                const settingsContainer = dom.create.elem('div'),
                      settingsModal = dom.create.elem('div', { id: `${app.slug}-settings` })
                      settingsContainer.append(settingsModal)

                // Init settings keys
                const settingsKeys = Object.keys(settings.controls).filter(key =>
                    !(env.browser.isMobile && settings.controls[key].mobile == false))

                // Init logo
                const settingsIcon = icons.googlegpt.create()
                settingsIcon.style.cssText += `width: ${ env.browser.isCompact ? 64 : 65 }px ;
                                               margin: 13px 0 ${ env.browser.isCompact ? '-35' : '-27' }px ;
                                               position: relative ; top: -42px ; ${
                                                   env.browser.isCompact ? 'left: 6px' : '' }`
                // Init title
                const settingsTitleDiv = dom.create.elem('div', { id: `${app.slug}-settings-title` }),
                      settingsTitleIcon = icons.create({ key: 'sliders' }),
                      settingsTitleH4 = dom.create.elem('h4', { textContent: app.msgs.menuLabel_settings })
                settingsTitleIcon.style.cssText += `
                    width: 21px ; height: 21px ; position: relative ; top: 2.5px ; right: 12px`
                settingsTitleH4.prepend(settingsTitleIcon) ; settingsTitleDiv.append(settingsTitleH4)

                // Init settings lists
                const settingsLists = [], middleGap = 30 // px
                const settingsListContainer = dom.create.elem('div')
                const settingsListCnt = (
                    env.browser.isMobile && ( env.browser.isCompact || settingsKeys.length < 8 )) ? 1 : 2
                const settingEntryCap = Math.floor(settingsKeys.length /2)
                for (let i = 0 ; i < settingsListCnt ; i++) settingsLists.push(dom.create.elem('ul'))
                settingsListContainer.style.width = '95%' // pad vs. parent
                if (settingsListCnt > 1) { // style multi-list landscape mode
                    settingsListContainer.style.cssText += ( // make/pad flexbox, add middle gap
                        `display: flex ; padding: 11px 0 13px ; gap: ${ middleGap /2 }px` )
                    settingsLists[0].style.cssText = ( // add vertical separator
                        `padding-right: ${ middleGap /2 }px` )
                }

                // Create/append setting icons/labels/toggles
                settingsKeys.forEach((key, idx) => {
                    const setting = settings.controls[key]

                    // Create/append item/label elems
                    const settingEntry = dom.create.elem('li',
                        { id: `${key}-settings-entry`, title: setting.helptip || '' })
                    const settingLabel = dom.create.elem('label', { textContent: setting.label })
                    settingEntry.append(settingLabel);
                    (settingsLists[env.browser.isCompact ? 0 : +(idx >= settingEntryCap)]).append(settingEntry)

                    // Create/prepend icons
                    const settingIcon = icons.create({ key: setting.icon })
                    settingIcon.style.cssText = 'position: relative ;' + (
                        /proxy/i.test(key) ? 'top: 3px ; left: -0.5px ; margin-right: 9px'
                      : /preferred/i.test(key) ? 'top: 3.5px ; margin-right: 7.5px'
                      : /streaming/i.test(key) ? 'top: 3px ; left: 0.5px ; margin-right: 9px'
                      : /auto(?:get|focus)/i.test(key) ? 'top: 4.5px ; margin-right: 7px'
                      : /summarize/i.test(key) ? 'top: 3.5px ; left: -5px ; margin-right: 3px ; height: 17.5px'
                      : /autoscroll/i.test(key) ? 'top: 3.5px ; left: -1.5px ; margin-right: 6px'
                      : /^rq/.test(key) ? 'top: 2.5px ; left: 0.5px ; margin-right: 9px ; transform: scaleY(-1)'
                      : /prefix/i.test(key) ? 'top: 2.5px ; left: 0.5px ; margin-right: 9px'
                      : /suffix/i.test(key) ? 'top: 4px ; left: -1.5px ; margin-right: 7px'
                      : /sidebar/i.test(key) ? 'top: 4px ; left: -1.5px ; margin-right: 7.5px'
                      : /anchor/i.test(key) ? 'top: 3px ; left: -2.5px ; margin-right: 5.5px'
                      : /animation/i.test(key) ? 'top: 3px ; left: -1.5px ; margin-right: 6.5px'
                      : /replylang/i.test(key) ? 'top: 3px ; left: -1.5px ; margin-right: 9px'
                      : /scheme/i.test(key) ? 'top: 2.5px ; left: -1.5px ; margin-right: 8px'
                      : /debug/i.test(key) ? 'top: 3.5px ; left: -1.5px ; margin-right: 8px'
                      : /about/i.test(key) ? 'top: 3px ; left: -3px ; margin-right: 5.5px' : ''
                    )
                    settingEntry.prepend(settingIcon)
                    if (key.includes('Animation')) // customize sparkle icon elem fill
                        settingIcon[`${ key.startsWith('fg') ? 'last' : 'first' }Child`].style.fill = 'none'

                    // Create/append toggles/listeners
                    if (setting.type == 'toggle') {

                        // Init toggle input
                        const settingToggle = dom.create.elem('input', {
                            type: 'checkbox', disabled: true, style: 'display: none' })
                        settingToggle.checked = settings.typeIsEnabled(key) // init based on config/name
                            && !(key == 'streamingDisabled' && !app.config.proxyAPIenabled) // uncheck Streaming in OAI mode

                        // Create/classify switch
                        const switchSpan = dom.create.elem('span', { class: 'track' }),
                              knobSpan = dom.create.elem('span', { class: 'knob' })

                        // Append elems
                        switchSpan.append(knobSpan) ; settingEntry.append(settingToggle, switchSpan)

                        // Update visual state w/ animation
                        setTimeout(() => modals.settings.toggle.updateStyles(settingToggle), 155)

                        // Add click listener
                        settingEntry.onclick = () => {
                            if (!(key == 'streamingDisabled' // visually switch toggle if not Streaminng...
                                && ( // ...in unsupported env...
                                    !env.scriptManager.supportsStreaming || !app.config.proxyAPIenabled )
                            )) modals.settings.toggle.switch(settingToggle)

                            // Call specialized toggle funcs
                            const autoGenMatch = /get|summarize/i.exec(key),
                                  manualGenMatch = /(?:suf|pre)fix/i.exec(key)
                            if (key.includes('proxy')) toggle.proxyMode()
                            else if (key.includes('streaming')) toggle.streaming()
                            else if (key.includes('rq')) toggle.relatedQueries()
                            else if (autoGenMatch) toggle.autoGen(autoGenMatch[0].toLowerCase())
                            else if (manualGenMatch) toggle.manualGen(manualGenMatch[0].toLowerCase())
                            else if (key.includes('Sidebar')) toggle.sidebar(key.replace('Sidebar', ''))
                            else if (key.includes('anchor')) toggle.anchorMode()
                            else if (key.includes('bgAnimation')) toggle.animations('bg')
                            else if (key.includes('fgAnimation')) toggle.animations('fg')

                            // ...or generically toggle/notify
                            else {
                                settings.save(key, !app.config[key]) // update config
                                feedback.notify(`${settings.controls[key].label} ${
                                    menus.toolbar.state.words[+(key.includes('Disabled') != app.config[key])]}`)
                            }
                        }

                    // Add .active + config status + listeners to pop-up settings
                    } else {
                        settingEntry.classList.add('active')
                        const configStatusSpan = dom.create.elem('span', {
                            style: `float: right ; font-size: 11px ; margin-top: 3px ;${
                                key != 'about' ? 'text-transform: uppercase !important' : '' }`
                        })
                        ;({
                            about: () => {
                                const innerDiv = dom.create.elem('div'), xGap = '&emsp;&emsp;&emsp;&emsp;&emsp;'
                                modals.settings.aboutContent = {
                                    short: `v${GM_info.script.version}`,
                                    long: `${app.msgs.about_version}: <span class="about-em">v${
                                            GM_info.script.version + xGap }</span>${
                                            app.msgs.about_poweredBy} <span class="about-em">chatgpt.js</span>${xGap}`
                                }
                                for (let i = 0; i < 7; i++)
                                    modals.settings.aboutContent.long += modals.settings.aboutContent.long // make long af
                                innerDiv.innerHTML = modals.settings.aboutContent[
                                    app.config.fgAnimationsDisabled ? 'short' : 'long']
                                innerDiv.style.float = app.config.fgAnimationsDisabled ? 'right' : ''
                                configStatusSpan.append(innerDiv) ; settingEntry.onclick = () => modals.open('about')
                            },
                            preferredAPI: () => {
                                configStatusSpan.textContent = app.config.preferredAPI || app.msgs.menuLabel_random
                                settingEntry.onclick = () => modals.open('api')
                                settingEntry.classList.toggle('active', app.config.proxyAPIenabled)
                                settingEntry.style.pointerEvents = app.config.proxyAPIenabled ? '' : 'none'
                            },
                            replyLang: () => {
                                configStatusSpan.textContent = app.config.replyLang
                                settingEntry.onclick = () => modals.open('replyLang')
                            },
                            scheme: () => {
                                modals.settings.updateSchemeStatus(configStatusSpan)
                                settingEntry.onclick = () => modals.open('scheme')
                            }
                        })[key]?.()
                        settingEntry.append(configStatusSpan)
                    }
                })
                settingsListContainer.append(...settingsLists)

                // Create close button
                const closeBtn = dom.create.elem('div',
                    { title: app.msgs.tooltip_close, class: `${app.slug}-modal-close-btn no-mobile-tap-outline` })
                closeBtn.append(icons.create({ key: 'x', size: 11 }))

                // Assemble/append elems
                settingsModal.append(settingsIcon, settingsTitleDiv, closeBtn, settingsListContainer)
                document.body.append(settingsContainer)

                return settingsContainer
            },

            get() { return document.getElementById(`${app.slug}-settings`) },

            show() {
                modals.settings.stylize()
                const settingsContainer = modals.settings.get()?.parentNode || modals.settings.createAppend()
                settingsContainer.style.display = '' // show modal
                if (env.browser.isCompact) { // scale 93% to viewport sides
                    const settingsModal = settingsContainer.querySelector(`#${app.slug}-settings`)
                    modals.settings.scaleRatio ||= 0.93 * innerWidth / settingsModal.scrollWidth
                    settingsModal.style.transform = `scale(${modals.settings.scaleRatio})`
                }
                return settingsContainer.firstChild
            },

            stylize() {
                const { scheme: appScheme } = env.ui.app
                if (!this.styles?.isConnected) document.head.append(this.styles ||= dom.create.style())
                this.styles.textContent = `
                    #${app.slug}-settings {
                        min-width: ${ env.browser.isCompact ? 288 : 698 }px ; max-width: 75vw ; word-wrap: break-word ;
                        margin: 12px 23px ; border-radius: 15px ;
                        ${ appScheme == 'dark' ? 'stroke: white ; fill: white' : 'stroke: black ; fill: black' };
                      --shadow: 0 30px 60px rgba(0,0,0,0.12) ; box-shadow: var(--shadow) ;
                           -webkit-box-shadow: var(--shadow) ; -moz-box-shadow: var(--shadow)
                    }
                    #${app.slug}-settings-title {
                        font-weight: bold ; line-height: 19px ; text-align: center ;
                        margin: 0 -6px ${ env.browser.isCompact ? 2 : -15 }px 0
                    }
                    #${app.slug}-settings-title h4 {
                        font-size: ${ env.browser.isCompact ? 22 : 29 }px ; font-weight: bold ;
                        margin: 0 0 ${ env.browser.isCompact ? 9 : 27 }px
                    }
                    #${app.slug}-settings ul {
                        align-content: center ; /* for symmetrized gaps when odd num of entries */
                        list-style: none ; padding: 0 ; margin-bottom: 2px ; /* hide bullets, close bottom gap */
                        width: ${ env.browser.isCompact ? 100 : 50 }% /* set width based on column cnt */
                    }
                    ${ env.browser.isCompact ? '' : `#${app.slug}-settings ul:first-of-type { /* color desktop middle sep */
                        border-right: 1px dotted ${ appScheme == 'dark' ? 'white' : 'black' }}`}
                    #${app.slug}-settings li {
                        color: ${ appScheme == 'dark' ? 'rgb(255,255,255,0.65)' : 'rgba(0,0,0,0.45)' };
                        fill: ${ appScheme == 'dark' ? 'rgb(255,255,255,0.65)' : 'rgba(0,0,0,0.45)' };
                        stroke: ${ appScheme == 'dark' ? 'rgb(255,255,255,0.65)' : 'rgba(0,0,0,0.45)' };
                        height: 24px ; padding: 6px 10px ; font-size: 13.5px ;
                        border-bottom: 1px dotted ${ appScheme == 'dark' ? 'white' : 'black' }; /* add separators */
                        border-radius: 3px ; /* slightly round highlight strip */
                        ${ app.config.fgAnimationsDisabled || env.browser.isMobile ? '' :
                            `transition: var(--settings-li-transition) ;
                                -webkit-transition: var(--settings-li-transition) ;
                                -moz-transition: var(--settings-li-transition) ;
                                -o-transition: var(--settings-li-transition) ;
                                -ms-transition: var(--settings-li-transition)` }
                    }
                    #${app.slug}-settings li.active {
                        color: ${ appScheme == 'dark' ? 'rgb(255,255,255)' : 'rgba(0,0,0)' };
                        fill: ${ appScheme == 'dark' ? 'rgb(255,255,255)' : 'rgba(0,0,0)' };
                        stroke: ${ appScheme == 'dark' ? 'rgb(255,255,255)' : 'rgba(0,0,0)' }
                    }
                    #${app.slug}-settings li label { padding-right: 20px } /* right-pad labels so toggles don't hug */
                    #${app.slug}-settings li:last-of-type { border-bottom: none } /* remove last bottom-border */
                    #${app.slug}-settings :where(li, li label) { cursor: pointer } /* add finger on hover */
                    #${app.slug}-settings li:hover {
                        background: rgba(100,149,237,0.88) ; color: white ; fill: white ; stroke: white ;
                        ${ env.browser.isMobile ? '' : 'transform: scale(1.15)' }
                    }
                    #${app.slug}-settings li > input { float: right } /* pos toggles */
                    #${app.slug}-settings li > .track {
                        position: relative ; left: -1px ; bottom: -5.5px ; float: right ;
                        background-color: #ccc ; width: 26px ; height: 13px ; border-radius: 28px ;
                        ${ app.config.fgAnimationsDisabled ? '' :
                            `transition: 0.4s ; -webkit-transition: 0.4s ; -moz-transition: 0.4s ;
                                -o-transition: 0.4s ; -ms-transition: 0.4s` }
                    }
                    #${app.slug}-settings li .knob {
                        position: absolute ; left: 1px ; bottom: 1px ; content: "" ;
                        background-color: white ; width: 11px ; height: 11px ; border-radius: 28px ;
                        ${ app.config.fgAnimationsDisabled ? '' :
                            `transition: 0.2s ; -webkit-transition: 0.2s ; -moz-transition: 0.2s ;
                                -o-transition: 0.2s ; -ms-transition: 0.2s` }
                    }
                    #scheme-settings-entry > span { margin: 3px -2px 0 } /* align Scheme status */
                    #scheme-settings-entry > span > svg { /* v-align/left-pad Scheme status icon */
                        position: relative ; top: 2px ; margin-left: 4px }
                    ${ app.config.fgAnimationsDisabled ? '' // spin cycle arrows icon when scheme is Auto
                        : `#scheme-settings-entry svg[class*=arrowsCyclic],
                                .chatgpt-notif svg[class*=arrowsCyclic] { animation: rotate 5s linear infinite }`
                    }
                    #about-settings-entry span { color: ${ appScheme == 'dark' ? '#28ee28' : 'green' }}
                    #about-settings-entry > span { /* outer About status span */
                        width: ${ env.browser.isCompact ? '15vw' : '95px' }; height: 20px ; overflow: hidden ;
                        ${ env.browser.isCompact ? 'position: relative ; bottom: 3px ;' : '' } /* v-align */
                        ${ app.config.fgAnimationsDisabled ? '' : // fade edges
                                `mask-image: linear-gradient(
                                    to right, transparent, black 20%, black 89%, transparent) ;
                        -webkit-mask-image: linear-gradient(
                                    to right, transparent, black 20%, black 89%, transparent) ;`}
                    }
                    #about-settings-entry > span > div {
                        text-wrap: nowrap ;
                        ${ app.config.fgAnimationsDisabled ? '' : 'animation: ticker linear 75s infinite' }
                    }
                    @keyframes ticker { 0% { transform: translateX(100%) } 100% { transform: translateX(-2000%) }}
                    .about-em { color: ${ appScheme == 'dark' ? 'white' : 'green' } !important }`
            },

            toggle: {
                switch(settingToggle) {
                    settingToggle.checked = !settingToggle.checked
                    modals.settings.toggle.updateStyles(settingToggle)
                },

                updateStyles(settingToggle) { // for .toggle.show() + staggered switch animations in .createAppend()
                    const settingLi = settingToggle.parentNode,
                          switchSpan = settingLi.querySelector('span'),
                          knobSpan = switchSpan.querySelector('span')
                    requestAnimationFrame(() => {
                        switchSpan.style.backgroundColor = settingToggle.checked ? '#ad68ff' : '#ccc'
                        switchSpan.style.boxShadow = settingToggle.checked ? '2px 1px 9px #d8a9ff' : 'none'
                        knobSpan.style.transform = settingToggle.checked ?
                            'translateX(14px) translateY(0)' : 'translateX(0)'
                        settingLi.classList.toggle('active', settingToggle.checked) // dim/brighten entry
                    }) // to trigger 1st transition fx
                }
            },

            updateSchemeStatus(schemeStatusSpan = null) {
                schemeStatusSpan ||= document.querySelector('#scheme-settings-entry span')
                if (schemeStatusSpan) {
                    schemeStatusSpan.textContent = ''
                    schemeStatusSpan.append( // status txt + icon
                        document.createTextNode(app.msgs[/dark|light/.test(app.config.scheme) ? `scheme_${app.config.scheme}`
                                                                                          : 'menuLabel_auto']),
                        icons.create({ size: 12,
                            key: app.config.scheme == 'dark' ? 'moon' : app.config.scheme == 'light' ? 'sun' : 'arrowsCyclic' })
                    )
                }
            }
        },

        shareChat(shareURL) {

            // Show modal
            const shareChatModal = modals.alert(
                `${log.toTitleCase(app.msgs.btnLabel_convo)} ${app.msgs.tooltip_page} ${ // title
                    app.msgs.alert_generated.toLowerCase()}!`,
                `<a target="_blank" rel="noopener" href="${shareURL}">${shareURL}</a>`, // link msg
                [ // buttons
                    function copyUrl() {
                        navigator.clipboard.writeText(shareURL)
                            .then(() => feedback.notify(app.msgs.notif_copiedToClipboard))
                    },
                    function visitPage() { modals.safeWinOpen(shareURL) },
                    function downloadChat() {
                        xhr({
                            method: 'GET', url: shareURL,
                            onload: ({ responseText }) => {
                                const download = responseText.match(/<title>([^<]+)<\/title>/i)[1]
                                    .replace(/\s*[—|/]+\s*/g, ' ') // convert symbols to space for hyphen-casing
                                    .replace(/\.{2,}/g, '') // strip ellipsis
                                    .toLowerCase().trim().replace(/\s+/g, '-') // hyphen-case
                                    + '.html'
                                const href = URL.createObjectURL(new Blob([responseText], { type: 'text/html' }))
                                const a = dom.create.anchor(href, '', { download, style: 'display: none' })
                                document.body.append(a) ; a.click() ; a.remove() ; URL.revokeObjectURL(href)
                            },
                            onerror: err => log.error('Failed to download chat:', err)
                        })
                    }
                ]
            )

            // Prefix icon to title
            const modalTitle = shareChatModal.querySelector('h2'), titleIcon = icons.create({ key: 'speechBalloons' })
            titleIcon.style.cssText = `height: 23px ; width: 23px ; position: relative ; top: 5px ; right: 8px ;
                                       fill: ${ env.ui.app.scheme == 'dark' ? 'white' : 'black' }`
            modalTitle.prepend(titleIcon)

            // Hide Dismiss button, localize other labels
            const modalBtns = shareChatModal.querySelectorAll('button')
            modalBtns[0].style.display = 'none' // hide Dismiss button
            if (!env.browser.language.startsWith('en')) // localize button labels
                modalBtns.forEach(btn => {
                    if (/copy/i.test(btn.textContent)) btn.textContent = `${app.msgs.tooltip_copy} URL`
                    else if (/visit/i.test(btn.textContent)) btn.textContent = app.msgs.btnLabel_visitPage
                    else if (/download/i.test(btn.textContent))
                         btn.textContent = `${app.msgs.btnLabel_download} ${log.toTitleCase(app.msgs.btnLabel_convo)}`
                })

            // Style elements
            shareChatModal.style.wordBreak = 'break-all' // since URL really long
            shareChatModal.querySelector('h2').style.justifySelf = 'center'
            shareChatModal.querySelector('p').style.cssText = 'text-align: center ; margin: 10px 0 -22px'
            shareChatModal.querySelector('.modal-buttons').style.cssText = 'justify-content: center'

            return shareChatModal
        },

        stylize() {
            const { scheme: appScheme } = env.ui.app
            if (!this.styles?.isConnected) document.head.append(this.styles ||= dom.create.style())
            this.styles.textContent = (

                // Vars
                `:root {
                  --modal-btn-zoom: scale(1.055) ; --modal-btn-transition: transform 0.15s ease ;
                  --settings-li-transition: transform 0.1s ease ; /* for Settings entry hover-zoom */
                  --fg-transition: opacity 0.65s cubic-bezier(0.165,0.84,0.44,1), /* fade-in */
                                   transform 0.55s cubic-bezier(0.165,0.84,0.44,1) !important ; /* move-in */
                  --bg-transition: background-color 0.25s ease !important } /* dim */`

                // Main modal styles
              + `@keyframes modal-zoom-fade-out {
                    0% { opacity: 1 } 50% { opacity: 0.25 ; transform: scale(1.05) }
                    100% { opacity: 0 ; transform: scale(1.35) }
                }
                .chatgpt-modal > div {
                    background-color: white ; color: #202124 ;
                    padding: ${ env.browser.isCompact ? '31px' : '25px 31px' }!important
                }
                .chatgpt-modal p { margin: 14px 0 -29px 4px ; font-size: 1.28em ; line-height: 1.57 }
                .modal-buttons {
                    margin: 42px 4px ${ env.browser.isMobile ? '2px 4px' : '-3px -4px' } !important ; width: 100% }
                .chatgpt-modal button { /* this.alert() buttons */
                    min-width: 113px ; padding: ${ env.browser.isMobile ? '5px' : '4px 15px' } !important ;
                    cursor: pointer ; border-radius: 0 !important ; height: 39px ;
                    border: 1px solid ${ appScheme == 'dark' ? 'white' : 'black' }!important }
                .primary-modal-btn { background: black !important ; color: white !important }
                .chatgpt-modal button:hover {
                  --btn-shadow: ${ appScheme == 'light' ? '2px 1px 43px #00cfff70' : '2px 1px 54px #00cfff' };
                    color: inherit !important ; /* remove color hack */
                    background-color: rgb(${ appScheme == 'light' ? '192 223 227 / 5%' : '43 156 171 / 43%' }) !important
                }
                ${ appScheme == 'dark' ? // darkmode chatgpt.alert() styles
                    `.chatgpt-modal > div, .chatgpt-modal button:not(.primary-modal-btn) {
                        color: white !important }
                    .primary-modal-btn { background: #00cfff !important ; color: black !important }
                    .chatgpt-modal a { color: #00cfff !important }` : ''
                }
                .${modals.class} { display: grid ; place-items: center } /* for centered icon/logo */
                [class*=modal-close-btn] {
                    position: absolute !important ; float: right ; top: 14px !important ; right: 16px !important ;
                    cursor: pointer ; width: 33px ; height: 33px ; border-radius: 20px
                }
                [class*=modal-close-btn] path {${ appScheme == 'dark' ? 'stroke: white ; fill: white'
                                                                      : 'stroke: #9f9f9f ; fill: #9f9f9f' }}
                ${ appScheme == 'dark' ? // invert dark mode hover paths
                    '[class*=modal-close-btn]:hover path { stroke: black ; fill: black }' : '' }
                [class*=modal-close-btn]:hover { background-color: #f2f2f2 } /* hover underlay */
                [class*=modal-close-btn] svg { margin: 11.5px } /* center SVG for hover underlay */
                [class*=-modal] h2 {
                    font-size: 1.65rem ; line-height: 32px ; padding: 0 ; margin: 9px 0 -3px !important ;
                    ${ env.browser.isMobile ? 'text-align: center' // center on mobile
                                            : 'justify-self: start' }} /* left-align on desktop */
                [class*=-modal] p { justify-self: start ; font-size: 20px }
                [class*=-modal] button {
                    color: ${ appScheme == 'dark' ? 'white' : 'black' }; font-size: 12px !important ; background: none }
                [class*=-modal-bg] {
                    pointer-events: auto ; /* override any disabling from site modals */
                    position: fixed ; top: 0 ; left: 0 ; width: 100% ; height: 100% ; /* expand to full view-port */
                    display: flex ; justify-content: center ; align-items: center ; z-index: 9999 ; /* align */
                    transition: var(--bg-transition) ; /* dim */
                       -webkit-transition: var(--bg-transition) ; -moz-transition: var(--bg-transition) ;
                       -o-transition: var(--bg-transition) ; -ms-transition: var(--bg-transition) }
                [class*=-modal-bg].animated > div {
                    z-index: 13456 ; opacity: 0.98 ; transform: translateX(0) translateY(0) }
                [class$=-modal] { /* native modals + chatgpt.alert()s */
                    position: absolute ; /* to be click-draggable */
                    opacity: 0 ; /* to fade-in */
                    background-image: linear-gradient(180deg, ${
                       appScheme == 'dark' ? '#99a8a6 -200px, black 200px' : '#b6ebff -296px, white 171px' }) ;
                    border: 1px solid ${ appScheme == 'dark' ? 'white' : '#b5b5b5' } !important ;
                    color: ${ appScheme == 'dark' ? 'white' : 'black' };
                    transform: translateX(-3px) translateY(7px) ; /* offset to move-in from */
                    transition: var(--fg-transition) ; /* fade-in + move-in */
                       -webkit-transition: var(--fg-transition) ; -moz-transition: var(--fg-transition) ;
                       -o-transition: var(--fg-transition) ; -ms-transition:  var(--fg-transition) }
                    ${ env.browser.isMobile ? '' : `[class$=-modal] button:hover { transform: var(--modal-btn-zoom) }`}
                    ${ app.config.fgAnimationsDisabled ? '' : `[class$=-modal] button {
                        ${ env.browser.isMobile ? '' : 'will-change: transform ;' }
                        transition: var(--modal-btn-transition) ;
                           -webkit-transition: var(--modal-btn-transition) ;
                           -moz-transition: var(--modal-btn-transition) ;
                           -o-transition: var(--modal-btn-transition) ;
                           -ms-transition: var(--modal-btn-transition) }`}`
            )
        },

        update: {
            width: 377,

            available() {
                const updateAvailModal = modals.alert(`🚀 ${app.msgs.alert_updateAvail}!`, // title
                    `${app.msgs.alert_newerVer} ${app.name} ` // msg
                        + `(v${app.latestVer}) ${app.msgs.alert_isAvail}!  `
                        + '<a target="_blank" rel="noopener" style="font-size: 0.97rem" href="'
                            + `${app.urls.github}/commits/main/greasemonkey/${app.slug}.user.js`
                        + `">${app.msgs.link_viewChanges}</a>`,
                    function update() { // button
                        modals.safeWinOpen(`${app.urls.update.gm}?t=${Date.now()}`)
                    }, '', modals.update.width
                )
                if (!env.browser.language.startsWith('en')) { // localize button labels
                    const updateBtns = updateAvailModal.querySelectorAll('button')
                    updateBtns[1].textContent = app.msgs.btnLabel_update
                    updateBtns[0].textContent = app.msgs.btnLabel_dismiss
                }
                return updateAvailModal
            },

            unavailable() {
                return modals.alert(`${app.msgs.alert_upToDate}!`, // title
                    `${app.name} (v${app.version}) ${app.msgs.alert_isUpToDate}!`, // msg
                    '', '', modals.update.width
                )
            }
        }
    }

    // Run MAIN routine

    menus.toolbar.register()

    if (/udm=2(?:&|$)/.test(location.search)) return log.debug('Exited from Google Images')

    // Init UI props
    env.ui = {
        app: { scheme: app.config.scheme || ui.getScheme() },
        site: { hasSidebar: !!document.querySelector('[class*=kp-]'), scheme: ui.getScheme() }
    }

    if (!app.config.aiSafetyWarned) {
        modals.alert('⚠️ Important Notice:',
            `<b>${app.name}</b> is powered by AI technology. While designed to be helpful:\n\n`
                + '• <b>AI can make mistakes</b> - Always verify important information\n'
                + `• <b>Double-check critical decisions</b> - Don't rely solely on AI advice\n`
                + '• <b>Not a substitute</b> - For professional, medical, or legal matters\n\n'
                + 'Use responsibly!',
            null, null, 388
        )
        settings.save('aiSafetyWarned', true)
    }

    // Create/ID/classify/listenerize/stylize APP container
    app.div = dom.create.elem('div', { id: app.slug, class: 'fade-in' })
    themes.apply(app.config.theme) ; ui.addListeners.appDiv()
    ;['anchored', 'expanded', 'sticky', 'wider'].forEach(mode =>
        (app.config[mode] || app.config[`${mode}Sidebar`]) && app.div.classList.add(mode))
    update.appStyle()
    ;['rpg', 'rpw'].forEach(cssType => // rising particles
        document.head.append(dom.create.style(GM_getResourceText(`${cssType}CSS`))))

    // APPEND to Google
    app.centerCol = document.querySelector('#center_col') || document.querySelector('#main')
    const appDivParent = env.browser.isMobile ? app.centerCol
        : document.getElementById('rhs') // sidebar container if side snippets exist
        || (() => { // create new one if no side snippets exist
               const appDivParent = dom.create.elem('div')
               app.centerCol.insertAdjacentElement('afterend', appDivParent)
               return appDivParent
           })()
    appDivParent.prepend(app.div)
    setTimeout(() => app.div.classList.add('active'), 100) // fade in

    // ANCHOR GoogleGPT in Google AI Mode
    if (/udm=50(?:&|$)/.test(location.search)) {
        toggle.anchorMode('on')
        if (!env.browser.isMobile) { // hide Pin button + Anchor Mode setting
            dom.get.loadedElem(`#${app.slug}-pin-btn`).then(btn => btn.style.display = 'none')
            document.head.append(dom.create.style('li#anchored-settings-entry { display: none }'))
        }

    // Strip Google TRACKING
    } else document.addEventListener(inputEvents.down, event => {
        let a = event.target ; while (a && !a.href) a = a.parentElement ; if (!a) return // find closest ancestor href
        a.removeAttribute('ping') // prevent pingback on link click
        if (a.getAttribute('onmousedown')?.includes('rwt(')) {
            a.removeAttribute('onmousedown')
            if (env.browser.isChrome) event.stopImmediatePropagation() // since inline listener still runs
        }
        let realURL = getRealURL(a)
        if (realURL) {
            a.href = realURL
            realURL = getRealURL(a) ; if (realURL) a.href = realURL // do again for old mobile UA
        }

        function getRealURL(a) {
            if (!a.protocol.startsWith('http')) return
            let url
            if ((a.hostname.startsWith('www.google.') || a.hostname == location.hostname) &&
               ['/url', // mobile: /url?q=<url>
                '/local_url', // Maps/Dito: /local_url?q=<url>
                '/searchurl/rr.html', '/linkredirect'].includes(a.pathname)) {
                    url = /[?&](?:q|url|dest)=((?:https?|ftp)[%:][^&]+)/.exec(a.search) // HTTP/FTP URLs
                    if (url) return decodeURIComponent(url[1])
                    url = /[?&](?:q|url)=((?:%2[Ff]|\/)[^&]+)/.exec(a.search) // help pages, e.g. safe browsing (/url?...&q=%2Fsupport%2Fanswer...)
                    if (url) return a.origin + decodeURIComponent(url[1])
                    url = /[#&]url=(https?[:%][^&]+)/.exec(a.hash) // Android intents (/searchurl/rr.html#...&url=...)
                    if (url) return decodeURIComponent(url[1])
            }
            if (a.hostname == 'googleweblight.com' && a.pathname == '/fp') { // Google Search w/ old mobile UA (e.g. Firefox 41)
                url = /[?&]u=((?:https?|ftp)[%:][^&]+)/.exec(a.search)
                if (url) return decodeURIComponent(url[1])
            }
        }
    }, true) // invoke during capturing phase

    // Init footer CTA to share feedback
    app.footerContent = dom.create.anchor(app.urls.discuss, app.msgs.link_shareFeedback)

    // AUTO-GEN reply or show STANDBY mode
    app.msgChain = [] ; const searchQuery = new URL(location.href).searchParams.get('q')
    if (app.config.autoGet || app.config.autoSummarize // Auto-Gen on
        || (app.config.prefixEnabled || app.config.suffixEnabled) // or Manual-Gen on
            && [app.config.prefixEnabled && location.href.includes('q=%2F'), // prefix required/present
                app.config.suffixEnabled // suffix required/present
                    && /q=.*?(?:%3F|?|%EF%BC%9F)(?:&|$)/.test(location.href)
            ].filter(Boolean).length == (app.config.prefixEnabled + app.config.suffixEnabled) // validate both Manual-Gen modes
    ) { // auto-gen reply
        app.msgChain.push({
            time: Date.now(), role: 'user',
            content: app.config.autoSummarize ? prompts.create('summarizeResults') : searchQuery
        })
        get.reply({ msgs: app.msgChain, src: 'query' })
    } else { // show Standby mode
        show.reply({ standby: true })
        if (!app.config.rqDisabled)
            get.related(searchQuery)
                .then(queries => show.related(queries))
                .catch(err => { log.error(err.message) ; api.tryNew(get.related) })
    }

    // Observe DOM for new sidebar div#rhs created by other extensions to INSERT GoogleGPT to visually co-exist
    const sidebarObserver = new MutationObserver(() => {
        const newSidebar = document.getElementById('rhs')
        if (newSidebar) { newSidebar.prepend(app.div) ; sidebarObserver.disconnect() }
    })
    sidebarObserver.observe(document.body, { subtree: true, childList: true })
    setTimeout(() => sidebarObserver.disconnect(), 5000) // don't observe forever

})()