diff --git a/Makefile b/Makefile
index 41becc30..dbb9ef96 100644
--- a/Makefile
+++ b/Makefile
@@ -5,9 +5,10 @@ TARGETS = darwin/arm64 darwin/amd64 linux/amd64 linux/arm64 windows/amd64 windo
# The GUI links the platform webview through cgo, so it is built natively.
# `nogui` builds the terminal-only magpie, which cross-compiles anywhere.
+# macOS 12 is the oldest Go (1.25 and later) runs on; Info.plist says so too.
ifeq ($(shell uname -s),Darwin)
- export CGO_CFLAGS = -mmacosx-version-min=11.0
- export CGO_LDFLAGS = -mmacosx-version-min=11.0
+ export CGO_CFLAGS = -mmacosx-version-min=12.0
+ export CGO_LDFLAGS = -mmacosx-version-min=12.0
endif
# On Linux, GTK 3 and WebKitGTK 4.1: older distributions have them, where
# Wails' default GTK 4 and WebKitGTK 6 would leave them out.
diff --git a/build/darwin/Info.plist b/build/darwin/Info.plist
index c7787209..62296b9c 100644
--- a/build/darwin/Info.plist
+++ b/build/darwin/Info.plist
@@ -11,7 +11,7 @@
CFBundlePackageTypeAPPL
CFBundleShortVersionString@VERSION@
CFBundleVersion@VERSION@
- LSMinimumSystemVersion11.0
+ LSMinimumSystemVersion12.0
LSUIElement
NSHighResolutionCapable
CFBundleURLTypes
diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css
index 44ad04c1..a7ffb1ff 100644
--- a/internal/gui/assets/app.css
+++ b/internal/gui/assets/app.css
@@ -278,11 +278,13 @@ body.window .row .ic { width: 22px; height: 22px; }
body.window .ag-handle { width: 28px; height: 28px; }
.ag-handle > .ic, .ag-handle > .grip { grid-area: 1 / 1; transition: opacity .15s, transform .2s cubic-bezier(.22, 1, .36, 1); }
.ag-handle > .grip { display: grid; place-items: center; color: var(--muted); opacity: 0; transform: scale(.8); }
-.ag-handle:hover, .ag-handle.open, .ag-handle:focus-visible { background: var(--pill); }
-.ag-handle:focus-visible { outline: 1.5px solid var(--fg-2); outline-offset: 0; }
+.ag-handle:hover, .ag-handle.open { background: var(--pill); }
+.ag-handle:focus-visible { background: var(--pill); outline: 1.5px solid var(--fg-2); outline-offset: 0; }
.agent-fold .ag-handle { cursor: default; }
-#agents > .row .ag-handle:is(:hover, .open, :focus-visible) > .ic, #agents > .row.dragging .ag-handle > .ic { opacity: 0; transform: scale(.8); }
-#agents > .row .ag-handle:is(:hover, .open, :focus-visible) > .grip, #agents > .row.dragging .ag-handle > .grip { opacity: 1; transform: none; }
+#agents > .row .ag-handle:is(:hover, .open) > .ic, #agents > .row.dragging .ag-handle > .ic { opacity: 0; transform: scale(.8); }
+#agents > .row .ag-handle:focus-visible > .ic { opacity: 0; transform: scale(.8); }
+#agents > .row .ag-handle:is(:hover, .open) > .grip, #agents > .row.dragging .ag-handle > .grip { opacity: 1; transform: none; }
+#agents > .row .ag-handle:focus-visible > .grip { opacity: 1; transform: none; }
/* one hidden by hand reads as put away, with its way back beside its name */
.agent-fold .row.put-away .ag-handle > .ic, .agent-fold .row.put-away .name { opacity: .6; }
.agent-fold .row.put-away .who { display: flex; align-items: center; gap: 8px; }
@@ -804,7 +806,7 @@ body.panel .profiles { flex-wrap: wrap; align-items: center; gap: 8px; padding:
body.panel .profiles > .label { display: none; }
body.panel .profiles .chips { flex: 1 1 100%; flex-wrap: wrap; }
body.panel .profiles .chips .hint { white-space: normal; line-height: 1.4; padding: 0 4px; color: var(--muted); }
-body.panel .profiles:has(> .chip-input) .chips .hint { display: none; }
+body.panel .profiles.naming .chips .hint { display: none; }
body.panel .profiles #save,
body.panel .profiles > .chip-input { margin: 0; }
body.panel .profiles #save { height: 25px; } /* the field's: the line keeps its height as it opens */
@@ -1198,7 +1200,8 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; }
.acc.in-use .dot { background: var(--fg); box-shadow: none; color: var(--card); }
.acc .using { font-size: 11px; color: var(--fg-2); padding: 2px 8px; border-radius: 999px; box-shadow: inset 0 0 0 1px var(--line); }
.acc .text.quiet { color: var(--muted); opacity: 0; transition: opacity .15s; }
-.acc:hover .text.quiet, .acc .text.quiet:focus-visible { opacity: 1; }
+.acc:hover .text.quiet { opacity: 1; }
+.acc .text.quiet:focus-visible { opacity: 1; }
.acc.new { animation: sub-in .5s cubic-bezier(.2, .8, .2, 1); }
.acc.new .dot { animation: copy-pop .45s cubic-bezier(.2, .8, .2, 1); }
@keyframes sub-in { from { opacity: 0; transform: translateY(-4px); background: var(--pill-hover); } }
@@ -1228,7 +1231,8 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; }
.acc .proto svg { opacity: .7; transition: transform .15s var(--ease); }
.acc .proto.open svg { transform: rotate(180deg); }
.acc .proto:not(.set) { opacity: 0; background: none; box-shadow: inset 0 0 0 1px var(--line); }
-.acc:hover .proto, .acc .proto:focus-visible, .acc .proto.open, .acc.adding .proto { opacity: 1; }
+.acc:hover .proto, .acc .proto.open, .acc.adding .proto { opacity: 1; }
+.acc .proto:focus-visible { opacity: 1; }
.acc .proto.set { color: var(--fg-2); }
.acc .proto:hover, .acc .proto.open { color: var(--fg); background: var(--pill-hover); }
.acc .n, .acc .rename { flex: 0 1 auto; }
@@ -1242,13 +1246,13 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; }
.pop.proto-menu { width: 262px; max-height: none; padding: 4px; border: 1px solid var(--line); display: grid; gap: 1px; }
.proto-menu .pm-head { padding: 6px 8px 5px; font-size: 11px; color: var(--muted); }
.proto-menu .pm-item { display: flex; align-items: flex-start; gap: 7px; width: 100%; padding: 6px 8px 7px; border: 0; border-radius: 7px; background: none; color: var(--fg); font: inherit; text-align: left; cursor: default; outline: none; }
-.proto-menu .pm-item:focus-visible, .proto-menu .pm-item:hover, .proto-menu .pm-item:focus { background: var(--pill); }
+.proto-menu .pm-item:hover, .proto-menu .pm-item:focus { background: var(--pill); }
.proto-menu .pm-tick { width: 14px; height: 16px; flex: none; display: grid; place-items: center; color: var(--fg); }
.proto-menu .pm-words { display: grid; gap: 1px; min-width: 0; }
.proto-menu .pm-name { font-size: 12.5px; font-weight: 500; line-height: 16px; }
.proto-menu .pm-note { font-size: 11px; color: var(--muted); line-height: 15px; }
/* a subscription's allowance, under its name */
-.accts .acc:has(.aq) { flex-wrap: wrap; row-gap: 3px; padding-top: 7px; padding-bottom: 8px; }
+.accts .acc.with-aq { flex-wrap: wrap; row-gap: 3px; padding-top: 7px; padding-bottom: 8px; }
.acc .aq { flex-basis: 100%; display: flex; flex-wrap: wrap; align-items: center; gap: 4px 16px; padding-left: 25px; font-size: 10.5px; color: var(--muted); min-height: 14px; }
.acc .aq-w { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
.acc .aq-track { width: 54px; height: 4px; border-radius: 2px; background: var(--pill); overflow: hidden; }
@@ -1367,7 +1371,7 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; }
font-size: 12.5px; outline: none; width: 100%; min-width: 0;
transition: border-color .12s, box-shadow .12s;
}
-.editor input:focus, .editor input:focus-visible, .editor .headers textarea.json:focus, .editor .headers textarea.json:focus-visible { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); outline: none; }
+.editor input:focus, .editor .headers textarea.json:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); outline: none; }
.editor input::placeholder { color: var(--faint); }
.editor .pair { display: flex; gap: 8px; align-items: center; }
.editor .pair input:first-child { flex: 1; }
@@ -1729,3 +1733,12 @@ body.panel .modal { padding: 16px 12px; }
.si-bar i { width: 100%; opacity: .5; }
}
.sess-indexing.known .si-bar i { animation: none; transform: none; opacity: 1; transition: width .4s ease-out; background: linear-gradient(90deg, #06b6d4, var(--accent), #ec4899); }
+/* Safari before 16.2 (macOS 12's, #220) has no color-mix(): a declaration
+ with one is dropped, and where nothing else is under it the page would go
+ clear. The panel's paint (which tintPanel hands the system), and the
+ floating status, take a plain colour there instead. */
+@supports not (color: color-mix(in srgb, red 50%, blue)) {
+ body.panel { --panel-tint: var(--bg); }
+ .status.ok, .status.err, .status.warn { background: var(--bg); }
+ .status.lift { background: var(--card); }
+}
diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js
index 01cd7297..e7acec3d 100644
--- a/internal/gui/assets/app.js
+++ b/internal/gui/assets/app.js
@@ -438,6 +438,7 @@ function renderProfiles() {
const chips = $("#profiles");
chips.replaceChildren();
$(".profiles > .chip-input")?.remove(); // a name field open goes with the list it was for
+ $(".profiles").classList.remove("naming");
$("#save").textContent = t("+ Save current");
if (!state.profiles.length) chips.append(el("span", "hint", t("none yet · save the setup to switch back in one click")));
for (const p of state.profiles) {
@@ -1629,7 +1630,7 @@ const saveCurrent = $("#save");
const saveField = () => $(".profiles > .chip-input");
const closeSave = (input) => {
input.remove();
- if (!saveField()) saveCurrent.textContent = t("+ Save current");
+ if (!saveField()) { saveCurrent.textContent = t("+ Save current"); $(".profiles").classList.remove("naming"); }
};
saveCurrent.onmousedown = (e) => { if (saveField()) e.preventDefault(); }; // the field keeps focus
saveCurrent.onclick = () => {
@@ -1648,6 +1649,7 @@ saveCurrent.onclick = () => {
};
input.onblur = () => setTimeout(() => closeSave(input), 100);
saveCurrent.before(input);
+ $(".profiles").classList.add("naming"); // for the panel's hint: :has() came in Safari 15.4 (#220)
saveCurrent.textContent = t("Save");
input.focus({ preventScroll: true });
};
@@ -2164,11 +2166,23 @@ const KEYWORDS = {
function highlight(code, lang) {
const re = lang === "node"
? /("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)|(\/\/.*)|(\b\d+(?:\.\d+)?\b)|([A-Za-z_$][\w$]*)(?=\s*\()|([A-Za-z_$][\w$]*)|(\s+|.)/g
- : /("(?:[^"\\]|\\.)*"|'[^']*'|(?<==)\S+)|(#.*)|(\b\d+(?:\.\d+)?\b(?=[,\s\]}]))|(-{1,2}[A-Za-z][\w-]*)|([A-Z][A-Z0-9_]+)(?==)|([A-Za-z_][\w.]*)(?=\s*\()|([A-Za-z_][\w.]*)|(\\\n|`\n)|(\s+|.)/g;
+ : /("(?:[^"\\]|\\.)*"|'[^']*')|(#.*)|(\b\d+(?:\.\d+)?\b(?=[,\s\]}]))|(-{1,2}[A-Za-z][\w-]*)|([A-Z][A-Z0-9_]+)(?==)|([A-Za-z_][\w.]*)(?=\s*\()|([A-Za-z_][\w.]*)|(\\\n|`\n)|(\s+|.)/g;
const out = document.createDocumentFragment();
const kw = KEYWORDS[lang] || KEYWORDS.shell;
let m;
while ((m = re.exec(code))) {
+ // an unquoted value after "=" is a string too; found here, as a
+ // lookbehind in the pattern is a syntax error before Safari 16.4 (#220)
+ if (lang !== "node" && !m[1] && code[m.index - 1] === "=") {
+ const v = /\S+/y;
+ v.lastIndex = m.index;
+ const s = v.exec(code);
+ if (s) {
+ out.append(el("span", "tk-s", s[0]));
+ re.lastIndex = m.index + s[0].length;
+ continue;
+ }
+ }
let cls = "";
if (lang === "node") {
if (m[1]) cls = "s"; else if (m[2]) cls = "c"; else if (m[3]) cls = "n";
@@ -4190,6 +4204,7 @@ function renderAccounts(a) {
row.append(forget, use);
}
row.append(accountQuota(l.lapsed ? { [l.user]: { error: l.lapsed } } : quota, l.user));
+ row.classList.add("with-aq"); // not :has(.aq), which Safari 15.0 lacks (#220)
list.append(row);
}
if (a.agent === "codex" && providers?.codexDaemon) list.append(renderCodexDaemon(providers.codexDaemon));
diff --git a/internal/gui/assets/compat.js b/internal/gui/assets/compat.js
new file mode 100644
index 00000000..841c4a5b
--- /dev/null
+++ b/internal/gui/assets/compat.js
@@ -0,0 +1,35 @@
+// The page runs in the system's WebKit: on macOS 12 that can be Safari
+// 15.0's (#220), older than what the rest of the code was written against.
+// Loaded before every other script, this fills in the few built-ins they
+// use that came later, each only where it is missing. The syntax the
+// scripts may use is held by tests/old-webkit.test.cjs.
+
+// Safari 15.4
+if (!Array.prototype.at) {
+ Object.defineProperty(Array.prototype, "at", {
+ configurable: true, writable: true,
+ value(i) { i = Math.trunc(i) || 0; if (i < 0) i += this.length; return this[i]; },
+ });
+}
+if (!Array.prototype.findLast) {
+ Object.defineProperty(Array.prototype, "findLast", {
+ configurable: true, writable: true,
+ value(f, self) { for (let i = this.length - 1; i >= 0; i--) if (f.call(self, this[i], i, this)) return this[i]; },
+ });
+}
+if (!Array.prototype.findLastIndex) {
+ Object.defineProperty(Array.prototype, "findLastIndex", {
+ configurable: true, writable: true,
+ value(f, self) { for (let i = this.length - 1; i >= 0; i--) if (f.call(self, this[i], i, this)) return i; return -1; },
+ });
+}
+if (!Object.hasOwn) {
+ Object.defineProperty(Object, "hasOwn", {
+ configurable: true, writable: true,
+ value: (o, k) => Object.prototype.hasOwnProperty.call(o, k),
+ });
+}
+// the page only copies plain data (what the API sent), so JSON is enough
+if (!window.structuredClone) {
+ window.structuredClone = (v) => (v === undefined ? v : JSON.parse(JSON.stringify(v)));
+}
diff --git a/internal/gui/assets/index.html b/internal/gui/assets/index.html
index 3b9d918b..a15a7d3f 100644
--- a/internal/gui/assets/index.html
+++ b/internal/gui/assets/index.html
@@ -267,6 +267,7 @@
+
diff --git a/internal/gui/assets/library.css b/internal/gui/assets/library.css
index dcf1fe25..ab8cef08 100644
--- a/internal/gui/assets/library.css
+++ b/internal/gui/assets/library.css
@@ -377,3 +377,5 @@ input.lib-setname:focus { border-color: var(--accent); box-shadow: 0 0 0 3px col
.lib-subhead .sub { margin: 0; font-size: 11px; color: var(--faint); font-variant-numeric: tabular-nums; }
.lib-subhead .lib-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); flex: none; }
.lib-row.lib-subhead:hover { background: color-mix(in srgb, var(--card-2) 70%, var(--pill)); }
+/* no color-mix() before Safari 16.2 (#220): the sticky head stays opaque */
+@supports not (color: color-mix(in srgb, red 50%, blue)) { .lib-head { background: var(--bg); } }
diff --git a/internal/gui/assets/library.js b/internal/gui/assets/library.js
index 4ad70587..8298c4e3 100644
--- a/internal/gui/assets/library.js
+++ b/internal/gui/assets/library.js
@@ -159,8 +159,11 @@
// filter, which is a sum over each colour (blending and gradients
// mix colours in sRGB, as the filter does, so they come out the same)
// and drawn as sharp as the drawing itself
- const s = (await r.text()).replace(/(? k + eq + q + greyColour(v) + q)
- .replace(/(? k + eq + greyColour(v));
+ // (a name inside another, as data-fill, is left: checked by hand, as a
+ // lookbehind in the pattern is a syntax error before Safari 16.4, #220)
+ const inWord = (s, at) => /[\w-]/.test(s[at - 1] || "");
+ const s = (await r.text()).replace(/(fill|stroke|stop-color|flood-color|lighting-color|color)(\s*=\s*)(["'])([^"']*)\3/g, (all, k, eq, q, v, at, s) => inWord(s, at) ? all : k + eq + q + greyColour(v) + q)
+ .replace(/(fill|stroke|stop-color|flood-color|lighting-color|color)(\s*:\s*)([^;"'}]+)/g, (all, k, eq, v, at, s) => inWord(s, at) ? all : k + eq + greyColour(v));
if (/<(image|feColorMatrix|feComponentTransfer|feTurbulence)\b/.test(s)) return null; // a colour the sum can't reach
blob = new Blob([s], { type: "image/svg+xml" });
} else {
diff --git a/internal/gui/assets/routing.css b/internal/gui/assets/routing.css
index bdee455b..e1a0447b 100644
--- a/internal/gui/assets/routing.css
+++ b/internal/gui/assets/routing.css
@@ -290,7 +290,8 @@
.rt-classifier .w { color: var(--muted); font-size: 12px; }
.rt-classifier .hint { flex-basis: 100%; }
/* the field is the pill: the editor's boxed input (its border and focus ring) stays off it */
-.rt-cond input, .editor .rt-cond input[type="text"], .editor .rt-cond input:focus, .editor .rt-cond input:focus-visible { box-shadow: none; border-radius: 0; }
+.rt-cond input, .editor .rt-cond input[type="text"], .editor .rt-cond input:focus { box-shadow: none; border-radius: 0; }
+.editor .rt-cond input:focus-visible { box-shadow: none; border-radius: 0; }
.rt-cond:focus-within { border-style: solid; border-color: var(--accent); color: var(--fg); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); }
.rt-cond input, .editor .rt-cond input[type="text"] { width: 7ch; height: 20px; padding: 0 2px; border: 0; background: transparent; color: inherit; font: inherit; font-variant-numeric: tabular-nums; outline: none; -moz-appearance: textfield; }
.rt-cond input::-webkit-outer-spin-button, .rt-cond input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
@@ -388,3 +389,30 @@
.pr-to .swap { flex: none; max-width: 45%; overflow: hidden; text-overflow: ellipsis; font-size: 10.5px; line-height: 16px; padding: 0 5px; border-radius: 5px; color: var(--amber); background: var(--amber-soft); white-space: nowrap; }
.pr-req .meta { justify-self: end; color: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; white-space: nowrap; text-align: right; }
@media (prefers-reduced-motion: reduce) { .pr-req.wait .pr-to > i, .ps-dst.wait > i, .ps-hub.busy, .ps-dot.wait { animation: none; } }
+/* Safari before 16 (macOS 12's, #220) has neither container queries nor
+ subgrid: there the narrow layouts above go by the window's width (the
+ tray panel is narrower than any of them), and a request's row sets out
+ its own columns */
+@supports not (container-type: inline-size) {
+ @media (max-width: 560px) {
+ .rt-stage { grid-template-columns: 1fr 1fr; column-gap: 28px; row-gap: 26px; padding: 16px 0; }
+ .rt-accts { grid-column: 1 / -1; padding-left: 22px; }
+ .rt-accts li { grid-template-columns: 10px minmax(0, 1fr); }
+ .rt-accts em { grid-column: 2; white-space: normal; }
+ .rt-accts .bar { grid-column: 2; }
+ .rt-stats { gap: 14px; }
+ .rt-gsec .row-head .note { display: none; }
+ .rt-gedit { grid-template-columns: minmax(0, 1fr); gap: 4px; }
+ .rt-gedit label { text-align: left; padding-top: 8px; }
+ .rt-gedit .segs .opt { padding-left: 9px; padding-right: 9px; }
+ }
+}
+@supports not (grid-template-columns: subgrid) {
+ .rt-req { grid-template-columns: auto fit-content(36%) minmax(0, 1fr) auto; }
+ @media (max-width: 560px) {
+ .rt-reqs { grid-template-columns: auto minmax(0, 1fr); }
+ .rt-req { grid-template-columns: auto minmax(0, 1fr); row-gap: 2px; }
+ .rt-req .to { grid-column: 2; }
+ .rt-req .meta { grid-column: 2; text-align: left; }
+ }
+}
diff --git a/internal/gui/tests/README.md b/internal/gui/tests/README.md
index 7707468a..9c0ce972 100644
--- a/internal/gui/tests/README.md
+++ b/internal/gui/tests/README.md
@@ -117,6 +117,21 @@ kiro-cli's own sign-in behind one of magpie's, then alone and first: its
Remove is there both times, its title saying magpie only hides it and it
shows again when Kiro signs in anew, and it posts login/forget; magpie's
own account in use first has no Remove; in English and Chinese.
+`old-webkit.test.cjs` holds the page to Safari 15.0, the WebKit macOS 12 can
+have (#220: a regex lookbehind in app.js, a syntax error before Safari 16.4,
+left the panel with its headings and nothing working). Every script in
+`assets/` is parsed (with the Babel parser Playwright bundles) and may have
+no regex lookbehind, v flag or modifiers, class static block, decorator,
+import attributes or `using`, in a literal or `new RegExp("…")`; no built-in
+newer than 15.0 (`.at`, `findLast`, `structuredClone`, `Object.hasOwn`,
+`toSorted`, Set methods, `Object.groupBy`, `Promise.withResolvers` …) unless
+`compat.js`, loaded before the rest, fills it in; the styles no `:has()`, no
+`:focus-visible` in a list with other selectors, no nesting or dvh units, and
+container queries, `subgrid` columns and a `color-mix()` custom property
+only with an `@supports not (…)` fallback in the same file. Then, in WebKit
+with those built-ins deleted, the panel's tabs and the window's pages are
+drawn with no page error. It cannot run an old engine, so syntax is judged
+by the parse, not by running it.
`usage-ledger.test.cjs` opens the Usage page's Requests, a row per request
from a faked `/api/usage/requests`: the columns, the model asked for, the
@@ -145,7 +160,7 @@ projects.
With Node.js and Playwright available:
```sh
-node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/panel-routing.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs internal/gui/tests/usage-ledger.test.cjs internal/gui/tests/update-check.test.cjs internal/gui/tests/routing-steady.test.cjs
+node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/panel-routing.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs internal/gui/tests/usage-ledger.test.cjs internal/gui/tests/update-check.test.cjs internal/gui/tests/routing-steady.test.cjs internal/gui/tests/old-webkit.test.cjs
```
If Playwright is installed outside the repository, set `NODE_PATH` to the
diff --git a/internal/gui/tests/old-webkit.test.cjs b/internal/gui/tests/old-webkit.test.cjs
new file mode 100644
index 00000000..75beeb67
--- /dev/null
+++ b/internal/gui/tests/old-webkit.test.cjs
@@ -0,0 +1,235 @@
+// Run with Node's test runner and Playwright on the module path; see README.md.
+// The page runs in the system's WebKit, which on macOS 12 can be Safari
+// 15.0's (#220 uclort: on 12.7.6 the panel showed its tabs and headings and
+// nothing else, no button working — a regex lookbehind in app.js is a
+// syntax error before Safari 16.4, so the whole file never ran). What the
+// scripts and styles may use is held to Safari 15.0 here:
+// - every script parses, and none has syntax newer than it: a regex
+// lookbehind (16.4) or v flag (17) or modifiers, a class static block
+// (16.4), decorators, import attributes, `using`;
+// - no built-in newer than it is used unless compat.js (loaded before the
+// rest) fills it in;
+// - the styles have no :has() (15.4), :focus-visible only in rules of its
+// own (a list with it and more drops them all before 15.4), no nesting, no dvh
+// units, and container queries, subgrid and a color-mix() custom property
+// only with an @supports fallback in the same file;
+// - in WebKit with those built-ins taken away, the panel and the window
+// draw every page with no error.
+// Syntax is parsed with the Babel parser Playwright bundles, so no other
+// module is needed.
+const assert = require("node:assert/strict");
+const fs = require("node:fs/promises");
+const path = require("node:path");
+const { test } = require("node:test");
+const { webkit } = require("playwright");
+const { babelParse } = require("playwright/lib/transform/babelBundle");
+
+const assets = path.resolve(__dirname, "../assets");
+const scripts = async () => (await fs.readdir(assets)).filter((f) => f.endsWith(".js")).sort();
+const styles = async () => (await fs.readdir(assets)).filter((f) => f.endsWith(".css")).sort();
+const read = (f) => fs.readFile(path.join(assets, f), "utf8");
+
+// Syntax Safari 15.0 can't parse; one of these fails the whole file.
+const LOOKBEHIND = /\(\?<[=!]/;
+const MODIFIERS = /\(\?[ims]*-?[ims]+:/;
+function badSyntax(ast) {
+ const found = [];
+ const at = (n) => n.loc ? `:${n.loc.start.line}` : "";
+ const regex = (n, pattern, flags) => {
+ if (LOOKBEHIND.test(pattern)) found.push(`${at(n)} regex lookbehind (Safari 16.4): /${pattern}/${flags}`);
+ if (/v/.test(flags)) found.push(`${at(n)} regex v flag (Safari 17): /${pattern}/${flags}`);
+ if (MODIFIERS.test(pattern)) found.push(`${at(n)} regex modifiers: /${pattern}/${flags}`);
+ };
+ const walk = (n) => {
+ if (!n || typeof n.type !== "string") return;
+ switch (n.type) {
+ case "RegExpLiteral": regex(n, n.pattern, n.flags); break;
+ case "StaticBlock": found.push(`${at(n)} class static block (Safari 16.4)`); break;
+ case "Decorator": found.push(`${at(n)} decorator`); break;
+ case "ClassAccessorProperty": found.push(`${at(n)} accessor field`); break;
+ case "ImportAttribute": found.push(`${at(n)} import attributes`); break;
+ case "VariableDeclaration": if (/using/.test(n.kind)) found.push(`${at(n)} ${n.kind} declaration`); break;
+ case "NewExpression": case "CallExpression": {
+ // new RegExp("…") takes the same syntax, a SyntaxError when it runs
+ const a = n.arguments?.[0];
+ if (n.callee?.type === "Identifier" && n.callee.name === "RegExp" && a) {
+ const text = a.type === "StringLiteral" ? a.value : a.type === "TemplateLiteral" ? a.quasis.map((q) => q.value.cooked).join("") : null;
+ const flags = n.arguments[1]?.type === "StringLiteral" ? n.arguments[1].value : "";
+ if (text != null) regex(n, text, flags);
+ }
+ break;
+ }
+ }
+ for (const k of Object.keys(n)) {
+ if (k === "loc" || k === "extra" || k === "leadingComments" || k === "trailingComments") continue;
+ const v = n[k];
+ if (Array.isArray(v)) v.forEach(walk);
+ else if (v && typeof v === "object") walk(v);
+ }
+ };
+ walk(ast.program);
+ return found;
+}
+
+// Built-ins Safari 15.0 lacks. `fill` is what compat.js tests for when it
+// fills one in; the others must not be used at all.
+const BUILTINS = [
+ { name: "Array.prototype.at", re: /\.at\(/, since: "15.4", fill: "Array.prototype.at" },
+ { name: "findLast", re: /\.findLast\(/, since: "15.4", fill: "Array.prototype.findLast" },
+ { name: "findLastIndex", re: /\.findLastIndex\(/, since: "15.4", fill: "Array.prototype.findLastIndex" },
+ { name: "Object.hasOwn", re: /\bObject\.hasOwn\(/, since: "15.4", fill: "Object.hasOwn" },
+ { name: "structuredClone", re: /\bstructuredClone\(/, since: "15.4", fill: "window.structuredClone" },
+ { name: "toSorted/toReversed/toSpliced", re: /\.to(Sorted|Reversed|Spliced)\(/, since: "16" },
+ { name: "Array.prototype.with", re: /\]\.with\(|\bArray\.prototype\.with\b/, since: "16" },
+ { name: "String isWellFormed/toWellFormed", re: /\.(is|to)WellFormed\(/, since: "16.4" },
+ { name: "AbortSignal.timeout/any", re: /\bAbortSignal\.(timeout|any)\(/, since: "16/17.4" },
+ { name: "URL.canParse", re: /\bURL\.canParse\(/, since: "17" },
+ { name: "Set methods", re: /\.(union|intersection|difference|symmetricDifference|isSubsetOf|isSupersetOf|isDisjointFrom)\(/, since: "17" },
+ { name: "Array.fromAsync", re: /\bArray\.fromAsync\(/, since: "16.4" },
+ { name: "Object.groupBy/Map.groupBy", re: /\b(Object|Map)\.groupBy\(/, since: "17.4" },
+ { name: "Promise.withResolvers", re: /\bPromise\.withResolvers\(/, since: "17.4" },
+ { name: "Iterator helpers", re: /\bIterator\.from\(|\.(keys|values|entries)\(\)\.(map|filter|take|drop|toArray|reduce|some|every|find|forEach|flatMap)\(/, since: "18.4" },
+ { name: "checkVisibility", re: /\.checkVisibility\(/, since: "17.4" },
+ { name: "popover", re: /\.(showPopover|hidePopover|togglePopover)\(|\.popover\s*=/, since: "17" },
+ { name: "RegExp.escape", re: /\bRegExp\.escape\(/, since: "18.2" },
+ { name: "Promise.try", re: /\bPromise\.try\(/, since: "18.2" },
+];
+const stripComments = (s) => s.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:\\"'`])\/\/.*$/gm, "$1");
+
+test("every script parses with no syntax newer than Safari 15.0", async () => {
+ const bad = [];
+ for (const f of await scripts()) {
+ let ast;
+ try { ast = babelParse(await read(f), f, false); } catch (e) { bad.push(`${f}: does not parse: ${e.message.split("\n")[0]}`); continue; }
+ for (const x of badSyntax(ast)) bad.push(f + x);
+ }
+ assert.deepEqual(bad, []);
+});
+
+test("the checks catch what broke macOS 12", () => {
+ const found = (src) => badSyntax(babelParse(src, "x.js", false));
+ assert.equal(found('const re = /("[^"]*"|(?<==)\\S+)/g;').length, 1, "lookbehind");
+ assert.equal(found("s.replace(/(?a)(?=b)(?!c)\\k/g; a ??= b; class B { #x; #m() {} }'), [], "what Safari 15 has");
+});
+
+test("no built-in newer than Safari 15.0 unless compat.js fills it in", async () => {
+ const compat = await read("compat.js");
+ const bad = [];
+ for (const f of await scripts()) {
+ if (f === "compat.js") continue;
+ const lines = stripComments(await read(f)).split("\n");
+ for (const b of BUILTINS) {
+ if (b.fill && compat.includes(`if (!${b.fill})`)) continue;
+ lines.forEach((l, i) => { if (b.re.test(l)) bad.push(`${f}:${i + 1} ${b.name} (Safari ${b.since}): ${l.trim().slice(0, 100)}`); });
+ }
+ }
+ assert.deepEqual(bad, []);
+});
+
+test("compat.js is the first of the page's own scripts", async () => {
+ const html = await read("index.html");
+ const srcs = [...html.matchAll(/