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,2 @@
export { default as TextSelectionLayer } from "./text-selection-layer.svelte";
export type { TextSelectionLayerProps } from "./types.js";

View File

@@ -0,0 +1 @@
export { default as TextSelectionLayer } from "./text-selection-layer.svelte";

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import { boxWith } from "svelte-toolbelt";
import type { TextSelectionLayerImplProps } from "./types.js";
import { TextSelectionLayerState } from "./use-text-selection-layer.svelte.js";
import { noop } from "../../../internal/noop.js";
let {
preventOverflowTextSelection = true,
onPointerDown = noop,
onPointerUp = noop,
id,
children,
enabled,
ref,
}: TextSelectionLayerImplProps = $props();
TextSelectionLayerState.create({
id: boxWith(() => id),
onPointerDown: boxWith(() => onPointerDown),
onPointerUp: boxWith(() => onPointerUp),
enabled: boxWith(() => enabled && preventOverflowTextSelection),
ref,
});
</script>
{@render children?.()}

View File

@@ -0,0 +1,4 @@
import type { TextSelectionLayerImplProps } from "./types.js";
declare const TextSelectionLayer: import("svelte").Component<TextSelectionLayerImplProps, {}, "">;
type TextSelectionLayer = ReturnType<typeof TextSelectionLayer>;
export default TextSelectionLayer;

View File

@@ -0,0 +1,33 @@
import type { Snippet } from "svelte";
import type { ReadableBox } from "svelte-toolbelt";
export type PointerHandler = (e: PointerEvent) => void;
export type TextSelectionLayerProps = {
/**
* Passing `true` will prevent the overflow of text selection
* outside the element, provided the element is the top layer.
* @defaultValue `true`
*/
preventOverflowTextSelection?: boolean;
};
export type TextSelectionLayerImplProps = {
/**
* Whether the layer is enabled. Currently, we determine this with the
* `presence` returned from the `presence` layer.
*/
enabled: boolean;
/**
* DOM ID of the node.
*/
id: string;
children?: Snippet;
/**
* Callback fired when pointerdown event triggers. Call `preventDefault()`
* to disable the prevention of text-selection overflow.
*/
onPointerDown?: PointerHandler;
/**
* Callback fired when pointerup event triggers.
*/
onPointerUp?: PointerHandler;
ref: ReadableBox<HTMLElement | null>;
} & TextSelectionLayerProps;

View File

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

View File

@@ -0,0 +1,14 @@
import { DOMContext, type ReadableBoxedValues } from "svelte-toolbelt";
import type { TextSelectionLayerImplProps } from "./types.js";
interface TextSelectionLayerStateOpts extends ReadableBoxedValues<Required<Omit<TextSelectionLayerImplProps, "children" | "preventOverflowTextSelection" | "ref">> & {
ref: HTMLElement | null;
}> {
}
export declare class TextSelectionLayerState {
#private;
static create(opts: TextSelectionLayerStateOpts): TextSelectionLayerState;
readonly opts: TextSelectionLayerStateOpts;
readonly domContext: DOMContext;
constructor(opts: TextSelectionLayerStateOpts);
}
export {};

View File

@@ -0,0 +1,94 @@
import { DOMContext, composeHandlers, contains, executeCallbacks, } from "svelte-toolbelt";
import { watch } from "runed";
import { on } from "svelte/events";
import { noop } from "../../../internal/noop.js";
import { isHTMLElement } from "../../../internal/is.js";
const noopPointer = () => { };
globalThis.bitsTextSelectionLayers ??= new Map();
export class TextSelectionLayerState {
static create(opts) {
return new TextSelectionLayerState(opts);
}
opts;
domContext;
#unsubSelectionLock = noop;
#enabledSnapshot = false;
#onPointerDownSnapshot = noopPointer;
#onPointerUpSnapshot = noopPointer;
constructor(opts) {
this.opts = opts;
this.domContext = new DOMContext(opts.ref);
let unsubEvents = noop;
watch(() => [
this.opts.enabled.current,
this.opts.onPointerDown.current,
this.opts.onPointerUp.current,
], ([enabled, onPointerDown, onPointerUp]) => {
this.#enabledSnapshot = enabled;
this.#onPointerDownSnapshot = onPointerDown;
this.#onPointerUpSnapshot = onPointerUp;
if (enabled) {
globalThis.bitsTextSelectionLayers.set(this, this.opts.enabled);
unsubEvents();
unsubEvents = this.#addEventListeners();
}
return () => {
this.#enabledSnapshot = false;
unsubEvents();
this.#resetSelectionLock();
globalThis.bitsTextSelectionLayers.delete(this);
};
});
}
#addEventListeners() {
return executeCallbacks(on(this.domContext.getDocument(), "pointerdown", this.#pointerdown), on(this.domContext.getDocument(), "pointerup", composeHandlers(this.#resetSelectionLock, this.#pointerupUserHandler)));
}
#pointerupUserHandler = (e) => {
this.#onPointerUpSnapshot(e);
};
#pointerdown = (e) => {
const node = this.opts.ref.current;
const target = e.target;
if (!isHTMLElement(node) || !isHTMLElement(target) || !this.#enabledSnapshot)
return;
/**
* We only lock user-selection overflow if layer is the top most layer and
* pointerdown occurred inside the node. You are still allowed to select text
* outside the node provided pointerdown occurs outside the node.
*/
if (!isHighestLayer(this) || !contains(node, target))
return;
this.#onPointerDownSnapshot(e);
if (e.defaultPrevented)
return;
this.#unsubSelectionLock = preventTextSelectionOverflow(node, this.domContext.getDocument().body);
};
#resetSelectionLock = () => {
this.#unsubSelectionLock();
this.#unsubSelectionLock = noop;
};
}
const getUserSelect = (node) => node.style.userSelect || node.style.webkitUserSelect;
function preventTextSelectionOverflow(node, body) {
const originalBodyUserSelect = getUserSelect(body);
const originalNodeUserSelect = getUserSelect(node);
setUserSelect(body, "none");
setUserSelect(node, "text");
return () => {
setUserSelect(body, originalBodyUserSelect);
setUserSelect(node, originalNodeUserSelect);
};
}
function setUserSelect(node, value) {
node.style.userSelect = value;
node.style.webkitUserSelect = value;
}
function isHighestLayer(instance) {
const layersArr = [...globalThis.bitsTextSelectionLayers];
if (!layersArr.length)
return false;
const highestLayer = layersArr.at(-1);
if (!highestLayer)
return false;
return highestLayer[0] === instance;
}