feat: Phase 1 — extract the client (web SPA + desktop) to dtoro/oikos-web
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

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:
2026-08-15 22:27:52 +02:00
parent e074f04bdf
commit d4d99a7473
18854 changed files with 2615729 additions and 173735 deletions

View File

@@ -0,0 +1,7 @@
import type { MaybeGetter } from "svelte-toolbelt";
export declare function get<T>(valueOrGetValue: MaybeGetter<T>): T;
export declare function getDPR(element: Element): number;
export declare function roundByDPR(element: Element, value: number): number;
export declare function getFloatingContentCSSVars(name: string): {
[x: string]: string;
};

View File

@@ -0,0 +1,24 @@
export function get(valueOrGetValue) {
return typeof valueOrGetValue === "function"
? valueOrGetValue()
: valueOrGetValue;
}
export function getDPR(element) {
if (typeof window === "undefined")
return 1;
const win = element.ownerDocument.defaultView || window;
return win.devicePixelRatio || 1;
}
export function roundByDPR(element, value) {
const dpr = getDPR(element);
return Math.round(value * dpr) / dpr;
}
export function getFloatingContentCSSVars(name) {
return {
[`--bits-${name}-content-transform-origin`]: `var(--bits-floating-transform-origin)`,
[`--bits-${name}-content-available-width`]: `var(--bits-floating-available-width)`,
[`--bits-${name}-content-available-height`]: `var(--bits-floating-available-height)`,
[`--bits-${name}-anchor-width`]: `var(--bits-floating-anchor-width)`,
[`--bits-${name}-anchor-height`]: `var(--bits-floating-anchor-height)`,
};
}

View File

@@ -0,0 +1,97 @@
import type { FloatingElement, Middleware, MiddlewareData, Placement, ReferenceElement, Strategy } from "@floating-ui/dom";
import type { ReadableBox, WritableBox } from "svelte-toolbelt";
type ValueOrGetValue<T> = T | (() => T);
export type Measurable = {
getBoundingClientRect: () => DOMRect;
};
export type UseFloatingOptions = {
/**
* Represents the open/close state of the floating element.
* @default true
*/
open?: ValueOrGetValue<boolean | undefined>;
/**
* Where to place the floating element relative to its reference element.
* @default 'bottom'
*/
placement?: ValueOrGetValue<Placement | undefined>;
/**
* The type of CSS position property to use.
* @default 'absolute'
*/
strategy?: ValueOrGetValue<Strategy | undefined>;
/**
* These are plain objects that modify the positioning coordinates in some fashion,
* or provide useful data for the consumer to use.
* @default undefined
*/
middleware?: ValueOrGetValue<Middleware[] | undefined>;
/**
* Whether to use `transform` instead of `top` and `left` styles to
* position the floating element (`floatingStyles`).
* @default true
*/
transform?: ValueOrGetValue<boolean | undefined>;
/**
* Reference / Anchor element to position the floating element relative to
*/
reference: ReadableBox<Measurable | HTMLElement | null>;
/**
* Callback to handle mounting/unmounting of the elements.
* @default undefined
*/
whileElementsMounted?: (reference: ReferenceElement, floating: FloatingElement, update: () => void) => () => void;
/**
* The offset from the reference element along the side axis.
* Used to detect bad coordinates during transitions.
* @default undefined
*/
sideOffset?: ValueOrGetValue<number | undefined>;
/**
* The offset from the reference element along the alignment axis.
* Used to detect bad coordinates during transitions.
* @default undefined
*/
alignOffset?: ValueOrGetValue<number | undefined>;
};
export type UseFloatingReturn = {
/**
* The reference element to position the floating element relative to.
*/
reference: ReadableBox<Measurable | HTMLElement | null>;
/**
* The floating element to position.
*/
floating: WritableBox<HTMLElement | null>;
/**
* The stateful placement, which can be different from the initial `placement` passed as options.
*/
placement: Readonly<Placement>;
/**
* The type of CSS position property to use.
*/
strategy: Readonly<Strategy>;
/**
* Additional data from middleware.
*/
middlewareData: Readonly<MiddlewareData>;
/**
* The boolean that let you know if the floating element has been positioned.
*/
isPositioned: Readonly<boolean>;
/**
* CSS styles to apply to the floating element to position it.
*/
floatingStyles: Readonly<{
position: Strategy;
top: string;
left: string;
transform?: string;
willChange?: string;
}>;
/**
* The function to update floating position manually.
*/
update: () => void;
};
export {};

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1,2 @@
import type { UseFloatingOptions, UseFloatingReturn } from "./types.js";
export declare function useFloating(options: UseFloatingOptions): UseFloatingReturn;

View File

@@ -0,0 +1,192 @@
import { computePosition } from "@floating-ui/dom";
import { simpleBox } from "svelte-toolbelt";
import { get, getDPR, roundByDPR } from "./floating-utils.svelte.js";
export function useFloating(options) {
/** Options */
const whileElementsMountedOption = options.whileElementsMounted;
const openOption = $derived(get(options.open) ?? true);
const middlewareOption = $derived(get(options.middleware));
const transformOption = $derived(get(options.transform) ?? true);
const placementOption = $derived(get(options.placement) ?? "bottom");
const strategyOption = $derived(get(options.strategy) ?? "absolute");
const sideOffsetOption = $derived(get(options.sideOffset) ?? 0);
const alignOffsetOption = $derived(get(options.alignOffset) ?? 0);
const reference = options.reference;
/** State */
let x = $state(0);
let y = $state(0);
const floating = simpleBox(null);
// svelte-ignore state_referenced_locally
let strategy = $state(strategyOption);
// svelte-ignore state_referenced_locally
let placement = $state(placementOption);
let middlewareData = $state({});
let isPositioned = $state(false);
let hasWhileMountedPosition = false;
let updateRequestId = 0;
const floatingStyles = $derived.by(() => {
// preserve last known position when floating ref is null (during transitions)
const xVal = floating.current ? roundByDPR(floating.current, x) : x;
const yVal = floating.current ? roundByDPR(floating.current, y) : y;
if (transformOption) {
return {
position: strategy,
left: "0",
top: "0",
transform: `translate(${xVal}px, ${yVal}px)`,
...(floating.current &&
getDPR(floating.current) >= 1.5 && {
willChange: "transform",
}),
};
}
return {
position: strategy,
left: `${xVal}px`,
top: `${yVal}px`,
};
});
/** Effects */
let whileElementsMountedCleanup;
function update() {
if (reference.current === null || floating.current === null)
return;
const referenceNode = reference.current;
const floatingNode = floating.current;
const requestId = ++updateRequestId;
computePosition(referenceNode, floatingNode, {
middleware: middlewareOption,
placement: placementOption,
strategy: strategyOption,
}).then((position) => {
// ignore stale async resolutions when newer updates were requested.
if (requestId !== updateRequestId)
return;
// ignore stale resolutions after ref replacement.
if (reference.current !== referenceNode || floating.current !== floatingNode)
return;
const referenceHidden = isReferenceHidden(referenceNode);
if (referenceHidden) {
// keep last good coordinates when the anchor disappears to avoid
// a transient jump to viewport origin before close propagates.
middlewareData = {
...middlewareData,
hide: {
// oxlint-disable-next-line no-explicit-any
...middlewareData.hide,
referenceHidden: true,
},
};
return;
}
// ignore bad coordinates that cause jumping during close transitions
if (!openOption && x !== 0 && y !== 0) {
// if we had a good position and now getting coordinates near
// the expected offset bounds during close, ignore it
const maxExpectedOffset = Math.max(Math.abs(sideOffsetOption), Math.abs(alignOffsetOption), 15);
if (position.x <= maxExpectedOffset && position.y <= maxExpectedOffset)
return;
}
x = position.x;
y = position.y;
strategy = position.strategy;
placement = position.placement;
middlewareData = position.middlewareData;
isPositioned = true;
});
}
function cleanup() {
if (typeof whileElementsMountedCleanup === "function") {
whileElementsMountedCleanup();
whileElementsMountedCleanup = undefined;
}
updateRequestId++;
}
function attach() {
cleanup();
if (whileElementsMountedOption === undefined) {
update();
return;
}
if (!openOption)
return;
if (reference.current === null || floating.current === null)
return;
whileElementsMountedCleanup = whileElementsMountedOption(reference.current, floating.current, update);
}
function reset() {
if (!openOption && floating.current === null) {
isPositioned = false;
}
}
function trackWhileMountedDeps() {
return [
middlewareOption,
placementOption,
strategyOption,
sideOffsetOption,
alignOffsetOption,
openOption,
];
}
$effect(() => {
if (whileElementsMountedOption !== undefined)
return;
if (!openOption)
return;
update();
});
$effect(attach);
$effect(() => {
if (whileElementsMountedOption === undefined)
return;
trackWhileMountedDeps();
if (!openOption) {
hasWhileMountedPosition = false;
return;
}
if (!isPositioned) {
hasWhileMountedPosition = false;
return;
}
// skip the first post-position run, since autoUpdate already computed it
if (!hasWhileMountedPosition) {
hasWhileMountedPosition = true;
return;
}
update();
});
$effect(reset);
$effect(() => cleanup);
return {
floating,
reference,
get strategy() {
return strategy;
},
get placement() {
return placement;
},
get middlewareData() {
return middlewareData;
},
get isPositioned() {
return isPositioned;
},
get floatingStyles() {
return floatingStyles;
},
get update() {
return update;
},
};
}
function isReferenceHidden(node) {
if (!(node instanceof Element))
return false;
if (!node.isConnected)
return true;
if (node instanceof HTMLElement && node.hidden)
return true;
return node.getClientRects().length === 0;
}