The app sometimes drew under the status and gesture bars and sometimes between them. Cause: Capacitor 8 ships a SystemBars plugin that already handles insets, from a listener on the decor view, and my MainActivity had installed another one on the WebView. Different views, so they stacked rather than replaced each other and the result depended on which fired last. SystemBars switches on the viewport meta tag. With viewport-fit=cover it passes insets through once onPageCommitVisible has checked the tag, decor padding drops to 0 and the WebView grows 735 -> 820, leaving the page to place itself from injected CSS vars. Without it the plugin pads the decor view and the WebView is laid out between the bars already. So sync-www.sh now strips viewport-fit=cover from the generated www/ (patch 5), MainActivity is an empty BridgeActivity, native.css and native.js no longer touch insets, and bar style moves to capacitor.config.json. No native->web handshake remains, so there is nothing to race and no launch-time layout jump. Verified identical across 3 cold launches, 2 resumes and an in-place reload: h=735 padTop=8px overflowBy=0, panel 485px, pad rows 44.8px. Costs no space: 820-45-114 and 735-8-66 both leave 661px. The bars take the same room either way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
225 lines
8.4 KiB
JavaScript
225 lines
8.4 KiB
JavaScript
/* Native glue for the Capacitor wrapper.
|
|
*
|
|
* Loaded as a classic script AFTER the app script, so it shares the global
|
|
* lexical scope and can reach the reference's top-level bindings directly
|
|
* (store, st, render, closeSheet, save). Nothing in reference/ is modified.
|
|
*
|
|
* Plugins are reached through window.Capacitor.Plugins rather than ESM imports,
|
|
* because there is no bundler in Phase 1. The native side registers them during
|
|
* `cap sync`, so the bridge proxies are present without any JS import.
|
|
*
|
|
* Everything degrades silently when the bridge is absent (desktop browser).
|
|
*/
|
|
(function () {
|
|
"use strict";
|
|
|
|
var cap = window.Capacitor;
|
|
var P = (cap && cap.Plugins) || {};
|
|
|
|
/* Insets are deliberately not handled here. sync-www.sh strips
|
|
viewport-fit=cover, so Capacitor's SystemBars plugin pads the decor view
|
|
and the WebView is already laid out between the system bars. An earlier
|
|
__applyInsets bridge raced with that plugin - see CLAUDE.md. */
|
|
|
|
if (!(cap && cap.isNativePlatform && cap.isNativePlatform())) return;
|
|
|
|
/* ---------- haptics ----------
|
|
The reference's buzz() is a `const`, so it cannot be reassigned. It calls
|
|
navigator.vibrate, which can be — so this needs no reference change at all.
|
|
Call sites: buzz() on every key press, buzz([12,40,12]) on a correct
|
|
answer, buzz(40) on a wrong one. */
|
|
var H = P.Haptics;
|
|
if (H) {
|
|
Object.defineProperty(navigator, "vibrate", {
|
|
configurable: true,
|
|
writable: true,
|
|
value: function (pattern) {
|
|
try {
|
|
if (Array.isArray(pattern)) H.notification({ type: "SUCCESS" });
|
|
else if (pattern > 15) H.notification({ type: "ERROR" });
|
|
else H.impact({ style: "LIGHT" });
|
|
} catch (e) {}
|
|
return true; // buzz() only checks truthiness of navigator.vibrate
|
|
}
|
|
});
|
|
}
|
|
|
|
/* ---------- storage ----------
|
|
localStorage stays the synchronous source of truth so load()/save() keep
|
|
their signatures. Every write is mirrored into SharedPreferences, which
|
|
survives a "clear app data" wipe of the WebView. */
|
|
var Pref = P.Preferences;
|
|
var SK = "sp404mk2-layers-v1"; // must match SK in reference/index.html
|
|
|
|
if (Pref) {
|
|
var origSave = window.save;
|
|
window.save = function () {
|
|
origSave.apply(this, arguments);
|
|
try {
|
|
Pref.set({ key: SK, value: localStorage.getItem(SK) || "{}" });
|
|
} catch (e) {}
|
|
};
|
|
|
|
/* The app already ran load() before this script executed, so rehydration is
|
|
a repair pass: only when localStorage is empty and Preferences is not. */
|
|
Pref.get({ key: SK })
|
|
.then(function (r) {
|
|
var backup = r && r.value;
|
|
if (!backup) return;
|
|
if (localStorage.getItem(SK)) return; // localStorage wins when present
|
|
|
|
var o = JSON.parse(backup);
|
|
if (!o || typeof o !== "object") return;
|
|
|
|
localStorage.setItem(SK, backup);
|
|
store = o; // global lexical binding from the reference
|
|
store.boxes = store.boxes || {};
|
|
st.ctx = store.ctx || st.ctx;
|
|
st.deck = store.deck || st.deck;
|
|
st.dir = store.dir || st.dir;
|
|
st.input = store.input || st.input;
|
|
render();
|
|
})
|
|
.catch(function () {});
|
|
}
|
|
|
|
/* ---------- hardware back ----------
|
|
Registering a listener suppresses Capacitor's default history navigation,
|
|
so this owns the whole back stack: sheet -> Explore tab -> exit.
|
|
closeSheet() does its own history.back(), which keeps the pushState entry
|
|
the sheet added from leaking. */
|
|
var App = P.App;
|
|
if (App) {
|
|
App.addListener("backButton", function () {
|
|
var sheet = document.getElementById("sheet");
|
|
if (sheet && sheet.classList.contains("open")) {
|
|
closeSheet();
|
|
return;
|
|
}
|
|
if (st.tab !== "explore") {
|
|
st.tab = "explore";
|
|
render();
|
|
window.scrollTo(0, 0);
|
|
return;
|
|
}
|
|
App.exitApp();
|
|
});
|
|
}
|
|
|
|
/* ---------- strip ergonomics (Phase 1 UI fix) ----------
|
|
Collapses the 7 context chips into one 44px control that opens the existing
|
|
sheet, which frees a row and fixes the flush-rows bug described in
|
|
native.css. renderExplore() rewrites #strip wholesale on every render, so
|
|
this re-applies afterwards rather than running once. */
|
|
|
|
function phone() { return matchMedia("(max-width:820px)").matches; }
|
|
|
|
function ctxSheetHTML() {
|
|
return Object.keys(CTX)
|
|
.map(function (c) {
|
|
return '<button class="ctxopt" data-ctx="' + c + '" aria-pressed="' + (c === st.ctx) + '">' +
|
|
"<b>" + CTX[c] + "</b><span class=\"hint\">" + CTXHINT[c] + "</span></button>";
|
|
})
|
|
.join("");
|
|
}
|
|
|
|
/* Snapshot of the reference's own layer chips, refreshed on every render.
|
|
Read from its DOM rather than recomputed, so the set, ordering and counts
|
|
cannot drift from what renderExplore() decided. */
|
|
var layerOptions = [];
|
|
|
|
function snapshotLayers(row) {
|
|
return [].map.call(row.querySelectorAll(".chip"), function (c) {
|
|
var clone = c.cloneNode(true);
|
|
var small = clone.querySelector("small");
|
|
var count = small ? small.textContent.trim() : "";
|
|
if (small) small.remove();
|
|
return { layer: c.getAttribute("data-layer") || "", label: clone.textContent.trim(), count: count };
|
|
});
|
|
}
|
|
|
|
function layerSheetHTML() {
|
|
var cur = layerKey(st.held);
|
|
return layerOptions
|
|
.map(function (o) {
|
|
return '<button class="ctxopt" data-layer="' + o.layer + '" aria-pressed="' + (o.layer === cur) + '">' +
|
|
"<b>" + o.label + '</b><span class="hint">' +
|
|
(o.count === "1" ? "1 function" : o.count + " functions") + " on this layer</span></button>";
|
|
})
|
|
.join("");
|
|
}
|
|
|
|
function pickerBtn(sheetAttr, text, aria) {
|
|
var btn = document.createElement("button");
|
|
btn.className = "ctxbtn";
|
|
btn.setAttribute(sheetAttr, "");
|
|
btn.setAttribute("aria-label", aria);
|
|
var label = document.createElement("b");
|
|
label.textContent = text;
|
|
var caret = document.createElement("span");
|
|
caret.setAttribute("aria-hidden", "true");
|
|
caret.textContent = "▾";
|
|
btn.appendChild(label);
|
|
btn.appendChild(caret);
|
|
return btn;
|
|
}
|
|
|
|
function reshapeStrip() {
|
|
var strip = document.getElementById("strip");
|
|
if (!strip) return;
|
|
|
|
// Two .chips rows means the Explore strip (context row first, layers second).
|
|
// Drill renders a .stripbtn and Find an input, so both are left alone.
|
|
var rows = strip.querySelectorAll(".chips");
|
|
if (phone() && rows.length >= 2) {
|
|
layerOptions = snapshotLayers(rows[1]);
|
|
|
|
// Only one layer is ever active, so both rows collapse to pickers. Side by
|
|
// side they cost one 44px row instead of two, which the panel needs: its
|
|
// pad rows sit at their minmax(34px,1fr) floor and clip below that.
|
|
var held = st.held.length ? st.held.map(mName).join(" + ") : "No modifier";
|
|
var bar = document.createElement("div");
|
|
bar.className = "stripbar";
|
|
bar.appendChild(pickerBtn("data-ctxsheet", CTX[st.ctx], "Mode: " + CTX[st.ctx] + ". Change mode"));
|
|
bar.appendChild(pickerBtn("data-layersheet", held, "Layer: " + held + ". Change layer"));
|
|
|
|
rows[0].replaceWith(bar);
|
|
rows[1].remove();
|
|
}
|
|
|
|
strip.querySelectorAll(".chips").forEach(function (r) {
|
|
if (r.scrollWidth > r.clientWidth + 1) r.setAttribute("data-more", "");
|
|
else r.removeAttribute("data-more");
|
|
});
|
|
}
|
|
|
|
var origRenderExplore = window.renderExplore;
|
|
if (typeof origRenderExplore === "function") {
|
|
window.renderExplore = function () {
|
|
origRenderExplore.apply(this, arguments);
|
|
reshapeStrip();
|
|
};
|
|
}
|
|
|
|
document.addEventListener("click", function (ev) {
|
|
var t = ev.target.closest && ev.target.closest("button");
|
|
if (!t) return;
|
|
if (t.hasAttribute("data-ctxsheet")) {
|
|
openSheet("Where you are", ctxSheetHTML());
|
|
return;
|
|
}
|
|
if (t.hasAttribute("data-layersheet")) {
|
|
openSheet("Layers in " + CTX[st.ctx], layerSheetHTML());
|
|
return;
|
|
}
|
|
// The reference's own handler runs first (registered earlier) and applies
|
|
// data-ctx / data-layer, so this only has to dismiss the sheet afterwards.
|
|
// data-layer="" (no modifier) is intentional: the reference tests
|
|
// `d.layer !== undefined`, so an empty value still selects that layer.
|
|
if ((t.hasAttribute("data-ctx") || t.hasAttribute("data-layer")) && t.closest("#sheet")) closeSheet();
|
|
});
|
|
|
|
// The first render already ran before this script loaded.
|
|
reshapeStrip();
|
|
})();
|