Files
404-Layers/native/native.js
ncode 066b9e8c34 Render between the system bars deterministically; delete my inset bridge
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>
2026-09-16 23:26:31 +03:00

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();
})();