feat: Phase 1 — extract the client (web SPA + desktop) to dtoro/oikos-web
Problem: the hexagonal refactor churns the backend tree for nine more phases; the UI delivery stack (web/ SPA, cmd/desktop Wails wrapper, compose/web image) must move to its own repo first so doc/layout rewrites land once on a backend-only tree. Change: - New repo git.hubris.network/dtoro/oikos-web (v0.33.0): web/, desktop/ (updateURL repointed to oikos-web releases), compose/, own CI (web + desktop jobs), own deploy script (CI-green gate, TOCTOU guard, version-tagged images, prune-to-3), own webhook receiver on :9798 + launchd unit, own compose project publishing the same 8091:80. - Cutover executed on mac-mini in order: oikos stack's web service stopped+removed, oikos-web project brought up on 8091; outer Caddy untouched (targets the published port) — serving + Authentik flow + /wails 404 quirk verified post-cutover. - Stripped from oikos: web/, cmd/desktop/, compose/web/, desktop CI workflow, ci.yml web job, Makefile ui/desktop/desktop-package/install targets, the compose web service, oikos-web from deploy.sh's fallback prune list; wails + go-keyring dropped from go.mod, vendor synced. - README / CONTRIBUTING / AGENTS.md / .agents dev+operations docs now point at the new repo; mbse + mascot design docs carry a path note. Risk: production SPA serving depends on the new pipeline now; rollback is versioned-image re-up of the old web service from a pre-split checkout (port 8091). Desktop builds installed before the split still check dtoro/oikos releases — one manual reinstall, noted in the oikos-web release notes. Verification: go vet, make test (race), make generate-check, golangci (no new findings; baseline down 400→365); post-cutover curls — localhost:8091 200, /wails/runtime.js 404, outer Caddy 302 Authentik.
This commit is contained in:
315
web/node_modules/bits-ui/dist/internal/safe-polygon.svelte.js
generated
vendored
Normal file
315
web/node_modules/bits-ui/dist/internal/safe-polygon.svelte.js
generated
vendored
Normal file
@@ -0,0 +1,315 @@
|
||||
import { getDocument } from "svelte-toolbelt";
|
||||
import { on } from "svelte/events";
|
||||
import { watch } from "runed";
|
||||
import { isElement } from "./is.js";
|
||||
function isPointInPolygon(point, polygon) {
|
||||
const [x, y] = point;
|
||||
let isInside = false;
|
||||
const length = polygon.length;
|
||||
for (let i = 0, j = length - 1; i < length; j = i++) {
|
||||
const [xi, yi] = polygon[i] ?? [0, 0];
|
||||
const [xj, yj] = polygon[j] ?? [0, 0];
|
||||
const intersect = yi >= y !== yj >= y && x <= ((xj - xi) * (y - yi)) / (yj - yi) + xi;
|
||||
if (intersect) {
|
||||
isInside = !isInside;
|
||||
}
|
||||
}
|
||||
return isInside;
|
||||
}
|
||||
function isInsideRect(point, rect) {
|
||||
return (point[0] >= rect.left &&
|
||||
point[0] <= rect.right &&
|
||||
point[1] >= rect.top &&
|
||||
point[1] <= rect.bottom);
|
||||
}
|
||||
function getSide(triggerRect, contentRect) {
|
||||
// determine which side the content is on relative to trigger
|
||||
const triggerCenterX = triggerRect.left + triggerRect.width / 2;
|
||||
const triggerCenterY = triggerRect.top + triggerRect.height / 2;
|
||||
const contentCenterX = contentRect.left + contentRect.width / 2;
|
||||
const contentCenterY = contentRect.top + contentRect.height / 2;
|
||||
const deltaX = contentCenterX - triggerCenterX;
|
||||
const deltaY = contentCenterY - triggerCenterY;
|
||||
if (Math.abs(deltaX) > Math.abs(deltaY)) {
|
||||
return deltaX > 0 ? "right" : "left";
|
||||
}
|
||||
return deltaY > 0 ? "bottom" : "top";
|
||||
}
|
||||
/**
|
||||
* Creates a safe polygon area that allows users to move their cursor between
|
||||
* the trigger and floating content without closing it.
|
||||
*/
|
||||
export class SafePolygon {
|
||||
#opts;
|
||||
#buffer;
|
||||
#transitIntentTimeout;
|
||||
// tracks the cursor position when leaving trigger or content
|
||||
#exitPoint = null;
|
||||
// tracks what we're moving toward: "content" when leaving trigger, "trigger" when leaving content
|
||||
#exitTarget = null;
|
||||
#transitTargets = [];
|
||||
#trackedTriggerNode = null;
|
||||
#leaveFallbackRafId = null;
|
||||
#transitIntentTimeoutId = null;
|
||||
#cancelLeaveFallback() {
|
||||
if (this.#leaveFallbackRafId !== null) {
|
||||
cancelAnimationFrame(this.#leaveFallbackRafId);
|
||||
this.#leaveFallbackRafId = null;
|
||||
}
|
||||
}
|
||||
#scheduleLeaveFallback() {
|
||||
this.#cancelLeaveFallback();
|
||||
this.#leaveFallbackRafId = requestAnimationFrame(() => {
|
||||
this.#leaveFallbackRafId = null;
|
||||
if (!this.#exitPoint || !this.#exitTarget)
|
||||
return;
|
||||
this.#clearTracking();
|
||||
this.#opts.onPointerExit();
|
||||
});
|
||||
}
|
||||
#cancelTransitIntentTimeout() {
|
||||
if (this.#transitIntentTimeoutId !== null) {
|
||||
clearTimeout(this.#transitIntentTimeoutId);
|
||||
this.#transitIntentTimeoutId = null;
|
||||
}
|
||||
}
|
||||
#scheduleTransitIntentTimeout() {
|
||||
if (this.#transitIntentTimeout === null)
|
||||
return;
|
||||
this.#cancelTransitIntentTimeout();
|
||||
this.#transitIntentTimeoutId = window.setTimeout(() => {
|
||||
this.#transitIntentTimeoutId = null;
|
||||
if (!this.#exitPoint || !this.#exitTarget)
|
||||
return;
|
||||
this.#clearTracking();
|
||||
this.#opts.onPointerExit();
|
||||
}, this.#transitIntentTimeout);
|
||||
}
|
||||
constructor(opts) {
|
||||
this.#opts = opts;
|
||||
this.#buffer = opts.buffer ?? 1;
|
||||
const transitIntentTimeout = opts.transitIntentTimeout;
|
||||
this.#transitIntentTimeout =
|
||||
typeof transitIntentTimeout === "number" && transitIntentTimeout > 0
|
||||
? transitIntentTimeout
|
||||
: null;
|
||||
watch([opts.triggerNode, opts.contentNode, opts.enabled], ([triggerNode, contentNode, enabled]) => {
|
||||
if (!triggerNode || !contentNode || !enabled) {
|
||||
this.#trackedTriggerNode = null;
|
||||
this.#clearTracking();
|
||||
return;
|
||||
}
|
||||
if (this.#trackedTriggerNode && this.#trackedTriggerNode !== triggerNode) {
|
||||
this.#clearTracking();
|
||||
}
|
||||
this.#trackedTriggerNode = triggerNode;
|
||||
const doc = getDocument(triggerNode);
|
||||
const handlePointerMove = (e) => {
|
||||
this.#onPointerMove([e.clientX, e.clientY], triggerNode, contentNode);
|
||||
};
|
||||
const handleTriggerLeave = (e) => {
|
||||
// when leaving trigger toward content, record exit point
|
||||
const target = e.relatedTarget;
|
||||
// if going directly to content, no need for polygon tracking
|
||||
if (isElement(target) && contentNode.contains(target)) {
|
||||
return;
|
||||
}
|
||||
// if moving to an ignored target (e.g. a sibling trigger), don't close —
|
||||
// the sibling's enter handler will take over
|
||||
const ignoredTargets = this.#opts.ignoredTargets?.() ?? [];
|
||||
if (isElement(target) &&
|
||||
ignoredTargets.some((n) => n === target || n.contains(target))) {
|
||||
return;
|
||||
}
|
||||
this.#transitTargets =
|
||||
isElement(target) && ignoredTargets.length > 0
|
||||
? ignoredTargets.filter((n) => target.contains(n))
|
||||
: [];
|
||||
// for unrelated elements, defer close decisions to pointer geometry checks.
|
||||
// this allows the cursor to pass through intermediate elements on the way
|
||||
// to content without immediately closing.
|
||||
this.#exitPoint = [e.clientX, e.clientY];
|
||||
this.#exitTarget = "content";
|
||||
this.#scheduleLeaveFallback();
|
||||
};
|
||||
const handleTriggerEnter = () => {
|
||||
// reached trigger, clear tracking
|
||||
this.#clearTracking();
|
||||
};
|
||||
const handleContentEnter = () => {
|
||||
// reached content, clear tracking
|
||||
this.#clearTracking();
|
||||
};
|
||||
const handleContentLeave = (e) => {
|
||||
// when leaving content, check if going directly back to trigger
|
||||
const target = e.relatedTarget;
|
||||
if (isElement(target) && triggerNode.contains(target)) {
|
||||
// going directly to trigger, no polygon tracking needed
|
||||
return;
|
||||
}
|
||||
// set up polygon tracking toward trigger — pointermove decides whether to close
|
||||
this.#exitPoint = [e.clientX, e.clientY];
|
||||
this.#exitTarget = "trigger";
|
||||
this.#scheduleLeaveFallback();
|
||||
};
|
||||
return [
|
||||
on(doc, "pointermove", handlePointerMove),
|
||||
on(triggerNode, "pointerleave", handleTriggerLeave),
|
||||
on(triggerNode, "pointerenter", handleTriggerEnter),
|
||||
on(contentNode, "pointerenter", handleContentEnter),
|
||||
on(contentNode, "pointerleave", handleContentLeave),
|
||||
].reduce((acc, cleanup) => () => {
|
||||
acc();
|
||||
cleanup();
|
||||
}, () => { });
|
||||
});
|
||||
}
|
||||
#onPointerMove(clientPoint, triggerNode, contentNode) {
|
||||
// if no exit point recorded, nothing to check
|
||||
if (!this.#exitPoint || !this.#exitTarget)
|
||||
return;
|
||||
this.#cancelLeaveFallback();
|
||||
this.#scheduleTransitIntentTimeout();
|
||||
const triggerRect = triggerNode.getBoundingClientRect();
|
||||
const contentRect = contentNode.getBoundingClientRect();
|
||||
// check if pointer reached the target
|
||||
if (this.#exitTarget === "content" && isInsideRect(clientPoint, contentRect)) {
|
||||
this.#clearTracking();
|
||||
return;
|
||||
}
|
||||
if (this.#exitTarget === "trigger" && isInsideRect(clientPoint, triggerRect)) {
|
||||
this.#clearTracking();
|
||||
return;
|
||||
}
|
||||
if (this.#exitTarget === "content" && this.#transitTargets.length > 0) {
|
||||
for (const transitTarget of this.#transitTargets) {
|
||||
const transitRect = transitTarget.getBoundingClientRect();
|
||||
if (isInsideRect(clientPoint, transitRect))
|
||||
return;
|
||||
const transitSide = getSide(triggerRect, transitRect);
|
||||
const transitCorridor = this.#getCorridorPolygon(triggerRect, transitRect, transitSide);
|
||||
if (transitCorridor && isPointInPolygon(clientPoint, transitCorridor))
|
||||
return;
|
||||
}
|
||||
}
|
||||
// check if pointer is in the rectangular corridor between trigger and content
|
||||
const side = getSide(triggerRect, contentRect);
|
||||
const corridorPoly = this.#getCorridorPolygon(triggerRect, contentRect, side);
|
||||
if (corridorPoly && isPointInPolygon(clientPoint, corridorPoly)) {
|
||||
return;
|
||||
}
|
||||
// check if pointer is within the safe polygon from exit point to target
|
||||
const targetRect = this.#exitTarget === "content" ? contentRect : triggerRect;
|
||||
const safePoly = this.#getSafePolygon(this.#exitPoint, targetRect, side, this.#exitTarget);
|
||||
if (isPointInPolygon(clientPoint, safePoly)) {
|
||||
return;
|
||||
}
|
||||
// pointer is outside all safe zones - close
|
||||
this.#clearTracking();
|
||||
this.#opts.onPointerExit();
|
||||
}
|
||||
#clearTracking() {
|
||||
this.#exitPoint = null;
|
||||
this.#exitTarget = null;
|
||||
this.#transitTargets = [];
|
||||
this.#cancelLeaveFallback();
|
||||
this.#cancelTransitIntentTimeout();
|
||||
}
|
||||
/**
|
||||
* Creates a rectangular corridor between trigger and content
|
||||
* This prevents closing when cursor is in the gap between them
|
||||
*/
|
||||
#getCorridorPolygon(triggerRect, contentRect, side) {
|
||||
const buffer = this.#buffer;
|
||||
switch (side) {
|
||||
case "top":
|
||||
return [
|
||||
[Math.min(triggerRect.left, contentRect.left) - buffer, triggerRect.top],
|
||||
[Math.min(triggerRect.left, contentRect.left) - buffer, contentRect.bottom],
|
||||
[Math.max(triggerRect.right, contentRect.right) + buffer, contentRect.bottom],
|
||||
[Math.max(triggerRect.right, contentRect.right) + buffer, triggerRect.top],
|
||||
];
|
||||
case "bottom":
|
||||
return [
|
||||
[Math.min(triggerRect.left, contentRect.left) - buffer, triggerRect.bottom],
|
||||
[Math.min(triggerRect.left, contentRect.left) - buffer, contentRect.top],
|
||||
[Math.max(triggerRect.right, contentRect.right) + buffer, contentRect.top],
|
||||
[Math.max(triggerRect.right, contentRect.right) + buffer, triggerRect.bottom],
|
||||
];
|
||||
case "left":
|
||||
return [
|
||||
[triggerRect.left, Math.min(triggerRect.top, contentRect.top) - buffer],
|
||||
[contentRect.right, Math.min(triggerRect.top, contentRect.top) - buffer],
|
||||
[contentRect.right, Math.max(triggerRect.bottom, contentRect.bottom) + buffer],
|
||||
[triggerRect.left, Math.max(triggerRect.bottom, contentRect.bottom) + buffer],
|
||||
];
|
||||
case "right":
|
||||
return [
|
||||
[triggerRect.right, Math.min(triggerRect.top, contentRect.top) - buffer],
|
||||
[contentRect.left, Math.min(triggerRect.top, contentRect.top) - buffer],
|
||||
[contentRect.left, Math.max(triggerRect.bottom, contentRect.bottom) + buffer],
|
||||
[triggerRect.right, Math.max(triggerRect.bottom, contentRect.bottom) + buffer],
|
||||
];
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Creates a triangular/trapezoidal safe zone from the exit point to the target
|
||||
*/
|
||||
#getSafePolygon(exitPoint, targetRect, side, exitTarget) {
|
||||
const buffer = this.#buffer * 4;
|
||||
const [x, y] = exitPoint;
|
||||
// when going back to trigger, we need to flip the side
|
||||
const effectiveSide = exitTarget === "trigger" ? this.#flipSide(side) : side;
|
||||
// create polygon points from cursor to target edges
|
||||
switch (effectiveSide) {
|
||||
case "top":
|
||||
return [
|
||||
[x - buffer, y + buffer],
|
||||
[x + buffer, y + buffer],
|
||||
[targetRect.right + buffer, targetRect.bottom],
|
||||
[targetRect.right + buffer, targetRect.top],
|
||||
[targetRect.left - buffer, targetRect.top],
|
||||
[targetRect.left - buffer, targetRect.bottom],
|
||||
];
|
||||
case "bottom":
|
||||
return [
|
||||
[x - buffer, y - buffer],
|
||||
[x + buffer, y - buffer],
|
||||
[targetRect.right + buffer, targetRect.top],
|
||||
[targetRect.right + buffer, targetRect.bottom],
|
||||
[targetRect.left - buffer, targetRect.bottom],
|
||||
[targetRect.left - buffer, targetRect.top],
|
||||
];
|
||||
case "left":
|
||||
return [
|
||||
[x + buffer, y - buffer],
|
||||
[x + buffer, y + buffer],
|
||||
[targetRect.right, targetRect.bottom + buffer],
|
||||
[targetRect.left, targetRect.bottom + buffer],
|
||||
[targetRect.left, targetRect.top - buffer],
|
||||
[targetRect.right, targetRect.top - buffer],
|
||||
];
|
||||
case "right":
|
||||
return [
|
||||
[x - buffer, y - buffer],
|
||||
[x - buffer, y + buffer],
|
||||
[targetRect.left, targetRect.bottom + buffer],
|
||||
[targetRect.right, targetRect.bottom + buffer],
|
||||
[targetRect.right, targetRect.top - buffer],
|
||||
[targetRect.left, targetRect.top - buffer],
|
||||
];
|
||||
}
|
||||
}
|
||||
#flipSide(side) {
|
||||
switch (side) {
|
||||
case "top":
|
||||
return "bottom";
|
||||
case "bottom":
|
||||
return "top";
|
||||
case "left":
|
||||
return "right";
|
||||
case "right":
|
||||
return "left";
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user