@allansantos-dev/smart-tool 0.9.45 → 0.9.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.47 - beta
4
+
5
+ - The update window names the browser check ("Checking the browsers…") while the installer verifies Camoufox and
6
+ Chromium, the longest step of an update; before, it kept saying "Preparing Python…" until the end.
7
+ - Update notifications carry a button. "Smart Tool updated from X to Y" opens what changed (the product page); a failed
8
+ update, on the update window's toast and on the tray's notice at the next start, opens the update log. Both are
9
+ fixed actions of `notice_action.py` (`open-changes`, `open-update-log`), never a link taken from the toast.
10
+
11
+ ## 0.9.46 - beta
12
+
13
+ - The interface speaks Portuguese and English. It follows the Windows display language (any Portuguese shows Brazilian
14
+ Portuguese, anything else English) until the user picks one: a globe button (PT/EN) in the header of the settings,
15
+ projects and map pages, and an "Idioma / Language" submenu in the tray, both saved as `language` in the config. The
16
+ settings and projects pages, including the text their scripts write, the tray menu, tooltip and notifications, the
17
+ Windows toasts, the folder picker and the update screen are translated; agent-facing text (tool answers, hook
18
+ messages) stays English. English is the catalog key (`locales/pt-BR.json`, read by `i18n.py`); the pages translate
19
+ their rendered text in the browser, and `tests/test_i18n.py` fails when a visible string has no entry.
20
+ - Updating never opens a console window. "Check for updates" now says "Checking for updates…" in a Windows notification,
21
+ and an update shows its own small window with no buttons (`update_splash.py`): the version change, the current step
22
+ (downloading, copying files, installing dependencies…) and the result, in the interface language. The install command
23
+ runs with no console, so npm, the installer, pip, PowerShell and schtasks share one hidden console; its output goes to
24
+ `~/.smart-tool/data/update.log`, and a failed update says so on the screen and in a toast with the log path.
25
+ - The tray menu no longer flashes console windows when it opens: its "Start with Windows" check ran `schtasks` from the
26
+ window-less tray without `CREATE_NO_WINDOW`.
27
+ - The tray tells "Smart Tool updated from X to Y" also when the daemon started first after the update: both record the
28
+ new running version, and the tray keeps its own marker (`tray_told`) instead of needing to be the one that found it.
29
+
3
30
  ## 0.9.45 - beta
4
31
 
5
32
  - The Playwright MCP server starts on first use. What the clients register is now `lazy_mcp_proxy.py` around
package/README.md CHANGED
@@ -17,7 +17,7 @@ registered as the `playwright` server where this machine lets a browser be drive
17
17
  An optional hook routes the agent's native Grep/Glob/Read/Bash/WebSearch/WebFetch calls to these tools when they
18
18
  are cheaper, or just tells the agent what Smart Tool would do.
19
19
 
20
- **Status:** 0.9.45 beta. Windows only. What changed in each version: [CHANGELOG.md](CHANGELOG.md).
20
+ **Status:** 0.9.47 beta. Windows only. What changed in each version: [CHANGELOG.md](CHANGELOG.md).
21
21
 
22
22
  ## How it works
23
23
 
@@ -87,12 +87,17 @@ npx @allansantos-dev/smart-tool@latest install
87
87
  ```
88
88
 
89
89
  Updates install themselves, on by default: the tray checks npm when it starts (at logon) and every 24 h, and when there
90
- is a newer version it runs this same command in a visible console (once per version; a failed version is tried again
91
- after 24 h, with the previous version kept). You are told each time: a Windows notification from the tray (newer version,
90
+ is a newer version it runs this same command behind its own small update window, with no console window (once per
91
+ version; a failed version is tried again after 24 h, with the previous version kept). The window has no buttons: it shows
92
+ the version change, the current step and the result, and closes by itself; the command's output is kept in
93
+ `~/.smart-tool/data/update.log`. You are told each time: a Windows notification from the tray (newer version,
92
94
  updating, updated, update failed), and agent sessions get the same line on their first `smart_search` (and at session
93
95
  start when the SessionStart report is on, shown to you too) with the link to the changes. The tray menu has "Update to
94
96
  X" (or "Check for updates") on demand and an "Update automatically" switch; `SMART_TOOL_AUTO_UPDATE=off` also turns
95
- automatic updates off, and the newer-version notice then carries the command to run. Running the command by hand also updates; `~/.smart-tool` is kept. It downloads [uv](https://docs.astral.sh/uv/) (checksum
97
+ automatic updates off, and the newer-version notice then carries the command to run. The interface (tray, notifications,
98
+ settings and projects pages, update window) is in Brazilian Portuguese or English: it follows the Windows display
99
+ language until you pick one with the globe button (PT/EN) at the top of the pages or the tray's "Idioma / Language"
100
+ submenu. Running the command by hand also updates; `~/.smart-tool` is kept. It downloads [uv](https://docs.astral.sh/uv/) (checksum
96
101
  verified) and a private Python 3.12 into `~/.smart-tool/runtime`, with nothing added to `PATH` and, in the registry, only two per-user (HKCU) keys that make the notification buttons work, then
97
102
  copies Smart Tool to `%LOCALAPPDATA%\Programs\SmartTool` with its own virtual environment,
98
103
  installs the web runtime, registers a per-user Scheduled Task that starts the tray at logon and starts the daemon on
package/autostart.py CHANGED
@@ -14,6 +14,8 @@ import tempfile
14
14
  import toast_notice
15
15
 
16
16
  TASK_NAME = os.environ.get("SMART_TOOL_TASK_NAME", "SmartTool-Tray")
17
+ # The tray runs under pythonw (no console): schtasks without this flag flashed a console each time its menu was drawn.
18
+ _NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
17
19
 
18
20
 
19
21
  class AutostartError(Exception):
@@ -138,7 +140,7 @@ def _task_installed():
138
140
  try:
139
141
  result = subprocess.run(
140
142
  ["schtasks", "/Query", "/TN", TASK_NAME],
141
- capture_output=True, text=True, timeout=20,
143
+ capture_output=True, text=True, timeout=20, creationflags=_NO_WINDOW,
142
144
  )
143
145
  except (OSError, subprocess.SubprocessError):
144
146
  return False
@@ -159,7 +161,7 @@ def install():
159
161
  f.write(xml.encode("utf-16"))
160
162
  result = subprocess.run(
161
163
  ["schtasks", "/Create", "/TN", TASK_NAME, "/XML", tmp_path, "/F"],
162
- capture_output=True, text=True, timeout=20,
164
+ capture_output=True, text=True, timeout=20, creationflags=_NO_WINDOW,
163
165
  )
164
166
  finally:
165
167
  try:
@@ -182,7 +184,7 @@ def remove():
182
184
  try:
183
185
  result = subprocess.run(
184
186
  ["schtasks", "/Delete", "/TN", TASK_NAME, "/F"],
185
- capture_output=True, text=True, timeout=20,
187
+ capture_output=True, text=True, timeout=20, creationflags=_NO_WINDOW,
186
188
  )
187
189
  if result.returncode == 0:
188
190
  removed.append("task")
package/client_hooks.py CHANGED
@@ -120,7 +120,7 @@ def _entry_changes(event, matcher, expected, groups):
120
120
  covered = str(group.get("matcher") or "").split("|")
121
121
  left_out = [tool for tool in matcher.split("|") if tool not in covered]
122
122
  if left_out:
123
- changes.append(f"Its {event} entry does not cover " + ", ".join(left_out) + ".")
123
+ changes.append(f"Its {event} entry does not cover {', '.join(left_out)}.")
124
124
  found = next(hook for hook in group["hooks"] if _our_hook(hook))
125
125
  if _target(found) != _target(expected):
126
126
  changes.append(f"It calls {_target(found)} instead of {_target(expected)}.")
package/config.py CHANGED
@@ -39,6 +39,7 @@ DEFAULT_CONFIG = {
39
39
  "duplicate_mode": "warn",
40
40
  "dependency_mode": "warn",
41
41
  "auto_update": True,
42
+ "language": "",
42
43
  "pattern_proposals": "on",
43
44
  "contact": "",
44
45
  "model_adapter": "",
@@ -132,6 +133,17 @@ def auto_update(cfg=None):
132
133
  return value
133
134
 
134
135
 
136
+ LANGUAGES = ("pt-BR", "en")
137
+
138
+
139
+ def language(cfg=None):
140
+ """Interface language the user picked ("pt-BR" or "en"), or "" to follow the Windows display language."""
141
+ value = (cfg if cfg is not None else load_config()).get("language", DEFAULT_CONFIG["language"])
142
+ if value not in ("", *LANGUAGES):
143
+ raise ValueError(f"Invalid language ({value!r}) in {CONFIG_PATH}: use one of {', '.join(LANGUAGES)} or \"\".")
144
+ return value
145
+
146
+
135
147
  def hook_mode(cfg=None):
136
148
  value = (cfg if cfg is not None else load_config()).get("hook_mode") or DEFAULT_CONFIG["hook_mode"]
137
149
  if value not in HOOK_MODES:
package/folder_picker.py CHANGED
@@ -6,14 +6,16 @@ import threading
6
6
  import urllib.request
7
7
  import webbrowser
8
8
 
9
+ import i18n
10
+
9
11
  _PICKER_LOCK = threading.Lock()
10
12
 
11
13
 
12
14
  def choose_folder():
13
15
  if os.name != "nt":
14
- raise RuntimeError("Native folder selection is only available on Windows; enter the folder path.")
16
+ raise RuntimeError(i18n.t("Native folder selection is only available on Windows; enter the folder path."))
15
17
  if not _PICKER_LOCK.acquire(blocking=False):
16
- raise ValueError("A folder picker is already open.")
18
+ raise ValueError(i18n.t("A folder picker is already open."))
17
19
  import ctypes
18
20
  from ctypes import wintypes
19
21
 
@@ -37,14 +39,14 @@ def choose_folder():
37
39
  label = ctypes.create_unicode_buffer(32768)
38
40
  info = BrowseInfo()
39
41
  info.pszDisplayName = ctypes.cast(label, wintypes.LPWSTR)
40
- info.lpszTitle = "Smart Tool — select the folder to index"
42
+ info.lpszTitle = i18n.t("Smart Tool — select the folder to index")
41
43
  info.ulFlags = 0x0001 | 0x0040 # Diretórios reais; diálogo redimensionável.
42
44
  selected = shell.SHBrowseForFolderW(ctypes.byref(info))
43
45
  if not selected:
44
46
  return None
45
47
  path = ctypes.create_unicode_buffer(32768)
46
48
  if not shell.SHGetPathFromIDListEx(selected, path, len(path), 0):
47
- raise ValueError("The selection is not an accessible folder.")
49
+ raise ValueError(i18n.t("The selection is not an accessible folder."))
48
50
  return path.value
49
51
  finally:
50
52
  if selected:
@@ -64,7 +66,7 @@ def register_from_tray():
64
66
  page = response.read().decode("utf-8")
65
67
  match = re.search(r'<meta name="setup-token" content="([a-zA-Z0-9_-]+)">', page)
66
68
  if not match:
67
- raise RuntimeError("Reopen Smart Tool to load the updated projects page.")
69
+ raise RuntimeError(i18n.t("Reopen Smart Tool to load the updated projects page."))
68
70
  request = urllib.request.Request(url + "/setup/projects", method="POST",
69
71
  headers={"Content-Type": "application/json", "X-Setup-Token": match.group(1)},
70
72
  data=json.dumps({"action": "register", "project_root": root}).encode("utf-8"))
package/i18n.py ADDED
@@ -0,0 +1,213 @@
1
+ """Interface language: Brazilian Portuguese or English, following the Windows display language until the user picks
2
+ one with the language button (config `language`).
3
+
4
+ Strings are written in English in the code and the English text is the catalog key (the gettext convention): a
5
+ missing Portuguese entry shows the English text instead of breaking the screen, and tests/test_i18n.py fails when a
6
+ user-visible string has no entry. The same catalog serves Python (tray, notifications, update screen) and the web
7
+ pages, where PAGE_SCRIPT translates the rendered DOM and whatever the page scripts insert afterwards.
8
+
9
+ Agent-facing text (MCP tool answers, hook messages) stays in English: agents read it, users do not."""
10
+ import json
11
+ import os
12
+ import re
13
+ import sys
14
+
15
+ import config
16
+
17
+ ROOT = os.path.dirname(os.path.abspath(__file__))
18
+ LOCALES_DIR = os.path.join(ROOT, "locales")
19
+ PORTUGUESE_PRIMARY_LANGID = 0x16
20
+ _CATALOGS = {}
21
+
22
+
23
+ def system_language():
24
+ """ "pt-BR" when the Windows display language is any Portuguese, else "en"."""
25
+ if sys.platform != "win32":
26
+ return "en"
27
+ import ctypes
28
+ return "pt-BR" if ctypes.windll.kernel32.GetUserDefaultUILanguage() & 0x3FF == PORTUGUESE_PRIMARY_LANGID else "en"
29
+
30
+
31
+ def language(cfg=None):
32
+ """The language the interface shows now: the user's choice, else the Windows display language."""
33
+ return config.language(cfg) or system_language()
34
+
35
+
36
+ def set_language(value):
37
+ """Saves the user's choice ("pt-BR" or "en") and returns it; raises ValueError for any other value."""
38
+ if value not in config.LANGUAGES:
39
+ raise ValueError(f"Unknown language {value!r}: use one of {', '.join(config.LANGUAGES)}.")
40
+ config.update_config(lambda cfg: cfg.__setitem__("language", value))
41
+ return value
42
+
43
+
44
+ def catalog(lang):
45
+ """English text -> translated text for lang; English itself has an empty catalog. Raises when the locale file is
46
+ missing or malformed: a broken install must not pass for an untranslated one."""
47
+ if lang == "en":
48
+ return {}
49
+ if lang not in _CATALOGS:
50
+ with open(os.path.join(LOCALES_DIR, f"{lang}.json"), encoding="utf-8") as stream:
51
+ data = json.load(stream)
52
+ messages = data["messages"]
53
+ if not isinstance(messages, dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in messages.items()):
54
+ raise ValueError(f"locales/{lang}.json: 'messages' must map text to text.")
55
+ _CATALOGS[lang] = messages
56
+ return _CATALOGS[lang]
57
+
58
+
59
+ _PLACEHOLDER = re.compile(r"\{(\w*)\}")
60
+ _WORD = re.compile(r"[A-Za-z]")
61
+ _SENTENCE_BREAK = re.compile(r"(?<=[.!?…])\s+(?=[A-Z])")
62
+
63
+
64
+ def _normal(text):
65
+ return " ".join(text.split())
66
+
67
+
68
+ def _fragment_key(key):
69
+ """Keys that may be replaced inside a longer text: two words or more, or one word ending in punctuation."""
70
+ return not _PLACEHOLDER.search(key) and (" " in key or (len(key) >= 4 and key[-1] in ".:!?…"))
71
+
72
+
73
+ def _bounded(fragment):
74
+ """Regex of a phrase that never matches inside a word or a file name: a boundary before it when it starts with a
75
+ letter, after it when it ends with a letter or sentence punctuation ("Saved." is not "Saved.txt"); phrases cut
76
+ from a sentence may start or end with other punctuation ("Accept selected (" before a count)."""
77
+ return ((r"(?<!\w)" if re.match(r"\w", fragment) else "") + re.escape(fragment)
78
+ + (r"(?!\w)" if re.search(r"[\w.:!?…]$", fragment) else ""))
79
+
80
+
81
+ class Translator:
82
+ """Translates finished English text with one catalog: the whole text, then a template with placeholders
83
+ ("Updated from {old} to {new}."), then each sentence on its own when there are several, then known phrases inside
84
+ it. PAGE_SCRIPT runs the same steps."""
85
+
86
+ def __init__(self, messages):
87
+ self.messages = {_normal(k): v for k, v in messages.items()}
88
+ self.templates = []
89
+ for key, value in self.messages.items():
90
+ literal = _PLACEHOLDER.sub("", key)
91
+ if _PLACEHOLDER.search(key) and len(_WORD.findall(literal)) >= 3:
92
+ parts = _PLACEHOLDER.split(key)
93
+ pattern = "".join(re.escape(part) if i % 2 == 0 else f"(?P<p{i // 2}>.+?)" for i, part in enumerate(parts))
94
+ names = parts[1::2]
95
+ self.templates.append((re.compile(f"^{pattern}$", re.S), names, value))
96
+ self.templates.sort(key=lambda item: -len(item[0].pattern))
97
+ fragments = sorted((k for k in self.messages if _fragment_key(k)), key=len, reverse=True)
98
+ self.fragments = (re.compile("|".join(_bounded(f) for f in fragments)) if fragments else None)
99
+
100
+ def __call__(self, text):
101
+ """text translated, or text unchanged when nothing in the catalog matches it."""
102
+ key = _normal(text)
103
+ if not key or not self.messages:
104
+ return text
105
+ if key in self.messages:
106
+ return self.messages[key]
107
+ for pattern, names, value in self.templates:
108
+ found = pattern.match(key)
109
+ if found:
110
+ values = {name: found.group(f"p{i}") for i, name in enumerate(names)}
111
+ return _PLACEHOLDER.sub(lambda m: self(values.get(m.group(1), m.group(0))), value)
112
+ sentences = _SENTENCE_BREAK.split(key)
113
+ if len(sentences) > 1:
114
+ return " ".join(self(sentence) for sentence in sentences)
115
+ if self.fragments:
116
+ return self.fragments.sub(lambda m: self.messages[m.group(0)], text)
117
+ return text
118
+
119
+
120
+ _TRANSLATORS = {}
121
+
122
+
123
+ def translator(lang=None):
124
+ """The Translator of lang (default: the current interface language)."""
125
+ lang = lang or language()
126
+ if lang not in _TRANSLATORS:
127
+ _TRANSLATORS[lang] = Translator(catalog(lang))
128
+ return _TRANSLATORS[lang]
129
+
130
+
131
+ def t(text, lang=None, **values):
132
+ """text (an English catalog key) in the interface language, with its {placeholders} filled from values."""
133
+ translated = translator(lang)(text)
134
+ return translated.format(**values) if values else translated
135
+
136
+
137
+ def N_(text):
138
+ """Marks text as a catalog key without translating it yet (gettext_noop): for tables built at import time,
139
+ translated with t() when shown."""
140
+ return text
141
+
142
+
143
+ def page_script(token, lang=None):
144
+ """The <script> the web pages carry in <head>: catalog, current language, the setup token the language button
145
+ sends, translator and language button."""
146
+ lang = lang or language()
147
+ data = {"lang": lang, "token": token, "messages": catalog(lang)}
148
+ return "<script>window.SMART_TOOL_I18N=" + json.dumps(data, ensure_ascii=False).replace("</", "<\\/") + ";</script>\n" \
149
+ + "<script>" + PAGE_SCRIPT + "</script>"
150
+
151
+
152
+ PAGE_SCRIPT = r"""
153
+ (()=>{
154
+ const I=window.SMART_TOOL_I18N,PH=/\{(\w*)\}/g,SKIP=new Set(['SCRIPT','STYLE','CODE','PRE','TEXTAREA','KBD','SAMP','svg','SVG']);
155
+ const INLINE=new Set(['STRONG','EM','B','I','A','CODE','BR','SPAN','SMALL','KBD','ABBR','SUP','SUB','U','MARK']);
156
+ const ATTRS=['title','placeholder','aria-label','alt'];
157
+ const norm=s=>s.replace(/\s+/g,' ').trim();
158
+ const esc=s=>s.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');
159
+ const messages={};for(const [k,v] of Object.entries(I.messages))messages[norm(k)]=v;
160
+ const templates=[];
161
+ for(const [k,v] of Object.entries(messages)){if(!/\{\w*\}/.test(k)||(k.replace(PH,'').match(/[A-Za-z]/g)||[]).length<3)continue;
162
+ const parts=k.split(PH),names=[];let src='';parts.forEach((p,i)=>{if(i%2){names.push(p);src+='(.+?)'}else src+=esc(p)});
163
+ templates.push([new RegExp('^'+src+'$','s'),names,v])}
164
+ templates.sort((a,b)=>b[0].source.length-a[0].source.length);
165
+ const frag=Object.keys(messages).filter(k=>!/\{\w*\}/.test(k)&&(k.includes(' ')||(k.length>=4&&/[.:!?…]$/.test(k)))).sort((a,b)=>b.length-a.length);
166
+ const bounded=f=>(/^\w/.test(f)?'(?<!\\w)':'')+esc(f)+(/[\w.:!?…]$/.test(f)?'(?!\\w)':'');
167
+ const fragRe=frag.length?new RegExp(frag.map(bounded).join('|'),'g'):null;
168
+ function tr(text){const key=norm(text);if(!key||I.lang==='en')return text;
169
+ if(key in messages){const lead=text.match(/^\s*/)[0],tail=text.match(/\s*$/)[0];return lead+messages[key]+tail}
170
+ for(const [re,names,v] of templates){const m=key.match(re);if(m){const vals={};names.forEach((n,i)=>vals[n]=m[i+1]);return v.replace(PH,(all,n)=>n in vals?tr(vals[n]):all)}}
171
+ const sentences=key.split(/(?<=[.!?…])\s+(?=[A-Z])/);if(sentences.length>1)return text.match(/^\s*/)[0]+sentences.map(tr).join(' ')+text.match(/\s*$/)[0];
172
+ return fragRe?text.replace(fragRe,m=>messages[m]):text}
173
+ const written=new WeakMap();
174
+ function skipped(el){return !el||!el.closest||el.closest('[data-no-i18n],script,style,noscript,code,pre,textarea,kbd,samp,svg')}
175
+ function translateElement(el){
176
+ if(SKIP.has(el.tagName)||skipped(el))return false;
177
+ for(const a of ATTRS){const v=el.getAttribute(a);if(v&&written.get(el)?.[a]!==v){const t=tr(v);if(t!==v){el.setAttribute(a,t);written.set(el,{...(written.get(el)||{}),[a]:t})}}}
178
+ if(el.childElementCount&&[...el.children].every(c=>INLINE.has(c.tagName)&&!c.id&&!Object.keys(c.dataset).length)&&[...el.childNodes].some(n=>n.nodeType===3&&n.data.trim())){
179
+ const key=norm(el.innerHTML);if(key in messages){el.innerHTML=messages[key];return true}}
180
+ return false}
181
+ function translateText(node){if(skipped(node.parentElement)||written.get(node)===node.data)return;const t=tr(node.data);if(t!==node.data){node.data=t;written.set(node,t)}}
182
+ function walk(root){if(I.lang==='en')return;
183
+ if(root.nodeType===3){translateText(root);return}
184
+ if(root.nodeType!==1)return;
185
+ if(translateElement(root))return;
186
+ const it=document.createTreeWalker(root,NodeFilter.SHOW_ELEMENT|NodeFilter.SHOW_TEXT,{acceptNode:n=>n.nodeType===1&&(SKIP.has(n.tagName)||n.hasAttribute('data-no-i18n'))?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_ACCEPT});
187
+ const nodes=[];for(let n=it.nextNode();n;n=it.nextNode())nodes.push(n);
188
+ const done=new Set();
189
+ for(const n of nodes){if([...done].some(d=>d.contains(n)))continue;if(n.nodeType===1){if(translateElement(n))done.add(n)}else translateText(n)}}
190
+ function applyTitle(){if(I.lang!=='en')document.title=tr(document.title)}
191
+ new MutationObserver(list=>{if(I.lang==='en')return;for(const m of list){
192
+ if(m.type==='characterData')translateText(m.target);
193
+ else if(m.type==='attributes')translateElement(m.target);
194
+ else for(const n of m.addedNodes)walk(n)}}).observe(document.documentElement,{subtree:true,childList:true,characterData:true,attributes:true,attributeFilter:ATTRS});
195
+ const nativeConfirm=window.confirm.bind(window),nativeAlert=window.alert.bind(window),nativePrompt=window.prompt.bind(window);
196
+ window.confirm=m=>nativeConfirm(tr(String(m)));window.alert=m=>nativeAlert(tr(String(m)));window.prompt=(m,d)=>nativePrompt(tr(String(m)),d);
197
+ window.smartToolTr=tr;
198
+ const GLOBE='<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c2.5 2.6 3.8 5.6 3.8 9s-1.3 6.4-3.8 9c-2.5-2.6-3.8-5.6-3.8-9S9.5 5.6 12 3z"/></svg>';
199
+ function paintButton(b){b.querySelector('span').textContent=I.lang==='en'?'EN':'PT';
200
+ const label=I.lang==='en'?'Mudar para português':'Switch to English';b.title=label;b.setAttribute('aria-label',label)}
201
+ function button(){const b=document.createElement('button');b.type='button';b.id='lang-toggle';b.setAttribute('data-no-i18n','');
202
+ b.innerHTML=GLOBE+'<span></span>';
203
+ b.style.cssText='display:inline-flex;align-items:center;gap:6px;margin-left:12px;padding:5px 10px;border-radius:999px;border:1px solid var(--line,var(--border,#c9ced6));background:var(--surface,#fff);color:var(--ink,#1b2430);font:600 13px/1 "Segoe UI",system-ui,sans-serif;cursor:pointer;flex:none';
204
+ paintButton(b);
205
+ b.addEventListener('click',async()=>{const next=I.lang==='en'?'pt-BR':'en';b.disabled=true;
206
+ try{const r=await fetch('/setup/language',{method:'POST',headers:{'Content-Type':'application/json','X-Setup-Token':I.token},body:JSON.stringify({language:next})});
207
+ const data=await r.json().catch(()=>({}));if(!r.ok)throw Error(data.error||('HTTP '+r.status));location.reload()}
208
+ catch(e){b.disabled=false;nativeAlert((I.lang==='en'?'Could not change the language: ':'Não foi possível trocar o idioma: ')+e.message)}});
209
+ return b}
210
+ document.addEventListener('DOMContentLoaded',()=>{document.documentElement.lang=I.lang;walk(document.body);applyTitle();
211
+ const header=document.querySelector('header');if(header)header.appendChild(button())});
212
+ })();
213
+ """