mirror of
https://github.com/hanzoai/control.git
synced 2026-09-30 20:56:21 +00:00
One family. Font stacks name Zen and fall back to the native faces, tokens name the role rather than a face, and the packages that used to supply a face now point at @hanzo/font. Self-hosted @font-face blocks are removed rather than renamed: rewriting the family in one would leave a src pointing at a file this repo no longer ships. @hanzo/font carries the binaries and declares the faces, so the local copies go with the declaration that used them. Comments naming a face we no longer use are deleted, not reworded.
1690 lines
72 KiB
JavaScript
1690 lines
72 KiB
JavaScript
/*
|
||
* Hanzo Edit — the ever-present "contribute to this page" widget.
|
||
*
|
||
* Drop into ANY Hanzo app: <script async src="https://hanzo.app/edit.js"></script>
|
||
*
|
||
* The page self-declares its source via <meta> tags:
|
||
* <meta name="hanzo:repo" content="owner/repo"> (required)
|
||
* <meta name="hanzo:path" content="path/to/file"> (optional default)
|
||
* <meta name="hanzo:branch" content="main"> (optional, default main)
|
||
* <meta name="hanzo:provider" content="github"> (optional, default github)
|
||
* <meta name="hanzo:key" content="pk_..."> (optional project key)
|
||
* <meta name="hanzo:anchor" content="#selector"> (optional launcher mount)
|
||
*
|
||
* ZERO manual path: the widget resolves the source file(s) for the CURRENT view
|
||
* itself and pre-fills the field (the user may override). It ranks candidates
|
||
* from the best available signal, in order:
|
||
* 1. an explicit `hanzo:path` (a page that maps 1:1 to a file),
|
||
* 2. a React `_debugSource` on the element in view (DEV builds only — absent
|
||
* in production, so never depended on),
|
||
* 3. the app's build-time route manifest (`/edit-manifest.json`) — the App
|
||
* Router pathname → `app/…/page.tsx` (+ its layout chain), the reliable
|
||
* signal since it is derived from the same filesystem convention Next
|
||
* routes on. Absent on apps that don't ship one → step 4.
|
||
* 4. a convention guess (`app/<segments>/page.tsx` + root layout).
|
||
*
|
||
* Every submission also carries a context trace so a reviewing agent/dev knows
|
||
* exactly where + what: the route, the ranked candidate files, the DOM
|
||
* breadcrumb of what was on screen, the app version, the analytics session id, a
|
||
* present-when-available session-replay deep-link, and a short usage trace.
|
||
*
|
||
* With no hanzo:repo the widget does nothing. Otherwise it renders a small
|
||
* floating control that lets ANYONE suggest a fix, and lets a signed-in user with
|
||
* credits (or an admin) run Hanzo's agent to fork→edit→PR the resolved file. All
|
||
* privilege is enforced SERVER-SIDE by /v1/edit; the widget only shapes the CTA
|
||
* from /v1/me. Framework-free, Shadow-DOM isolated, theme-neutral. No deps.
|
||
*/
|
||
(function () {
|
||
'use strict';
|
||
|
||
// The origin the script was served from is the backend base (works when this
|
||
// runs cross-origin on another Hanzo app). Captured synchronously (currentScript
|
||
// is null inside async callbacks).
|
||
var SELF = document.currentScript;
|
||
if (window.__hanzoEdit) return; // idempotent
|
||
window.__hanzoEdit = true;
|
||
|
||
function meta(name) {
|
||
var el = document.querySelector('meta[name="' + name + '"]');
|
||
return el ? (el.getAttribute('content') || '').trim() : '';
|
||
}
|
||
|
||
// ---- Which repo is this page? ---------------------------------------------
|
||
//
|
||
// The tag is byte-identical on every property:
|
||
// <script async src="https://hanzo.app/edit.js"></script>
|
||
// so a page that never declares `hanzo:repo` still resolves, from the ONE
|
||
// registry below (host → repo). Keeping the map here — in the single file every
|
||
// site already loads — is why the include needs no per-site thought: adding a
|
||
// property is one line HERE, not a commit in that property's repo.
|
||
//
|
||
// The meta ALWAYS wins when present: a repo knows its own name better than this
|
||
// table does, and a site served on a host we don't list (previews, branch
|
||
// deploys, a new domain) declares itself and works immediately.
|
||
var SITES = {
|
||
'hanzo.ai': 'hanzoai/hanzo.ai',
|
||
'hanzo.app': 'hanzoai/app',
|
||
'hanzo.chat': 'hanzoai/chat',
|
||
'world.hanzo.ai': 'hanzoai/world',
|
||
'docs.hanzo.ai': 'hanzoai/docs',
|
||
'insights.hanzo.ai': 'hanzoai/insights',
|
||
'platform.hanzo.ai': 'hanzoai/platform',
|
||
'cloud.hanzo.ai': 'hanzoai/cloud',
|
||
'ui.hanzo.ai': 'hanzoai/ui',
|
||
};
|
||
|
||
// `www.` is never its own property, and an unknown subdomain falls back to the
|
||
// apex's repo only when the apex is one we know.
|
||
function repoForHost(host) {
|
||
host = String(host || '').toLowerCase().replace(/^www\./, '');
|
||
if (SITES[host]) return SITES[host];
|
||
var apex = host.split('.').slice(-2).join('.');
|
||
return SITES[apex] || '';
|
||
}
|
||
|
||
var REPO = meta('hanzo:repo') || repoForHost(location.hostname);
|
||
|
||
var PATH = meta('hanzo:path');
|
||
var BRANCH = meta('hanzo:branch') || 'main';
|
||
var PROVIDER = meta('hanzo:provider') || 'github';
|
||
var KEY = meta('hanzo:key');
|
||
|
||
var BASE = 'https://hanzo.app';
|
||
try {
|
||
if (SELF && SELF.src) BASE = new URL(SELF.src).origin;
|
||
} catch (e) {
|
||
/* keep default */
|
||
}
|
||
|
||
// The IAM access token, forwarded as a bearer. Same-site the SDK's session
|
||
// cookie is readable here; a different-site Hanzo app exposes its own token as
|
||
// window.HANZO_TOKEN. hanzo.app verifies whatever arrives against IAM's JWKS.
|
||
function bearer() {
|
||
if (window.HANZO_TOKEN) return String(window.HANZO_TOKEN);
|
||
var m = document.cookie.match(/(?:^|;\s*)hanzo_iam_access_token=([^;]+)/);
|
||
return m ? decodeURIComponent(m[1]) : '';
|
||
}
|
||
|
||
function api(path, opts) {
|
||
opts = opts || {};
|
||
var headers = opts.headers || {};
|
||
var t = bearer();
|
||
if (t) headers['Authorization'] = 'Bearer ' + t;
|
||
opts.headers = headers;
|
||
opts.credentials = 'include';
|
||
return fetch(BASE + path, opts);
|
||
}
|
||
|
||
function selection() {
|
||
try {
|
||
var s = window.getSelection ? String(window.getSelection()) : '';
|
||
return s.trim().slice(0, 2000);
|
||
} catch (e) {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
// ---- Session, replay & usage trace ("what the user was doing") -------------
|
||
|
||
// The analytics/insights session id (@hanzo/event's localStorage `hz_session`
|
||
// = {id,last}); fall back to the stable anon id, else a widget-local id. This
|
||
// is the SAME id session-replay is keyed on, so the fix ties to the recording.
|
||
function readJSON(store, k) {
|
||
try {
|
||
return JSON.parse(store.getItem(k) || 'null');
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
function sessionId() {
|
||
try {
|
||
var ls = window.localStorage;
|
||
var s = readJSON(ls, 'hz_session');
|
||
if (s && s.id) return String(s.id);
|
||
var anon = ls.getItem('hz_anon_id');
|
||
if (anon) return String(anon);
|
||
var own = ls.getItem('hz_edit_sid');
|
||
if (!own) {
|
||
own = 'edit-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
||
ls.setItem('hz_edit_sid', own);
|
||
}
|
||
return own;
|
||
} catch (e) {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
// ---- Session replay (rrweb → Hanzo Insights) ------------------------------
|
||
//
|
||
// The recorder is deliberately NOT bundled here. We load the one Insights
|
||
// itself serves — `<insights>/static/recorder.js` — so the rrweb build that
|
||
// RECORDS is by construction the build the player REPLAYS. Vendoring our own
|
||
// copy would fork that pair and let it drift silently: a recording that the
|
||
// player of the day cannot read is worse than no recording, because it looks
|
||
// like it worked. Same reasoning as the route manifest — prefer the signal
|
||
// derived from the same source as the consumer.
|
||
//
|
||
// Batches ride the documented recordings route (`POST /v1/s`) as the PostHog-
|
||
// shaped `$snapshot` envelope insights-capture parses (its `RawRecording`), and
|
||
// are keyed on the SAME session id the edit payload reports — so a filed fix and
|
||
// its recording are one session and `replayRef`'s deep-link actually resolves.
|
||
var REPLAY = {
|
||
started: false,
|
||
live: false,
|
||
host: 'https://insights.hanzo.ai',
|
||
key: '',
|
||
win: 'w' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8),
|
||
buf: [],
|
||
stop: null,
|
||
};
|
||
|
||
// Consent & opt-out. Replay is off for anyone who asked not to be measured, and
|
||
// any page can veto it before this script runs (`window.__hanzoNoReplay = true`).
|
||
function replayOptedOut() {
|
||
try {
|
||
if (window.__hanzoNoReplay) return true;
|
||
var dnt = navigator.doNotTrack || window.doNotTrack || navigator.msDoNotTrack;
|
||
if (dnt === '1' || dnt === 'yes') return true;
|
||
if (window.localStorage && localStorage.getItem('hz_replay_off') === '1') return true;
|
||
} catch (e) {
|
||
/* storage blocked → fall through and record */
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// Ship a batch. `closing` is the page-going-away path, which uses `keepalive`
|
||
// so the request outlives the document — losing the tail of a session costs
|
||
// exactly the part a bug report needs most.
|
||
//
|
||
// Deliberately NOT sendBeacon: capture 502s on a `text/plain` body, so the
|
||
// envelope must be `application/json`, which is not a CORS-safelisted content
|
||
// type and therefore preflights. `keepalive` fetch preflights cleanly (and the
|
||
// preflight is cached for a day); a beacon's is far less certain. One path,
|
||
// both cases.
|
||
function flushReplay(closing) {
|
||
if (!REPLAY.key || !REPLAY.buf.length) return;
|
||
var batch = REPLAY.buf;
|
||
REPLAY.buf = [];
|
||
var sid = sessionId();
|
||
var body = JSON.stringify([
|
||
{
|
||
event: '$snapshot',
|
||
api_key: REPLAY.key,
|
||
distinct_id: sid,
|
||
properties: {
|
||
$session_id: sid,
|
||
$window_id: REPLAY.win,
|
||
$snapshot_source: 'web',
|
||
$lib: 'hanzo-edit',
|
||
$snapshot_data: batch,
|
||
},
|
||
},
|
||
]);
|
||
// credentials omitted: the recording is keyed by the publishable ingest key,
|
||
// never by the viewer's Hanzo cookie.
|
||
var opts = {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: body,
|
||
credentials: 'omit',
|
||
};
|
||
// `keepalive` caps the body at 64KB, so it is used ONLY when the page is
|
||
// going away and a normal request would be cancelled. An over-size closing
|
||
// batch is dropped rather than silently truncated — the 5s timer means at
|
||
// most that much is ever at risk.
|
||
if (closing) {
|
||
if (body.length > 60000) return;
|
||
opts.keepalive = true;
|
||
}
|
||
fetch(REPLAY.host + '/v1/s', opts).catch(function () {
|
||
/* a dropped batch must never surface on the page being recorded */
|
||
});
|
||
}
|
||
|
||
function loadScript(src, cb) {
|
||
var s = document.createElement('script');
|
||
s.src = src;
|
||
s.async = true;
|
||
s.crossOrigin = 'anonymous';
|
||
s.onload = cb;
|
||
s.onerror = function () {
|
||
/* recorder unavailable → the page is untouched, editing still works */
|
||
};
|
||
(document.head || document.documentElement).appendChild(s);
|
||
}
|
||
|
||
function startRecorder() {
|
||
var rr = window.rrweb || (window.__InsightsExtensions__ && window.__InsightsExtensions__.rrweb);
|
||
if (!rr || typeof rr.record !== 'function') return;
|
||
REPLAY.live = true;
|
||
REPLAY.stop = rr.record({
|
||
emit: function (ev) {
|
||
REPLAY.buf.push(ev);
|
||
// Cap the hold when the key hasn't landed yet, so a page that never gets
|
||
// config can't grow a buffer without bound.
|
||
if (!REPLAY.key && REPLAY.buf.length > 400) REPLAY.buf.splice(0, REPLAY.buf.length - 400);
|
||
else if (REPLAY.buf.length >= 100) flushReplay(false);
|
||
},
|
||
// Privacy is the DEFAULT, not a deployment option: every input value is
|
||
// masked before it leaves the page, so a recording can never carry a
|
||
// password, a card number, or anything else somebody typed. Opt an element
|
||
// back in with `.hz-unmask`; hide any subtree entirely with `.hz-no-record`.
|
||
maskAllInputs: true,
|
||
maskTextClass: 'hz-mask',
|
||
unmaskTextClass: 'hz-unmask',
|
||
blockClass: 'hz-no-record',
|
||
ignoreClass: 'hz-no-ignore',
|
||
// Never record our own widget: replaying the editor on top of the page it
|
||
// edits is noise, and its panel can hold the user's unsent prose.
|
||
blockSelector: '[data-hanzo-edit]',
|
||
collectFonts: false,
|
||
recordCanvas: false,
|
||
recordCrossOriginIframes: false,
|
||
// A viewer scrubbing into the middle of a long session still needs a full
|
||
// DOM to start from.
|
||
checkoutEveryNms: 300000,
|
||
});
|
||
|
||
setInterval(function () {
|
||
flushReplay(false);
|
||
}, 5000);
|
||
// `pagehide` fires where `unload` is unreliable (bfcache, mobile Safari).
|
||
window.addEventListener('pagehide', function () {
|
||
flushReplay(true);
|
||
});
|
||
document.addEventListener('visibilitychange', function () {
|
||
if (document.visibilityState === 'hidden') flushReplay(true);
|
||
});
|
||
}
|
||
|
||
// Boot replay. Orthogonal to editing: it runs on every page carrying the tag,
|
||
// including pages that map to no repo, because a recording is worth having
|
||
// whether or not this page happens to be editable.
|
||
function bootReplay() {
|
||
if (REPLAY.started || replayOptedOut()) return;
|
||
REPLAY.started = true;
|
||
fetch(BASE + '/v1/edit/config', { credentials: 'omit' })
|
||
.then(function (r) {
|
||
return r.ok ? r.json() : null;
|
||
})
|
||
.then(function (cfg) {
|
||
var ins = (cfg && cfg.insights) || null;
|
||
if (!ins || !ins.enabled || !ins.key) return;
|
||
if (typeof ins.sampleRate === 'number' && ins.sampleRate < 1 && Math.random() > ins.sampleRate) return;
|
||
REPLAY.key = String(ins.key);
|
||
if (ins.host) REPLAY.host = String(ins.host).replace(/\/+$/, '');
|
||
loadScript(REPLAY.host + '/static/recorder.js', startRecorder);
|
||
})
|
||
.catch(function () {
|
||
/* no config → no recording; never a page error */
|
||
});
|
||
}
|
||
|
||
// The replay reference attached to every submission. `recording` states whether
|
||
// this session is actually being captured, so a reviewer knows whether the
|
||
// deep-link will have anything behind it rather than guessing.
|
||
function replayRef() {
|
||
var sid = sessionId();
|
||
if (!sid) return undefined;
|
||
return {
|
||
sessionId: sid,
|
||
deepLink: REPLAY.host + '/replay/' + encodeURIComponent(sid),
|
||
recording: REPLAY.live || undefined,
|
||
};
|
||
}
|
||
|
||
// Recording starts here — BEFORE the repo gate below, because replay and
|
||
// editing are independent capabilities that happen to share one tag. A page we
|
||
// cannot map to a repo is still a page worth being able to watch back.
|
||
bootReplay();
|
||
|
||
// Past this line everything is the EDIT widget, which is meaningless without a
|
||
// repo to open the change against.
|
||
if (!REPO) return;
|
||
|
||
// A short ring buffer of recent route events, captured from load. Degrades to
|
||
// just the initial view when the page never client-navigates.
|
||
var USAGE = [];
|
||
// What this widget reports about ITSELF, and nothing else.
|
||
//
|
||
// Pageviews and autocapture belong to @hanzo/event, the one telemetry client a
|
||
// page runs. This widget is a script tag and cannot reach that client -- it is
|
||
// a module the app holds -- so if it reported a pageview there would be two
|
||
// clients counting the same load through the same door, which is exactly the
|
||
// defect a second hz.js tag already caused: every pageview counted twice.
|
||
//
|
||
// So the rule is not "coordinate with the other client", it is "never emit an
|
||
// event another client also emits". These are named for the widget and emitted
|
||
// by nothing else, so the two planes cannot overlap and there is no shared
|
||
// transport to keep in step.
|
||
//
|
||
// Same door as the recordings (`insights.hanzo.ai`), same publishable key, same
|
||
// opt-out: a reader who declined measurement is not measured here either.
|
||
function report(name, props) {
|
||
try {
|
||
if (!REPLAY.key || replayOptedOut()) return;
|
||
var sid = sessionId();
|
||
fetch(REPLAY.host + '/v1/event', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'omit',
|
||
body: JSON.stringify({
|
||
api_key: REPLAY.key,
|
||
event: 'controls_' + name,
|
||
distinct_id: sid,
|
||
properties: Object.assign(
|
||
{ $session_id: sid, $lib: 'hanzo-control', route: location.pathname },
|
||
props || {},
|
||
),
|
||
}),
|
||
}).catch(function () {
|
||
/* telemetry must never surface on the page being measured */
|
||
});
|
||
} catch (e) {
|
||
/* nor throw on it */
|
||
}
|
||
}
|
||
|
||
function pushUsage(kind) {
|
||
try {
|
||
USAGE.push({ t: Date.now(), route: location.pathname + location.search, kind: kind });
|
||
if (USAGE.length > 12) USAGE.shift();
|
||
} catch (e) {
|
||
/* ignore */
|
||
}
|
||
}
|
||
pushUsage('load');
|
||
(function hookHistory() {
|
||
try {
|
||
['pushState', 'replaceState'].forEach(function (m) {
|
||
var orig = history[m];
|
||
if (typeof orig !== 'function') return;
|
||
history[m] = function () {
|
||
var r = orig.apply(this, arguments);
|
||
pushUsage('nav');
|
||
return r;
|
||
};
|
||
});
|
||
window.addEventListener('popstate', function () {
|
||
pushUsage('nav');
|
||
});
|
||
} catch (e) {
|
||
/* history not patchable → single-entry trace, still fine */
|
||
}
|
||
})();
|
||
function usageTrace() {
|
||
if (!USAGE.length) return undefined;
|
||
// Normalize timestamps to seconds-ago so the trace reads at a glance.
|
||
var now = Date.now();
|
||
return USAGE.slice(-8).map(function (e) {
|
||
return { agoMs: now - e.t, route: e.route, kind: e.kind };
|
||
});
|
||
}
|
||
|
||
// The element most recently interacted with — the thing the user was looking
|
||
// at when they opened the widget (retargets to the shadow host for our own UI,
|
||
// which we ignore).
|
||
var lastEl = null;
|
||
var host = document.createElement('div');
|
||
host.setAttribute('data-hanzo-edit', '');
|
||
// ONE NUMBER, AND IT IS ON AN ELEMENT THE PAGE OWNS.
|
||
//
|
||
// The fab and the panel inside the shadow root asked for 2147483000, which is
|
||
// not a layer so much as a refusal to have one: nothing a page can write beats
|
||
// it, and shadow content cannot be reached by a page's stylesheet either. So
|
||
// the widget painted over a nav drawer's primary CTA on hanzo.ai — measured,
|
||
// 43×32px of "Try Hanzo" unreachable at 390 — and the page had no way to say
|
||
// otherwise.
|
||
//
|
||
// A positioned host with a z-index IS a stacking context, so everything in the
|
||
// shadow tree is clamped to this one value, and this element is in the page's
|
||
// own DOM: `[data-hanzo-edit] { z-index: 400 }` is now a rule that works. The
|
||
// default stays maximal, because this script is embedded on pages it does not
|
||
// own and most of them will never say anything. Only the page that HAS layers
|
||
// knows where a guest belongs.
|
||
host.style.position = 'fixed';
|
||
host.style.zIndex = '2147483000';
|
||
['pointerdown', 'click', 'focusin'].forEach(function (t) {
|
||
document.addEventListener(
|
||
t,
|
||
function (e) {
|
||
if (e.target && e.target !== host) lastEl = e.target;
|
||
},
|
||
true,
|
||
);
|
||
});
|
||
|
||
// ---- DOM breadcrumb (what was on screen) ----------------------------------
|
||
|
||
function attr(el, n) {
|
||
return el && el.getAttribute ? el.getAttribute(n) : null;
|
||
}
|
||
function nodeToken(el) {
|
||
var tag = el.tagName ? el.tagName.toLowerCase() : '';
|
||
var slot = attr(el, 'data-slot') || attr(el, 'data-component') || attr(el, 'data-testid');
|
||
if (slot) return tag + '[' + slot + ']';
|
||
if (el.id) return tag + '#' + el.id;
|
||
var aria = attr(el, 'aria-label');
|
||
if (aria) return tag + '[aria=' + aria.slice(0, 24) + ']';
|
||
var role = attr(el, 'role');
|
||
if (role) return tag + '[role=' + role + ']';
|
||
var cls = typeof el.className === 'string' ? el.className.trim().split(/\s+/)[0] : '';
|
||
return tag + (cls ? '.' + cls : '');
|
||
}
|
||
function breadcrumb() {
|
||
var el = lastEl && lastEl.isConnected ? lastEl : document.querySelector('main') || document.body;
|
||
var parts = [];
|
||
var hints = [];
|
||
var hops = 0;
|
||
while (el && el.nodeType === 1 && hops < 6) {
|
||
parts.unshift(nodeToken(el));
|
||
var slot = attr(el, 'data-slot') || attr(el, 'data-component');
|
||
if (slot) hints.push(slot);
|
||
if (el === document.body) break;
|
||
el = el.parentElement;
|
||
hops++;
|
||
}
|
||
return { crumb: parts.join(' > ').slice(0, 400), hints: hints };
|
||
}
|
||
|
||
// A React `_debugSource` on/above the element in view → the exact source file
|
||
// & line. Present only in DEV builds (the automatic JSX runtime strips it in
|
||
// production), so this is a bonus when available, never a dependency.
|
||
function firstPartyRel(fileName) {
|
||
var m = String(fileName || '').match(/(?:^|\/)((?:app|components|lib|src)\/.+)$/);
|
||
return m ? m[1] : null;
|
||
}
|
||
function fiberSource(el) {
|
||
try {
|
||
for (var k in el) {
|
||
if (k.indexOf('__reactFiber$') === 0 || k.indexOf('__reactInternalInstance$') === 0) {
|
||
var f = el[k];
|
||
var hops = 0;
|
||
while (f && hops < 40) {
|
||
if (f._debugSource && f._debugSource.fileName) {
|
||
var rel = firstPartyRel(f._debugSource.fileName);
|
||
if (rel) return { path: rel, line: f._debugSource.lineNumber };
|
||
}
|
||
f = f.return;
|
||
hops++;
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
/* ignore */
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ---- Route → source file resolution ---------------------------------------
|
||
|
||
var MANIFEST; // cached promise
|
||
function loadManifest() {
|
||
if (MANIFEST) return MANIFEST;
|
||
// The app being viewed serves its OWN manifest (same-origin), not hanzo.app's.
|
||
MANIFEST = fetch(location.origin + '/edit-manifest.json', { credentials: 'omit' })
|
||
.then(function (r) {
|
||
return r.ok ? r.json() : null;
|
||
})
|
||
.then(function (m) {
|
||
// Only trust file paths that describe THIS repo.
|
||
if (m && m.repo && REPO && m.repo !== REPO) return null;
|
||
return m && Array.isArray(m.routes) ? m : null;
|
||
})
|
||
.catch(function () {
|
||
return null;
|
||
});
|
||
return MANIFEST;
|
||
}
|
||
|
||
function pathParts(pathname) {
|
||
return (pathname || '/').split(/[?#]/)[0].split('/').filter(Boolean);
|
||
}
|
||
// Match a manifest route's segments against the current path; return a
|
||
// specificity score (static ≫ dynamic) or null when it doesn't match.
|
||
function matchSpec(segs, parts) {
|
||
var i = 0;
|
||
var spec = 0;
|
||
for (var si = 0; si < segs.length; si++) {
|
||
var s = segs[si];
|
||
if (s.k === 's') {
|
||
if (parts[i] !== s.v) return null;
|
||
i++;
|
||
spec += 3;
|
||
} else if (s.k === 'd') {
|
||
if (i >= parts.length) return null;
|
||
i++;
|
||
spec += 1;
|
||
} else if (s.k === 'c') {
|
||
if (i >= parts.length) return null;
|
||
i = parts.length;
|
||
} else if (s.k === 'o') {
|
||
i = parts.length;
|
||
}
|
||
}
|
||
return i === parts.length ? spec : null;
|
||
}
|
||
function fromManifest(manifest, parts) {
|
||
var best = null;
|
||
var bestSpec = -1;
|
||
for (var i = 0; i < manifest.routes.length; i++) {
|
||
var r = manifest.routes[i];
|
||
var spec = matchSpec(r.segments || [], parts);
|
||
if (spec === null) continue;
|
||
if (spec > bestSpec || (spec === bestSpec && (r.segments || []).length > (best.segments || []).length)) {
|
||
best = r;
|
||
bestSpec = spec;
|
||
}
|
||
}
|
||
if (!best) return [];
|
||
var out = [{ path: best.page, score: 0.9, why: 'route → page' }];
|
||
(best.layouts || []).forEach(function (l, idx) {
|
||
out.push({ path: l, score: 0.55 - idx * 0.05, why: 'route layout' });
|
||
});
|
||
return out;
|
||
}
|
||
function fromConvention(parts) {
|
||
var dir = parts.length ? 'app/' + parts.join('/') : 'app';
|
||
return [
|
||
{ path: dir + '/page.tsx', score: 0.4, why: 'convention guess' },
|
||
{ path: 'app/layout.tsx', score: 0.3, why: 'root layout' },
|
||
];
|
||
}
|
||
|
||
// Resolve a ranked, de-duplicated candidate list for the current view.
|
||
function resolveCandidates() {
|
||
var parts = pathParts(location.pathname);
|
||
return loadManifest().then(function (manifest) {
|
||
var out = [];
|
||
if (PATH) out.push({ path: PATH, score: 1.0, why: 'declared (hanzo:path)' });
|
||
var el = lastEl && lastEl.isConnected ? lastEl : null;
|
||
var fib = el ? fiberSource(el) : null;
|
||
if (fib) out.push({ path: fib.path, score: 0.95, why: 'react source (dev)' + (fib.line ? ':' + fib.line : '') });
|
||
out = out.concat(manifest ? fromManifest(manifest, parts) : fromConvention(parts));
|
||
if (manifest && out.filter(function (c) { return c.why.indexOf('route') === 0; }).length === 0) {
|
||
// Manifest loaded but no route matched (e.g. an unlisted path) → still
|
||
// give the convention guess something to chew on.
|
||
out = out.concat(fromConvention(parts));
|
||
}
|
||
// De-dupe by path, keeping the highest score; sort desc; cap.
|
||
var seen = {};
|
||
var ranked = [];
|
||
out
|
||
.sort(function (a, b) {
|
||
return b.score - a.score;
|
||
})
|
||
.forEach(function (c) {
|
||
if (seen[c.path]) return;
|
||
seen[c.path] = 1;
|
||
ranked.push({ path: c.path, score: Math.round(c.score * 100) / 100, why: c.why });
|
||
});
|
||
return { candidates: ranked.slice(0, 6), version: manifest ? manifest.version : undefined };
|
||
});
|
||
}
|
||
|
||
// ---- UI -------------------------------------------------------------------
|
||
|
||
var root = host.attachShadow ? host.attachShadow({ mode: 'open' }) : host;
|
||
|
||
// This script is loaded `async` and is often placed in <head>, so it can run
|
||
// before <body> is parsed — in which case `document.body` is null and the
|
||
// append throws, aborting the IIFE and leaving the widget absent. The shadow
|
||
// root is already attached and usable while detached, so build into it now
|
||
// and mount once the body exists.
|
||
// `hanzo:anchor` names an element the LAUNCHER should live inside — a toolbar
|
||
// slot, a status bar — instead of floating at the viewport corner. The fixed
|
||
// corner is right for a normal page and wrong wherever the product already
|
||
// owns that pixel: hanzo.app's builder had to disable the widget outright
|
||
// because the mark landed on top of the customer's preview, and a host page
|
||
// cannot restyle `.fab` from outside a shadow root.
|
||
//
|
||
// Anchoring moves the HOST, so the panel still opens from the launcher and the
|
||
// shadow boundary is untouched. A selector that matches nothing falls back to
|
||
// the corner rather than losing the widget.
|
||
// Put the launcher where `hanzo:anchor` points. Returns true once it lands in
|
||
// the slot. The catch: that slot is usually rendered by the host app's
|
||
// framework AFTER this script runs — hanzo.app's dock is a React node that does
|
||
// not exist at script-load — so a ONE-SHOT querySelector always missed it and
|
||
// the mark fell back to the fixed corner, full size, sitting on the builder
|
||
// chrome. Observed exactly that, three times. So `place` is retryable and
|
||
// `mount` watches for the slot instead of giving up on the first miss.
|
||
function place() {
|
||
var sel = meta('hanzo:anchor');
|
||
if (!sel) return false;
|
||
var slot = document.querySelector(sel);
|
||
if (!slot) return false;
|
||
if (host.parentNode !== slot) {
|
||
host.setAttribute('data-hanzo-anchored', '');
|
||
slot.appendChild(host);
|
||
}
|
||
return true;
|
||
}
|
||
// WATCH for the anchor slot to appear, then dock. The slot is rendered by the
|
||
// host app's framework AFTER this script runs, and on a single-page app the
|
||
// route that OWNS a slot is often reached by client navigation long after this
|
||
// one evaluation — so a one-shot query, OR a short-lived observer that gives
|
||
// up, both miss it and strand the mark at the corner, full size. (Measured on
|
||
// hanzo.app/dev: the `#enso-dock` slot and its meta were both present, yet the
|
||
// fab still floated 56px at the corner — the 10s observer had expired before
|
||
// the builder mounted its dock.) Watch until it lands; disconnect the instant
|
||
// it does. The slot node is stable once created, so there is nothing to
|
||
// re-observe after a successful dock.
|
||
var watching = false;
|
||
function watchForSlot() {
|
||
if (watching || place()) return;
|
||
// Nothing to wait for unless a slot is actually named: a page with no
|
||
// `hanzo:anchor` keeps the corner fallback and arms no observer.
|
||
if (!meta('hanzo:anchor')) return;
|
||
watching = true;
|
||
try {
|
||
var obs = new MutationObserver(function () {
|
||
if (place()) { obs.disconnect(); watching = false; }
|
||
});
|
||
obs.observe(document.documentElement, { childList: true, subtree: true });
|
||
// A floor, not a deadline: SPA navigation re-arms this (below), so the
|
||
// timer only stops the watch on a page that renders no slot at all — it
|
||
// never observes the DOM forever, and never gives up on a slow route.
|
||
setTimeout(function () { obs.disconnect(); watching = false; }, 30000);
|
||
} catch (e) {
|
||
watching = false; /* no MutationObserver: the corner fallback stands */
|
||
}
|
||
}
|
||
function mount() {
|
||
if (!host.parentNode) document.body.appendChild(host); // corner, meanwhile
|
||
watchForSlot();
|
||
}
|
||
if (document.body) {
|
||
mount();
|
||
} else {
|
||
document.addEventListener('DOMContentLoaded', mount, { once: true });
|
||
}
|
||
|
||
// Single-page apps swap the DOM under one script evaluation: the slot (and its
|
||
// `hanzo:anchor` meta) belong to a route reached by pushState, AFTER mount()
|
||
// already ran. Re-run placement on every route change so the launcher docks
|
||
// the moment you arrive at the route that owns a slot, and falls back to the
|
||
// corner on one that does not.
|
||
function onRoute() {
|
||
if (place()) return;
|
||
if (host.parentNode !== document.body) document.body.appendChild(host);
|
||
watchForSlot();
|
||
}
|
||
['pushState', 'replaceState'].forEach(function (m) {
|
||
var orig = history[m];
|
||
if (typeof orig !== 'function') return;
|
||
history[m] = function () {
|
||
var r = orig.apply(this, arguments);
|
||
try { onRoute(); } catch (e) { /* placement is best-effort */ }
|
||
return r;
|
||
};
|
||
});
|
||
window.addEventListener('popstate', onRoute);
|
||
|
||
// The widget's whole palette, declared ONCE, in terms of the host page's
|
||
// design tokens. Everything below reads these — no literal colour, font or
|
||
// radius appears twice.
|
||
//
|
||
// Custom properties cross the shadow boundary: they are inherited, and `all`
|
||
// does NOT reset them, so `:host{all:initial}` isolates this tree from the
|
||
// page's own rules while `--background`, `--brand-accent`, `--font-sans`
|
||
// and the rest still arrive intact. (Measured on hanzo.app: --brand-accent
|
||
// reads #8b5cf6 and --font-sans reads "Zen" from inside this root.)
|
||
// Adopting the page's stylesheets would be the wrong tool anyway — this script
|
||
// is dropped into OTHER Hanzo apps cross-origin, where the only contract that
|
||
// can be relied on is the token names.
|
||
//
|
||
// Every token carries the literal it replaces as its fallback, so a page that
|
||
// ships no token layer at all renders exactly what it rendered before.
|
||
var TOKENS =
|
||
':host{all:initial;' +
|
||
'--hz-font:Zen, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;' +
|
||
'--hz-mono:var(--font-mono,var(--font-mono,ui-monospace,SFMono-Regular,Menlo,monospace));' +
|
||
// Purple is the ONE accent in this system. The widget used to draw a stray
|
||
// #8ab4ff blue — links, the admin chip, the chosen candidate, the
|
||
// inline-edit outline — which belongs to no palette here.
|
||
'--hz-accent:var(--brand-accent,#8b5cf6);' +
|
||
'--hz-accent-soft:var(--brand-accent-soft,rgba(139,92,246,.14));' +
|
||
'--hz-panel:var(--card,#0e0e0e);' +
|
||
'--hz-field:var(--muted,#171717);' +
|
||
'--hz-text:var(--foreground,#f4f4f5);' +
|
||
'--hz-dim:var(--muted-foreground,#9a9a9a);' +
|
||
'--hz-line:var(--border,rgba(255,255,255,.14));' +
|
||
'--hz-radius:var(--control-radius,8px);' +
|
||
// The ensō lights in WHITE. It used to borrow the composer's
|
||
// --hz-spectrum, which is prismatic BY DESIGN and owner-directed — but that
|
||
// is a decision about the composer, the one surface allowed colour, and the
|
||
// mark inherited it only because the token happened to be in scope. Two
|
||
// different things wearing one value is not sharing, it is coupling: a mark
|
||
// that is meant to be a monochrome circle cannot be, because someone else's
|
||
// ring is iridescent.
|
||
//
|
||
// So the glow is its own token and its own value. Three white stops rather
|
||
// than one, because the sweep still turns: a uniform ring would rotate
|
||
// invisibly. Closes on the stop it opens with — a conic whose ends differ
|
||
// shows a hard seam.
|
||
'--hz-glow:var(--hz-mark-glow,rgba(255,255,255,.62),rgba(255,255,255,.22),' +
|
||
'rgba(255,255,255,.62));' +
|
||
'}';
|
||
|
||
var css =
|
||
TOKENS +
|
||
'*{box-sizing:border-box;font-family:Zen, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif}' +
|
||
// The trigger is the ensō mark alone — one affordance for every AI action
|
||
// on the page (ask, edit, suggest). It lights rather than grows, so it never
|
||
// reflows content or competes with the page's own controls.
|
||
//
|
||
// The mark IS the button: no disc, no plate, no shadow. It is the canonical
|
||
// Hanzo H (see MARK below) — monochrome and quiet until touched, then the
|
||
// SAME glyph lights white over a soft, slowly-turning halo. One glyph in both
|
||
// states, with nothing handed over to a second shape.
|
||
//
|
||
// --mark is the DRAWN diameter and the only size to change; the halo sizes
|
||
// itself from it.
|
||
//
|
||
// The drawn ring and the hit box are two different numbers, deliberately.
|
||
// The mark is 22px so the corner stays quiet on someone else's page — this
|
||
// script is embedded on pages it does not own, and a 34px ring in a 56px box
|
||
// read as a second product's button parked over theirs. The BOX stays 44,
|
||
// the touch floor: a thumb is about as wide as it is tall, and shrinking the
|
||
// target with the glyph is how an affordance becomes decorative. Nothing
|
||
// catches that regression either — the fab lives in a shadow root, so the
|
||
// deploy gate's querySelectorAll cannot see it.
|
||
':host([data-hanzo-anchored]) .fab{--mark:18px;position:relative;right:auto;bottom:auto;' +
|
||
'width:20px;height:20px}' +
|
||
'.fab{--mark:22px;position:fixed;right:16px;bottom:16px;z-index:2147483000;display:inline-flex;' +
|
||
'align-items:center;justify-content:center;width:44px;height:44px;padding:0;' +
|
||
'border-radius:999px;border:0;background:transparent;color:var(--hz-text);' +
|
||
'cursor:pointer;line-height:0;-webkit-tap-highlight-color:transparent;' +
|
||
'transition:transform .2s ease}' +
|
||
// `position:relative` puts the ring in the same paint phase as the halo, so
|
||
// tree order stacks the ::before halo UNDER the SVG ring. Without it the SVG
|
||
// paints below every positioned box and the halo covers the mark.
|
||
'.fab svg{width:var(--mark);height:var(--mark);display:block;overflow:visible;position:relative;' +
|
||
'color:var(--hz-dim);transition:color .25s ease}' +
|
||
// The ring stays drawn on hover and lights white; it is never hidden, so the
|
||
// thick ensō is what you see in both states.
|
||
'.fab:hover svg,.fab:focus-visible svg{color:var(--hz-text)}' +
|
||
// One conic sweep, blurred into a soft halo BEHIND the ring — absent at
|
||
// rest, lit on touch and slowly turning. The ring itself is the SVG ensō
|
||
// above; the halo only glows around it.
|
||
'.fab::before{content:"";position:absolute;left:50%;top:50%;' +
|
||
'width:calc(var(--mark) * .9);height:calc(var(--mark) * .9);border-radius:999px;' +
|
||
'background:conic-gradient(var(--hz-glow));transform:translate(-50%,-50%);' +
|
||
'opacity:0;transition:opacity .25s ease;pointer-events:none;' +
|
||
// No `saturate()` — there is no hue left to saturate, and pushing it only
|
||
// hardened the blur's edge.
|
||
'filter:blur(calc(var(--mark) * .22))}' +
|
||
'.fab:hover::before,.fab:focus-visible::before,.fab:active::before{opacity:.7}' +
|
||
'.fab:hover{transform:scale(1.05)}' +
|
||
'.fab:focus-visible{outline:none}' +
|
||
'.fab:active{transform:scale(.96)}' +
|
||
// The sweep turns only while the mark is held, and only where motion is
|
||
// welcome. Everywhere else it holds a still frame — lit, not moving.
|
||
'@media (prefers-reduced-motion:no-preference){' +
|
||
'.fab:hover::before,.fab:focus-visible::before' +
|
||
'{animation:hzPrism 6s linear infinite}}' +
|
||
'@keyframes hzPrism{from{transform:translate(-50%,-50%) rotate(0)}' +
|
||
'to{transform:translate(-50%,-50%) rotate(360deg)}}' +
|
||
'@media (prefers-reduced-motion:reduce){.fab{transition:none}' +
|
||
'.fab:hover,.fab:active{transform:none}}' +
|
||
'.panel{position:fixed;right:16px;bottom:16px;z-index:2147483001;width:360px;max-width:92vw;' +
|
||
'background:var(--hz-panel);color:var(--hz-text);border:1px solid var(--hz-line);border-radius:14px;' +
|
||
'box-shadow:0 12px 40px rgba(0,0,0,.5);overflow:hidden;display:none}' +
|
||
'.panel.open{display:block}' +
|
||
'.hd{display:flex;align-items:center;justify-content:space-between;padding:12px 14px;border-bottom:1px solid var(--hz-line)}' +
|
||
'.hd b{font-size:13px;font-weight:600}' +
|
||
'.hd .sub{font-size:11px;color:var(--hz-dim);margin-top:2px}' +
|
||
'.x{background:none;border:none;color:var(--hz-dim);cursor:pointer;font-size:18px;line-height:1;padding:2px 4px}' +
|
||
'.x:hover{color:var(--hz-text)}' +
|
||
'.bd{padding:14px}' +
|
||
'textarea{width:100%;min-height:84px;resize:vertical;background:var(--hz-field);color:var(--hz-text);' +
|
||
'border:1px solid var(--hz-line);border-radius:var(--hz-radius);padding:9px 10px;font-size:13px;outline:none}' +
|
||
'textarea:focus{border-color:var(--hz-accent)}' +
|
||
'input.path{width:100%;margin-top:8px;background:var(--hz-field);color:var(--hz-text);border:1px solid var(--hz-line);' +
|
||
'border-radius:var(--hz-radius);padding:8px 10px;font-size:12px;font-family:Zen Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;outline:none}' +
|
||
'input.path:focus{border-color:var(--hz-accent)}' +
|
||
'.cands{margin-top:7px;display:flex;flex-wrap:wrap;gap:6px}' +
|
||
'.cand{font-family:Zen Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;font-size:11px;color:var(--hz-dim);background:var(--hz-field);' +
|
||
'border:1px solid var(--hz-line);border-radius:6px;padding:3px 7px;cursor:pointer;max-width:100%;overflow:hidden;' +
|
||
'text-overflow:ellipsis;white-space:nowrap}' +
|
||
'.cand:hover{border-color:var(--hz-accent);color:var(--hz-text)}' +
|
||
'.cand.on{border-color:var(--hz-accent);background:var(--hz-accent-soft);color:var(--hz-text)}' +
|
||
'.ctx{font-size:11px;color:var(--hz-dim);margin-top:9px;line-height:1.5;word-break:break-word}' +
|
||
'.row{display:flex;gap:8px;margin-top:12px;align-items:center}' +
|
||
// Primary action stays WHITE — purple is links/active/focus only, never the
|
||
// primary button (the v2 monochrome rule).
|
||
'.btn{flex:1;padding:10px 12px;border-radius:var(--hz-radius);border:none;background:#fff;color:#000;' +
|
||
'font-size:13px;font-weight:600;cursor:pointer}' +
|
||
'.btn:hover{background:#e8e8e8}' +
|
||
'.btn:disabled{opacity:.55;cursor:default}' +
|
||
'.btn.sec{flex:0 0 auto;background:transparent;color:var(--hz-dim);border:1px solid var(--hz-line);font-weight:500}' +
|
||
'.btn.sec:hover{background:var(--hz-field);color:var(--hz-text)}' +
|
||
'.note{font-size:11px;color:var(--hz-dim);margin-top:9px}' +
|
||
// Help and preferences share the panel with the form, under one rule each.
|
||
// A reader in the corner of a page wants a way out and a way to make it
|
||
// readable; two more bubbles would ask them to guess which held which.
|
||
'.sect{border-top:1px solid var(--hz-line);margin-top:12px;padding-top:10px}' +
|
||
'.sect h3{font-size:11px;font-weight:500;color:var(--hz-dim);margin:0 0 4px}' +
|
||
'.hlink{display:flex;align-items:center;justify-content:flex-start;gap:6px;' +
|
||
'min-height:32px;font-size:13px;color:var(--hz-text);text-decoration:none}' +
|
||
'.hlink svg{opacity:.5}.hlink:hover svg{opacity:.9}' +
|
||
'.prow{display:flex;align-items:center;justify-content:space-between;gap:10px;' +
|
||
'min-height:34px;font-size:13px}' +
|
||
'.trk{display:inline-flex;align-items:center;gap:2px;padding:2px;border-radius:999px;' +
|
||
'background:var(--hz-soft,rgba(255,255,255,.06))}' +
|
||
'.opt{height:22px;padding:0 8px;border:0;border-radius:999px;background:transparent;' +
|
||
'color:var(--hz-dim);font-size:11px;font-family:Zen, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;cursor:pointer}' +
|
||
'.opt[data-on]{background:rgba(255,255,255,.10);color:var(--hz-text)}' +
|
||
'.link{color:var(--hz-accent);text-decoration:none}' +
|
||
'.link:hover{text-decoration:underline}' +
|
||
'.msg{font-size:13px;line-height:1.5;word-break:break-word}' +
|
||
'.msg.err{color:#ff9d9d}' +
|
||
// Admin review: the proposed-change diff before a live commit. Added/removed
|
||
// lines stay green/red — that is semantics, not chrome.
|
||
'.diff{margin-top:10px;max-height:38vh;overflow:auto;background:var(--hz-field);border:1px solid var(--hz-line);' +
|
||
'border-radius:var(--hz-radius);padding:8px 10px;font-family:Zen Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;font-size:11px;' +
|
||
'line-height:1.45;white-space:pre;tab-size:2}' +
|
||
'.diff .a{color:#7ee787;background:rgba(46,160,67,.12);display:block}' +
|
||
'.diff .d{color:#ff9d9d;background:rgba(248,81,73,.12);display:block}' +
|
||
'.diff .c{color:var(--hz-dim);display:block}' +
|
||
'.warn{font-size:11px;color:#e3b341;margin-top:9px}' +
|
||
'code{font-family:Zen Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;font-size:11px;background:var(--hz-field);' +
|
||
'border:1px solid var(--hz-line);border-radius:5px;padding:1px 5px}' +
|
||
// Admin affordances: the "admin" chip + the ghost inline-edit button.
|
||
'.adm{color:var(--hz-accent);font-weight:600;text-transform:uppercase;letter-spacing:.04em;font-size:10px}' +
|
||
'.btn.ghost{width:100%;margin-top:8px;background:transparent;color:var(--hz-dim);border:1px dashed var(--hz-line);' +
|
||
'font-weight:500;padding:9px 12px;border-radius:var(--hz-radius);cursor:pointer}' +
|
||
'.btn.ghost:hover{border-color:var(--hz-accent);color:var(--hz-text)}' +
|
||
'.spin{display:inline-block;width:13px;height:13px;border:2px solid var(--hz-line);border-top-color:var(--hz-text);' +
|
||
'border-radius:50%;animation:hz 0.7s linear infinite;vertical-align:-2px;margin-right:6px}' +
|
||
'@keyframes hz{to{transform:rotate(360deg)}}' +
|
||
// Mobile: the panel becomes a bottom-sheet (full-width, rounded top, safe-area
|
||
// inset) with larger touch targets; the FAB tucks above the home indicator.
|
||
'@media (max-width:560px){' +
|
||
'.fab{right:12px;bottom:calc(12px + env(safe-area-inset-bottom))}' +
|
||
'.panel{left:0;right:0;bottom:0;top:auto;width:100%;max-width:100%;border-radius:16px 16px 0 0;' +
|
||
'border-left:none;border-right:none;border-bottom:none;max-height:88vh;overflow-y:auto;' +
|
||
'padding-bottom:env(safe-area-inset-bottom)}' +
|
||
'.hd{padding:14px 16px}.bd{padding:16px}' +
|
||
'textarea{min-height:96px;font-size:16px}' + // 16px ⇒ iOS never zooms on focus
|
||
'input.path{font-size:14px;padding:11px 12px}' +
|
||
'.btn{padding:14px 14px;font-size:15px}.btn.sec{padding:14px 16px}.btn.ghost{padding:13px 14px}' +
|
||
'.cand{padding:6px 10px;font-size:12px}' +
|
||
'.x{font-size:24px;padding:6px 10px}' +
|
||
'}';
|
||
|
||
var style = document.createElement('style');
|
||
style.textContent = css;
|
||
root.appendChild(style);
|
||
|
||
var PENCIL =
|
||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
|
||
'<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>';
|
||
|
||
// The ensō — Hanzo's AI mark, the CANONICAL glyph: a closed brush ring, r=8.88
|
||
// on a 24 viewBox, stroke 2.64, round caps — byte-identical to the one
|
||
// @hanzo/logo carries and model-icon.tsx / the hanzo.ai models mark draw, so
|
||
// the ensō is one thick weight everywhere. The stroke scales with the mark
|
||
// (no `vector-effect`), like every other place this glyph is drawn: at the
|
||
// 18px dock size it lands ~2px, a bold little brush ring — the weight the mark
|
||
// is meant to have, not the thin hairline it briefly wore.
|
||
// The H, the same five paths @hanzogui/shell draws. The glyph is the
|
||
// assistant's across every surface -- the header says the name instead -- so a
|
||
// reader learns one shape in one corner rather than meeting it in two places
|
||
// meaning two things.
|
||
var MARK =
|
||
'<svg viewBox="0 0 67 67" xmlns="http://www.w3.org/2000/svg" fill="currentColor" aria-hidden="true" focusable="false">' +
|
||
'<path d="M22.21 67V44.6369H0V67H22.21Z"/>' +
|
||
'<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z"/>' +
|
||
'<path d="M22.21 0H0V22.3184H22.21V0Z"/>' +
|
||
'<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z"/>' +
|
||
'<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z"/></svg>';
|
||
|
||
var fab = document.createElement('button');
|
||
fab.className = 'fab';
|
||
fab.setAttribute('aria-label', 'Ask or edit this page');
|
||
fab.setAttribute('title', 'Ask or edit this page');
|
||
fab.innerHTML = MARK;
|
||
root.appendChild(fab);
|
||
|
||
var panel = document.createElement('div');
|
||
panel.className = 'panel';
|
||
root.appendChild(panel);
|
||
|
||
var ME = {
|
||
authenticated: false,
|
||
isAdmin: false,
|
||
hasCredits: false,
|
||
balance: null,
|
||
// WHY the balance is what it is. `hasCredits:false` alone cannot tell a real
|
||
// zero from a balance we failed to read, and those need opposite remedies.
|
||
balanceState: 'anonymous',
|
||
};
|
||
|
||
// Resolved-once-per-open view context.
|
||
var CTX = { candidates: [], version: undefined, chosen: '' };
|
||
|
||
function esc(s) {
|
||
return String(s).replace(/[&<>"]/g, function (c) {
|
||
return { '&': '&', '<': '<', '>': '>', '"': '"' }[c];
|
||
});
|
||
}
|
||
|
||
// Decide the primary CTA from identity + credits. Admin's primary is the
|
||
// "goes live" direct commit; the server re-checks sudo for that mode,
|
||
// so this only shapes the UI.
|
||
function cta() {
|
||
if (ME.isAdmin)
|
||
return {
|
||
label: 'Apply live',
|
||
action: 'edit',
|
||
mode: 'direct',
|
||
admin: true,
|
||
note: 'Commits directly to ' + BRANCH + ' — goes live.',
|
||
};
|
||
if (ME.authenticated && ME.hasCredits) return { label: 'Submit fix', action: 'edit', note: 'Uses your credits.' };
|
||
// Not funded — but WHY decides the remedy, and only 'ok' is a real zero.
|
||
// `/v1/me` and `/v1/edit` both already keep these apart (a refused balance is
|
||
// a 401 there, an unreadable one a 503, and 402 is reserved for a balance we
|
||
// READ and found empty). Reading `hasCredits` alone put them back together
|
||
// and billed the customer for our failure: a funded account whose token had
|
||
// gone stale was told to top up.
|
||
if (ME.authenticated && ME.balanceState === 'noauth')
|
||
return { label: 'Suggest a fix', action: 'suggest', stale: true };
|
||
if (ME.authenticated && ME.balanceState === 'unavailable')
|
||
return { label: 'Suggest a fix', action: 'suggest', down: true };
|
||
if (ME.authenticated) return { label: 'Suggest a fix', action: 'suggest', top: true };
|
||
// Anonymous: /v1/suggest requires a name now, so offering the form would
|
||
// collect a paragraph and answer 401. Say what is needed BEFORE it is typed.
|
||
return { label: 'Sign in to suggest', action: 'login', login: true };
|
||
}
|
||
|
||
// ---- Help and preferences -------------------------------------------------
|
||
//
|
||
// The knobs are @hanzo/design's, and so is the transform that produces them:
|
||
// `vars(pref)` is imported from the vendored `preference.js` rather than
|
||
// restated here. That module is the one place density 'compact' means 0.85
|
||
// and a face resolves to var(--font-sans); a widget with its own copy is the
|
||
// drift @hanzo/design's docblock already records once.
|
||
//
|
||
// The knobs land on the HOST page's <html>, not in this shadow root — the
|
||
// reader is adjusting the page they are reading, and the root is where every
|
||
// ramp reads them from.
|
||
var PREF_KEY = 'hanzo.appearance'; // @hanzo/appearance exports this as KEY
|
||
var VARS = null; // resolved transform, or null until it arrives
|
||
|
||
var PREF_ROWS = [
|
||
{ axis: 'type', label: 'Text size', opts: [['S', 0.9], ['M', 1], ['L', 1.15], ['XL', 1.3]] },
|
||
{ axis: 'ratio', label: 'Scale', opts: [['Flat', 0.85], ['Default', 1], ['Airy', 1.2]] },
|
||
{ axis: 'density', label: 'Spacing', opts: [['Tight', 'compact'], ['Default', 'default'], ['Roomy', 'comfortable']] },
|
||
{ axis: 'font', label: 'Font', opts: [['Sans', 'default'], ['System', 'system'], ['Serif', 'serif'], ['Mono', 'mono']] },
|
||
{ axis: 'width', label: 'Width', opts: [['Narrow', 'narrow'], ['Default', 'default'], ['Wide', 'wide']] },
|
||
];
|
||
|
||
// Every property vars() can emit. The removal list has to be exhaustive or
|
||
// clearing an axis leaves its last value stuck on the page.
|
||
var PREF_KNOBS = ['--type-scale', '--type-ratio', '--density', '--font-sans',
|
||
'--container-max', '--container-prose', '--container-wide', '--primary', '--accent'];
|
||
|
||
function readPref() {
|
||
try {
|
||
var raw = window.localStorage.getItem(PREF_KEY);
|
||
var p = raw ? JSON.parse(raw) : {};
|
||
return p && typeof p === 'object' ? p : {};
|
||
} catch (e) {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
function applyPref(p) {
|
||
if (!VARS) return;
|
||
var out = VARS(p);
|
||
var st = document.documentElement.style;
|
||
for (var i = 0; i < PREF_KNOBS.length; i++) {
|
||
var k = PREF_KNOBS[i];
|
||
// An axis nobody set is REMOVED, never written as a neutral: an inline
|
||
// property outranks every stylesheet, so a neutral 1 would silently
|
||
// override a brand that published its own scale.
|
||
if (out[k]) st.setProperty(k, out[k]);
|
||
else st.removeProperty(k);
|
||
}
|
||
}
|
||
|
||
function setPref(axis, value) {
|
||
var p = readPref();
|
||
p[axis] = value;
|
||
try {
|
||
window.localStorage.setItem(PREF_KEY, JSON.stringify(p));
|
||
} catch (e) {
|
||
/* storage can be blocked; the page still updates for this visit */
|
||
}
|
||
applyPref(p);
|
||
return p;
|
||
}
|
||
|
||
function helpHtml() {
|
||
var out = '<a class="hlink" href="' + BASE + '/docs" target="_blank" rel="noopener">Docs' + OUTLINK + '</a>';
|
||
out += '<a class="hlink" href="' + BASE + '/support" target="_blank" rel="noopener">Get help' + OUTLINK + '</a>';
|
||
out += '<a class="hlink" href="' + BASE + '/contact" target="_blank" rel="noopener">Contact us' + OUTLINK + '</a>';
|
||
return '<div class="sect"><h3>Help</h3>' + out + '</div>';
|
||
}
|
||
|
||
var OUTLINK =
|
||
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
|
||
'stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M7 17L17 7M8 7h9v9"/></svg>';
|
||
|
||
function prefsHtml() {
|
||
// Nothing is offered until the transform is here — a control that cannot
|
||
// apply its own value is worse than one that is not shown yet.
|
||
if (!VARS) return '';
|
||
var p = readPref();
|
||
var rows = PREF_ROWS.map(function (r) {
|
||
var cur = p[r.axis];
|
||
var opts = r.opts
|
||
.map(function (o) {
|
||
var on = cur === undefined ? o[1] === 1 || o[1] === 'default' : cur === o[1];
|
||
return (
|
||
'<button type="button" class="opt"' + (on ? ' data-on' : '') +
|
||
' data-axis="' + r.axis + '" data-val="' + esc(String(o[1])) + '">' + esc(o[0]) + '</button>'
|
||
);
|
||
})
|
||
.join('');
|
||
return '<div class="prow"><span>' + esc(r.label) + '</span><span class="trk">' + opts + '</span></div>';
|
||
}).join('');
|
||
return '<div class="sect"><h3>Preferences</h3>' + rows + '</div>';
|
||
}
|
||
|
||
function wirePrefs(root) {
|
||
var btns = root.querySelectorAll('.opt[data-axis]');
|
||
for (var i = 0; i < btns.length; i++) {
|
||
btns[i].onclick = function () {
|
||
var axis = this.getAttribute('data-axis');
|
||
var raw = this.getAttribute('data-val');
|
||
// A numeric axis stores a number; a named one stores its word.
|
||
var val = axis === 'type' || axis === 'ratio' ? parseFloat(raw) : raw;
|
||
setPref(axis, val);
|
||
renderForm();
|
||
};
|
||
}
|
||
}
|
||
|
||
function renderForm() {
|
||
var c = cta();
|
||
var showPath = c.action === 'edit';
|
||
var chosen = CTX.chosen || (CTX.candidates[0] && CTX.candidates[0].path) || PATH || '';
|
||
var candChips = CTX.candidates.length
|
||
? '<div class="cands">' +
|
||
CTX.candidates
|
||
.map(function (cand) {
|
||
return (
|
||
'<button type="button" class="cand' +
|
||
(cand.path === chosen ? ' on' : '') +
|
||
'" data-path="' +
|
||
esc(cand.path) +
|
||
'" title="' +
|
||
esc(cand.why) +
|
||
'">' +
|
||
esc(cand.path) +
|
||
'</button>'
|
||
);
|
||
})
|
||
.join('') +
|
||
'</div>'
|
||
: '';
|
||
panel.innerHTML =
|
||
'<div class="hd"><div><b>' +
|
||
(c.admin ? 'Command this page' : 'Improve this page') +
|
||
'</b><div class="sub">' +
|
||
esc(REPO) +
|
||
(BRANCH ? ' · ' + esc(BRANCH) : '') +
|
||
(c.admin ? ' · <span class="adm">admin</span>' : '') +
|
||
'</div></div><button class="x" aria-label="Close">×</button></div>' +
|
||
'<div class="bd">' +
|
||
'<textarea placeholder="' +
|
||
(c.admin
|
||
? 'Command — e.g. “change the hero headline to Ship faster”'
|
||
: 'Describe the change or fix…') +
|
||
'"></textarea>' +
|
||
(showPath
|
||
? '<input class="path" placeholder="auto-detected file — edit to override" value="' + esc(chosen) + '"/>' + candChips
|
||
: '') +
|
||
(c.admin
|
||
? '<button type="button" class="btn ghost" data-inline>✎ Edit text directly on the page</button>'
|
||
: '') +
|
||
'<div class="row">' +
|
||
'<button class="btn primary">' +
|
||
esc(c.label) +
|
||
'</button>' +
|
||
(c.admin
|
||
? '<button class="btn sec" data-pr>Open PR</button>'
|
||
: c.action === 'edit'
|
||
? '<button class="btn sec" data-suggest>Suggest</button>'
|
||
: '') +
|
||
'</div>' +
|
||
(c.note ? '<div class="note">' + esc(c.note) + '</div>' : '') +
|
||
(c.top
|
||
? '<div class="note"><a class="link" href="' + BASE + '/billing" target="_blank" rel="noopener">Top up</a> to open a PR directly.</div>'
|
||
: '') +
|
||
(c.stale
|
||
? '<div class="note">We couldn’t read your balance — your session expired. <a class="link" href="' +
|
||
BASE +
|
||
'/login" target="_blank" rel="noopener">Sign in again</a> to open a PR.</div>'
|
||
: '') +
|
||
(c.down
|
||
? '<div class="note">Billing is unreachable, which is on us — your credits are untouched. Try again shortly.</div>'
|
||
: '') +
|
||
(c.login
|
||
? '<div class="note"><a class="link" href="' + BASE + '/login" target="_blank" rel="noopener">Log in</a> to open a PR directly.</div>'
|
||
: '') +
|
||
'<div class="ctx">Context attached: <b>' +
|
||
esc(location.pathname) +
|
||
'</b>' +
|
||
(CTX.candidates.length ? ' · ' + CTX.candidates.length + ' candidate file' + (CTX.candidates.length > 1 ? 's' : '') : '') +
|
||
(CTX.version ? ' · v' + esc(CTX.version) : '') +
|
||
'</div>' +
|
||
helpHtml() +
|
||
prefsHtml() +
|
||
'</div>';
|
||
|
||
wirePrefs(panel);
|
||
panel.querySelector('.x').onclick = close;
|
||
var ta = panel.querySelector('textarea');
|
||
var pathInput = panel.querySelector('.path');
|
||
ta.focus();
|
||
|
||
// Candidate chips set the path field (and remember the choice).
|
||
Array.prototype.forEach.call(panel.querySelectorAll('.cand'), function (chip) {
|
||
chip.onclick = function () {
|
||
CTX.chosen = chip.getAttribute('data-path');
|
||
if (pathInput) pathInput.value = CTX.chosen;
|
||
Array.prototype.forEach.call(panel.querySelectorAll('.cand'), function (o) {
|
||
o.classList.toggle('on', o === chip);
|
||
});
|
||
};
|
||
});
|
||
if (pathInput)
|
||
pathInput.oninput = function () {
|
||
CTX.chosen = pathInput.value;
|
||
};
|
||
|
||
var pathOf = function () {
|
||
return pathInput ? pathInput.value : chosen;
|
||
};
|
||
panel.querySelector('.btn.primary').onclick = function () {
|
||
submit(c.action, ta.value, pathOf(), c.mode);
|
||
};
|
||
var sug = panel.querySelector('[data-suggest]');
|
||
if (sug)
|
||
sug.onclick = function () {
|
||
submit('suggest', ta.value, pathOf());
|
||
};
|
||
// Admin: "Open PR" runs the same agent edit but as a PR (mode omitted).
|
||
var pr = panel.querySelector('[data-pr]');
|
||
if (pr)
|
||
pr.onclick = function () {
|
||
submit('edit', ta.value, pathOf(), 'pr');
|
||
};
|
||
// Admin: inline click-to-edit the tracked element → a precise instruction.
|
||
var inl = panel.querySelector('[data-inline]');
|
||
if (inl)
|
||
inl.onclick = function () {
|
||
inlineEdit(pathOf());
|
||
};
|
||
}
|
||
|
||
function showMessage(html, isErr) {
|
||
panel.innerHTML =
|
||
'<div class="hd"><div><b>Hanzo Edit</b></div><button class="x" aria-label="Close">×</button></div>' +
|
||
'<div class="bd"><div class="msg' +
|
||
(isErr ? ' err' : '') +
|
||
'">' +
|
||
html +
|
||
'</div>' +
|
||
'<div class="row"><button class="btn primary" data-again>New suggestion</button></div></div>';
|
||
panel.querySelector('.x').onclick = close;
|
||
panel.querySelector('[data-again]').onclick = renderForm;
|
||
}
|
||
|
||
function busy(label) {
|
||
var b = panel.querySelector('.btn.primary');
|
||
if (b) {
|
||
b.disabled = true;
|
||
b.innerHTML = '<span class="spin"></span>' + esc(label);
|
||
}
|
||
var s = panel.querySelector('[data-suggest]');
|
||
if (s) s.disabled = true;
|
||
}
|
||
|
||
// The rich context every submission carries — enough for an agent or dev to
|
||
// review and finish the fix.
|
||
function contextTrace() {
|
||
var bc = breadcrumb();
|
||
return {
|
||
route: location.pathname,
|
||
candidateFiles: CTX.candidates,
|
||
domBreadcrumb: bc.crumb || undefined,
|
||
appVersion: CTX.version || meta('hanzo:version') || undefined,
|
||
sessionId: sessionId() || undefined,
|
||
replayRef: replayRef(),
|
||
usageTrace: usageTrace(),
|
||
};
|
||
}
|
||
|
||
// The shared payload skeleton every submission carries.
|
||
function editPayload(effectivePath) {
|
||
var trace = contextTrace();
|
||
return {
|
||
repo: REPO,
|
||
provider: PROVIDER,
|
||
path: effectivePath || undefined,
|
||
branch: BRANCH,
|
||
url: location.href,
|
||
key: KEY || undefined,
|
||
context: selection() || undefined,
|
||
route: trace.route,
|
||
candidateFiles: trace.candidateFiles && trace.candidateFiles.length ? trace.candidateFiles : undefined,
|
||
domBreadcrumb: trace.domBreadcrumb,
|
||
appVersion: trace.appVersion,
|
||
sessionId: trace.sessionId,
|
||
replayRef: trace.replayRef,
|
||
usageTrace: trace.usageTrace,
|
||
};
|
||
}
|
||
|
||
function submit(action, text, path, mode) {
|
||
// Signed out: the door refuses an unnamed suggestion, so the primary control
|
||
// opens the sign-in rather than collecting a paragraph to throw away. The
|
||
// return brings the reader back to the page they were reading.
|
||
if (action === 'login') {
|
||
window.open(BASE + '/login?return=' + encodeURIComponent(location.href), '_blank', 'noopener');
|
||
return;
|
||
}
|
||
text = (text || '').trim();
|
||
if (!text) return;
|
||
var effectivePath = (path || '').trim() || (CTX.candidates[0] && CTX.candidates[0].path) || PATH || '';
|
||
|
||
if (action === 'suggest') {
|
||
report('suggested');
|
||
var payload = editPayload(effectivePath);
|
||
payload.suggestion = text;
|
||
busy('Sending…');
|
||
api('/v1/suggest', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
})
|
||
.then(readJson)
|
||
.then(function (r) {
|
||
if (r.data && r.data.ok && r.data.issueUrl) {
|
||
showMessage(
|
||
'Suggestion filed: <a class="link" href="' +
|
||
esc(r.data.issueUrl) +
|
||
'" target="_blank" rel="noopener">view issue ↗</a>',
|
||
);
|
||
} else if (r.data && r.data.ok) {
|
||
showMessage('Thanks — your suggestion was received.');
|
||
} else {
|
||
showMessage(esc((r.data && r.data.error) || 'Could not send the suggestion.'), true);
|
||
}
|
||
})
|
||
.catch(function () {
|
||
showMessage('Network error — please try again.', true);
|
||
});
|
||
return;
|
||
}
|
||
|
||
report('edited', { mode: mode });
|
||
postEdit(text, effectivePath, mode);
|
||
}
|
||
|
||
// Timestamp of the in-flight edit, so a direct commit can report ~elapsed
|
||
// seconds ("live in ~Ns").
|
||
var editT0 = 0;
|
||
|
||
// Run the agent edit: mode 'direct' asks the server to commit straight to the
|
||
// default branch (honored ONLY for an admin — the server re-checks); anything
|
||
// else is the fork→PR flow. All privilege lives server-side; mode is a hint.
|
||
// The in-flight direct edit, remembered so the CONFIRM phase can resend the
|
||
// same instruction + path alongside the admin-approved bytes.
|
||
var lastEdit = null;
|
||
|
||
function postEdit(instruction, path, mode) {
|
||
instruction = (instruction || '').trim();
|
||
if (!instruction) return;
|
||
var effectivePath = (path || '').trim() || (CTX.candidates[0] && CTX.candidates[0].path) || PATH || '';
|
||
if (!effectivePath) {
|
||
showMessage('Couldn’t detect a source file for this view — use <b>Suggest</b> instead.', true);
|
||
return;
|
||
}
|
||
lastEdit = { instruction: instruction, path: effectivePath };
|
||
var payload = editPayload(effectivePath);
|
||
payload.instruction = instruction;
|
||
if (mode) payload.mode = mode;
|
||
// Direct mode returns a PROPOSAL first (nothing commits until the admin
|
||
// confirms), so the button reads "Reviewing…" not "Applying…".
|
||
busy(mode === 'direct' ? 'Reviewing…' : 'Opening PR…');
|
||
editT0 = Date.now();
|
||
api('/v1/edit', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
})
|
||
.then(readJson)
|
||
.then(renderEditResult)
|
||
.catch(function () {
|
||
showMessage('Network error — please try again.', true);
|
||
});
|
||
}
|
||
|
||
// CONFIRM phase: the admin has reviewed the proposal; commit the EXACT approved
|
||
// bytes straight to the default branch, optimistic-locked to the reviewed sha.
|
||
function confirmEdit(proposed, baseSha) {
|
||
if (!lastEdit) return;
|
||
var payload = editPayload(lastEdit.path);
|
||
payload.instruction = lastEdit.instruction;
|
||
payload.mode = 'direct';
|
||
payload.reviewed = proposed;
|
||
payload.baseSha = baseSha;
|
||
showMessageBusy('Applying…');
|
||
editT0 = Date.now();
|
||
api('/v1/edit', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
})
|
||
.then(readJson)
|
||
.then(renderEditResult)
|
||
.catch(function () {
|
||
showMessage('Network error — please try again.', true);
|
||
});
|
||
}
|
||
|
||
// Render a diff string ("+ "/"- "/" " line prefixes) as colored lines.
|
||
function renderDiff(diff) {
|
||
return diff
|
||
.split('\n')
|
||
.map(function (ln) {
|
||
var k = ln.charAt(0) === '+' ? 'a' : ln.charAt(0) === '-' ? 'd' : 'c';
|
||
return '<span class="' + k + '">' + esc(ln) + '</span>';
|
||
})
|
||
.join('');
|
||
}
|
||
|
||
// The admin review gate: show the proposed change (diff, or full contents when
|
||
// the file is too large to diff) and require an explicit "Apply live" before
|
||
// anything reaches the default branch.
|
||
function renderPreview(d) {
|
||
var body = d.diff
|
||
? '<div class="diff">' + renderDiff(d.diff) + '</div>'
|
||
: '<div class="warn">Large file — review the full proposed contents below.</div>' +
|
||
'<div class="diff">' + esc(String(d.proposed || '')) + '</div>';
|
||
panel.innerHTML =
|
||
'<div class="hd"><div><b>Review before it goes live</b><div class="sub">' +
|
||
esc(d.path || (lastEdit && lastEdit.path) || '') +
|
||
' · ' + esc(d.branch || BRANCH) +
|
||
' · <span class="adm">admin</span></div></div><button class="x" aria-label="Close">×</button></div>' +
|
||
'<div class="bd">' +
|
||
body +
|
||
'<div class="warn">This commits directly to ' + esc(d.branch || BRANCH) + ' and goes live to all visitors.</div>' +
|
||
'<div class="row">' +
|
||
'<button class="btn primary" data-apply>Apply live</button>' +
|
||
'<button class="btn sec" data-cancel>Cancel</button>' +
|
||
'</div></div>';
|
||
panel.querySelector('.x').onclick = close;
|
||
panel.querySelector('[data-cancel]').onclick = renderForm;
|
||
panel.querySelector('[data-apply]').onclick = function () {
|
||
var b = panel.querySelector('[data-apply]');
|
||
if (b) {
|
||
b.disabled = true;
|
||
b.innerHTML = '<span class="spin"></span>Applying…';
|
||
}
|
||
confirmEdit(d.proposed, d.baseSha);
|
||
};
|
||
}
|
||
|
||
function renderEditResult(r) {
|
||
var d = r.data || {};
|
||
if (d.ok && d.preview) {
|
||
renderPreview(d);
|
||
} else if (d.ok && d.committed) {
|
||
var secs = Math.max(1, Math.round((Date.now() - editT0) / 1000));
|
||
var commit = d.commitUrl
|
||
? ' <a class="link" href="' + esc(d.commitUrl) + '" target="_blank" rel="noopener">view commit ↗</a>'
|
||
: d.commitSha
|
||
? ' <code>' + esc(String(d.commitSha).slice(0, 7)) + '</code>'
|
||
: '';
|
||
var live = d.liveUrl
|
||
? '<div class="note">Live at <a class="link" href="' +
|
||
esc(d.liveUrl) +
|
||
'" target="_blank" rel="noopener">' +
|
||
esc(d.liveUrl.replace(/^https?:\/\//, '')) +
|
||
'</a> — refresh to see the change.</div>'
|
||
: '';
|
||
showMessage('Committed to <b>' + esc(d.branch || BRANCH) + '</b> — live in ~' + secs + 's.' + commit + live);
|
||
} else if (d.ok && d.prUrl) {
|
||
showMessage(
|
||
(d.forked ? 'Forked and opened' : 'Opened') +
|
||
' a pull request: <a class="link" href="' +
|
||
esc(d.prUrl) +
|
||
'" target="_blank" rel="noopener">' +
|
||
esc(d.prUrl.replace(/^https?:\/\//, '')) +
|
||
' ↗</a>',
|
||
);
|
||
} else if (r.status === 401 || d.openLogin) {
|
||
showMessage('<a class="link" href="' + BASE + '/login" target="_blank" rel="noopener">Log in</a> to open a PR.', true);
|
||
} else if (r.status === 402 || d.needsCredits) {
|
||
showMessage(
|
||
'You’re out of credits. <a class="link" href="' + BASE + '/billing" target="_blank" rel="noopener">Top up</a> to open a PR.',
|
||
true,
|
||
);
|
||
} else if (d.connect) {
|
||
showMessage(
|
||
'Connect ' +
|
||
esc(PROVIDER) +
|
||
' in your <a class="link" href="' +
|
||
BASE +
|
||
'/connectors" target="_blank" rel="noopener">Hanzo account</a> to open a PR.',
|
||
true,
|
||
);
|
||
} else {
|
||
showMessage(esc(d.error || 'The edit failed.'), true);
|
||
}
|
||
}
|
||
|
||
// What "this is the element you are editing" looks like on someone else's
|
||
// page. A hard 2px box in a colour from no palette read as damage; this is the
|
||
// accent at one hairline, held off the text by an offset, with a soft wash
|
||
// behind it — the same shape a focus ring takes everywhere else in the system.
|
||
//
|
||
// These land on a PAGE element, outside the shadow root, so they read the
|
||
// page's own tokens directly (the widget's --hz-* aliases live on :host and do
|
||
// not reach here) and fall back to the literals when a page ships no tokens.
|
||
function highlight(el) {
|
||
el.style.outline = '1px solid var(--brand-accent, #8b5cf6)';
|
||
el.style.outlineOffset = '2px';
|
||
el.style.boxShadow = '0 0 0 4px var(--brand-accent-soft, rgba(139,92,246,.14))';
|
||
el.style.borderRadius = el.style.borderRadius || '2px';
|
||
}
|
||
|
||
// Inline click-to-edit (admin): make the tracked element's text editable in
|
||
// place; on commit (Enter / blur) turn the before→after diff into a precise
|
||
// instruction and apply it live. Escape cancels + restores. Text-only — never
|
||
// touches structure or attributes.
|
||
var inlineActive = false;
|
||
function inlineEdit(path) {
|
||
if (inlineActive) return;
|
||
var el = lastEl && lastEl.isConnected ? lastEl : null;
|
||
// Climb to the nearest element that actually carries text (skip empty wrappers).
|
||
while (el && el !== document.body && !(el.innerText || el.textContent || '').trim()) el = el.parentElement;
|
||
if (!el || el === document.body) {
|
||
showMessage('Click the text you want to change first, then reopen and choose “Edit text directly”.', true);
|
||
return;
|
||
}
|
||
inlineActive = true;
|
||
var before = (el.innerText || el.textContent || '').trim();
|
||
var label = nodeToken(el);
|
||
var prevCE = el.getAttribute('contenteditable');
|
||
// ONE thing to remember: the element's whole inline style, restored verbatim.
|
||
// Remembering `outline` alone meant every property the highlight grew had to
|
||
// be remembered separately, and the first one that was not leaked onto the
|
||
// page for good.
|
||
var prevStyle = el.getAttribute('style');
|
||
close(); // reveal the element so the admin can type over it
|
||
el.setAttribute('contenteditable', 'true');
|
||
highlight(el);
|
||
el.focus();
|
||
// Select all the text so typing replaces it.
|
||
try {
|
||
var range = document.createRange();
|
||
range.selectNodeContents(el);
|
||
var sel = window.getSelection();
|
||
sel.removeAllRanges();
|
||
sel.addRange(range);
|
||
} catch (e) {
|
||
/* selection is a nicety */
|
||
}
|
||
|
||
function restore() {
|
||
if (prevStyle === null) el.removeAttribute('style');
|
||
else el.setAttribute('style', prevStyle);
|
||
if (prevCE === null) el.removeAttribute('contenteditable');
|
||
else el.setAttribute('contenteditable', prevCE);
|
||
el.removeEventListener('keydown', onKey, true);
|
||
el.removeEventListener('blur', onBlur, true);
|
||
inlineActive = false;
|
||
}
|
||
function commit() {
|
||
var after = (el.innerText || el.textContent || '').trim();
|
||
var changed = after && after !== before;
|
||
if (!changed) {
|
||
el.textContent = before;
|
||
restore();
|
||
return;
|
||
}
|
||
restore();
|
||
var instruction =
|
||
'Change the visible text of the ' + label + ' element from "' + before + '" to "' + after + '". Change only that text.';
|
||
fab.style.display = 'none';
|
||
panel.classList.add('open');
|
||
showMessageBusy('Applying…');
|
||
postEdit(instruction, path, 'direct');
|
||
}
|
||
function onKey(e) {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
commit();
|
||
} else if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
el.textContent = before;
|
||
restore();
|
||
}
|
||
}
|
||
function onBlur() {
|
||
commit();
|
||
}
|
||
el.addEventListener('keydown', onKey, true);
|
||
el.addEventListener('blur', onBlur, true);
|
||
}
|
||
|
||
// A minimal "working" panel used when a flow starts without a form on screen
|
||
// (inline edit). The subsequent renderEditResult replaces it.
|
||
function showMessageBusy(label) {
|
||
panel.innerHTML =
|
||
'<div class="hd"><div><b>Hanzo Edit</b></div><button class="x" aria-label="Close">×</button></div>' +
|
||
'<div class="bd"><div class="msg"><span class="spin"></span>' +
|
||
esc(label) +
|
||
'</div></div>';
|
||
panel.querySelector('.x').onclick = close;
|
||
}
|
||
|
||
function readJson(res) {
|
||
return res
|
||
.json()
|
||
.then(function (data) {
|
||
return { status: res.status, data: data };
|
||
})
|
||
.catch(function () {
|
||
return { status: res.status, data: {} };
|
||
});
|
||
}
|
||
|
||
function open() {
|
||
report('opened');
|
||
panel.classList.add('open');
|
||
fab.style.display = 'none';
|
||
CTX.chosen = '';
|
||
renderForm(); // render immediately (candidates fill in when resolved)
|
||
resolveCandidates()
|
||
.then(function (res) {
|
||
CTX.candidates = res.candidates;
|
||
CTX.version = res.version;
|
||
if (panel.classList.contains('open')) renderForm();
|
||
})
|
||
.catch(function () {
|
||
/* keep the form usable with no candidates */
|
||
});
|
||
}
|
||
function close() {
|
||
panel.classList.remove('open');
|
||
fab.style.display = '';
|
||
}
|
||
fab.onclick = open;
|
||
|
||
// Register this property as a project so it "ties back" and shows in
|
||
// hanzo.app's projects list. Fire-and-forget, idempotent (the server only
|
||
// creates when no project already links this repo), and SIGNED-IN ONLY. The
|
||
// org is derived server-side from the bearer — the browser never picks it.
|
||
var REGISTERED = false;
|
||
function registerProperty() {
|
||
if (REGISTERED) return;
|
||
REGISTERED = true;
|
||
api('/v1/register', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ repo: REPO }),
|
||
}).catch(function () {
|
||
/* registration is a convenience — never blocks editing */
|
||
});
|
||
}
|
||
|
||
// The preference transform, from @hanzo/design via hanzo.app. Cross-origin, so
|
||
// /vendor/appearance/* carries Access-Control-Allow-Origin (next.config.ts) —
|
||
// without it the import rejects and the section simply never appears, which is
|
||
// the honest degradation: the widget's other jobs are unaffected.
|
||
import(BASE + '/vendor/appearance/preference.js')
|
||
.then(function (m) {
|
||
if (m && typeof m.vars === 'function') {
|
||
VARS = m.vars;
|
||
// Apply what this device already chose, then let an open panel show it.
|
||
applyPref(readPref());
|
||
if (panel && panel.classList.contains('open')) renderForm();
|
||
}
|
||
})
|
||
.catch(function () {
|
||
/* no transform, no preferences section — every other capability stands */
|
||
});
|
||
|
||
// Probe identity to shape the CTA. It fails CLOSED to the signed-out state,
|
||
// which is now the honest one: the door refuses an unnamed suggestion, so a
|
||
// widget that could not reach /v1/me must not promise one.
|
||
api('/v1/me', { headers: { Accept: 'application/json' } })
|
||
.then(function (r) {
|
||
return r.json();
|
||
})
|
||
.then(function (d) {
|
||
if (d && typeof d === 'object') {
|
||
ME.authenticated = !!d.authenticated;
|
||
ME.isAdmin = !!d.isAdmin;
|
||
ME.hasCredits = !!d.hasCredits;
|
||
ME.balance = typeof d.balance === 'number' ? d.balance : null;
|
||
// Absent state is NOT "you have no money" — an old server that does not
|
||
// send it is our unknown, so it reads as 'unavailable', never as a zero.
|
||
ME.balanceState = typeof d.balanceState === 'string' ? d.balanceState : 'unavailable';
|
||
}
|
||
if (ME.authenticated) registerProperty();
|
||
})
|
||
.catch(function () {
|
||
/* stays signed-out: the CTA asks for a sign-in rather than a paragraph */
|
||
});
|
||
})();
|