@pajh/buldng 0.0.2 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +14 -10
- package/src/buldng-dev-errors.js +84 -0
- package/src/buldng-lib.js +465 -0
- package/src/buldng-ops.js +258 -0
- package/src/buldng-validate.js +207 -0
- package/src/buldng-web.ts +267 -0
- package/src/buldng.css +148 -0
- package/src/clean.sh +14 -0
- package/src/layout.ts +184 -0
- package/src/types/global.d.ts +10 -0
- package/src/workfile.js +69 -0
- package/src/wrapper.html +24 -0
- package/dist/buldng-lib.js +0 -194
- package/dist/buldng-validate.js +0 -102
- package/dist/workfile.js +0 -162
- /package/{dist → src}/init.js +0 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// buldng-web.ts
|
|
2
|
+
// Pure runtime API for buldng. The optional renderer only writes text into a caller-owned element.
|
|
3
|
+
|
|
4
|
+
let errorPage: string | null = null;
|
|
5
|
+
let listenersInstalled = false;
|
|
6
|
+
let redirectStarted = false;
|
|
7
|
+
|
|
8
|
+
declare global {
|
|
9
|
+
interface Window {
|
|
10
|
+
__buldngErrorOverlay?: boolean;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const ERROR_STORAGE_PREFIX = "buldng:error:";
|
|
15
|
+
|
|
16
|
+
export type ErrorPayload = {
|
|
17
|
+
id: string;
|
|
18
|
+
type: "error" | "unhandledrejection";
|
|
19
|
+
message: string;
|
|
20
|
+
stack: string;
|
|
21
|
+
time: string;
|
|
22
|
+
url: string;
|
|
23
|
+
userAgent: string;
|
|
24
|
+
platform: string;
|
|
25
|
+
language: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Configure the global error handler.
|
|
30
|
+
* @param url A full URL path like "/error/" or "/fatal/".
|
|
31
|
+
*/
|
|
32
|
+
export function setErrorPage(url: string) {
|
|
33
|
+
if (typeof url !== "string" || !url.length) {
|
|
34
|
+
throw new Error("setErrorPage: URL must be a non-empty string");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Normalize: ensure no accidental double slashes
|
|
38
|
+
errorPage = url.endsWith("/") ? url.slice(0, -1) : url;
|
|
39
|
+
|
|
40
|
+
// Install global listeners once. Calling setErrorPage again only changes the destination.
|
|
41
|
+
installGlobalErrorHandler();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Internal: installs global error + unhandled rejection handlers.
|
|
46
|
+
*/
|
|
47
|
+
function installGlobalErrorHandler() {
|
|
48
|
+
if (!errorPage || listenersInstalled || isErrorPage() || window.__buldngErrorOverlay) return;
|
|
49
|
+
|
|
50
|
+
listenersInstalled = true;
|
|
51
|
+
|
|
52
|
+
window.addEventListener("error", (event) => {
|
|
53
|
+
handleError({
|
|
54
|
+
type: "error",
|
|
55
|
+
reason: event.error ?? event.message,
|
|
56
|
+
fallbackMessage: event.message,
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
window.addEventListener("unhandledrejection", (event) => {
|
|
61
|
+
handleError({
|
|
62
|
+
type: "unhandledrejection",
|
|
63
|
+
reason: event.reason,
|
|
64
|
+
fallbackMessage: "Unhandled promise rejection",
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Read an error payload previously saved by the global handler.
|
|
71
|
+
* Defaults to the errorId query parameter on the current page.
|
|
72
|
+
*/
|
|
73
|
+
export function readErrorPayload(id = getErrorId()): ErrorPayload | null {
|
|
74
|
+
if (!id) return null;
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const raw = localStorage.getItem(storageKey(id));
|
|
78
|
+
if (!raw) return null;
|
|
79
|
+
|
|
80
|
+
const payload: unknown = JSON.parse(raw);
|
|
81
|
+
return isErrorPayload(payload) ? payload : null;
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Read and remove an error payload. Useful for one-shot error pages.
|
|
89
|
+
*/
|
|
90
|
+
export function consumeErrorPayload(id = getErrorId()): ErrorPayload | null {
|
|
91
|
+
const payload = readErrorPayload(id);
|
|
92
|
+
if (id) {
|
|
93
|
+
try {
|
|
94
|
+
localStorage.removeItem(storageKey(id));
|
|
95
|
+
} catch {
|
|
96
|
+
// Storage may be unavailable or blocked; the payload was still safely read if possible.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return payload;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Render a payload as safe text inside a caller-owned element.
|
|
104
|
+
* Returns the payload that was rendered, or null when it was unavailable.
|
|
105
|
+
*/
|
|
106
|
+
export function renderErrorPayload(
|
|
107
|
+
elementId: string,
|
|
108
|
+
payload = readErrorPayload()
|
|
109
|
+
): ErrorPayload | null {
|
|
110
|
+
const container = document.getElementById(elementId);
|
|
111
|
+
if (!container) return null;
|
|
112
|
+
|
|
113
|
+
container.replaceChildren();
|
|
114
|
+
if (!payload) {
|
|
115
|
+
const empty = document.createElement("p");
|
|
116
|
+
empty.textContent = "Error details are no longer available.";
|
|
117
|
+
container.appendChild(empty);
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const details: Array<[string, string]> = [
|
|
122
|
+
["Type", payload.type],
|
|
123
|
+
["Message", payload.message],
|
|
124
|
+
["Time", payload.time],
|
|
125
|
+
["Page", payload.url],
|
|
126
|
+
["User agent", payload.userAgent],
|
|
127
|
+
["Platform", payload.platform],
|
|
128
|
+
["Language", payload.language],
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
const list = document.createElement("dl");
|
|
132
|
+
for (const [label, value] of details) {
|
|
133
|
+
const term = document.createElement("dt");
|
|
134
|
+
term.textContent = label;
|
|
135
|
+
const description = document.createElement("dd");
|
|
136
|
+
description.textContent = value;
|
|
137
|
+
list.append(term, description);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (payload.stack) {
|
|
141
|
+
const stackLabel = document.createElement("h3");
|
|
142
|
+
stackLabel.textContent = "Stack";
|
|
143
|
+
const stack = document.createElement("pre");
|
|
144
|
+
stack.textContent = payload.stack;
|
|
145
|
+
list.append(stackLabel, stack);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
container.appendChild(list);
|
|
149
|
+
return payload;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Return the error record ID from the current URL.
|
|
154
|
+
*/
|
|
155
|
+
export function getErrorId(): string | null {
|
|
156
|
+
return new URLSearchParams(location.search).get("errorId");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function handleError(info: {
|
|
160
|
+
type: ErrorPayload["type"];
|
|
161
|
+
reason: unknown;
|
|
162
|
+
fallbackMessage: string;
|
|
163
|
+
}) {
|
|
164
|
+
if (redirectStarted || !errorPage) return;
|
|
165
|
+
redirectStarted = true;
|
|
166
|
+
|
|
167
|
+
const payload = buildPayload(info);
|
|
168
|
+
const stored = saveErrorPayload(payload);
|
|
169
|
+
redirect(payload, stored);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function buildPayload(info: {
|
|
173
|
+
type: ErrorPayload["type"];
|
|
174
|
+
reason: unknown;
|
|
175
|
+
fallbackMessage: string;
|
|
176
|
+
}): ErrorPayload {
|
|
177
|
+
const id = createId();
|
|
178
|
+
const error = info.reason instanceof Error ? info.reason : null;
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
id,
|
|
182
|
+
type: info.type,
|
|
183
|
+
message: getErrorMessage(info.reason, info.fallbackMessage),
|
|
184
|
+
stack: error?.stack ?? getErrorStack(info.reason),
|
|
185
|
+
time: new Date().toISOString(),
|
|
186
|
+
url: location.href,
|
|
187
|
+
userAgent: navigator.userAgent,
|
|
188
|
+
platform: navigator.platform,
|
|
189
|
+
language: navigator.language,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Redirect to the configured error page with a storage key, plus a small fallback.
|
|
195
|
+
*/
|
|
196
|
+
function redirect(payload: ErrorPayload, stored: boolean) {
|
|
197
|
+
if (!errorPage) return;
|
|
198
|
+
|
|
199
|
+
const destination = new URL(errorPage, location.href);
|
|
200
|
+
if (stored) {
|
|
201
|
+
destination.searchParams.set("errorId", payload.id);
|
|
202
|
+
} else {
|
|
203
|
+
destination.searchParams.set("type", payload.type);
|
|
204
|
+
destination.searchParams.set("message", payload.message.slice(0, 500));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
window.location.href = destination.href;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function saveErrorPayload(payload: ErrorPayload): boolean {
|
|
211
|
+
try {
|
|
212
|
+
localStorage.setItem(storageKey(payload.id), JSON.stringify(payload));
|
|
213
|
+
return true;
|
|
214
|
+
} catch {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function storageKey(id: string) {
|
|
220
|
+
return `${ERROR_STORAGE_PREFIX}${id}`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function createId() {
|
|
224
|
+
if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
|
|
225
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function isErrorPage() {
|
|
229
|
+
const destination = new URL(errorPage!, location.href);
|
|
230
|
+
const destinationPath = destination.pathname.replace(/\/$/, "") || "/";
|
|
231
|
+
const currentPath = location.pathname.replace(/\/$/, "") || "/";
|
|
232
|
+
return destination.origin === location.origin && destinationPath === currentPath;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function getErrorMessage(reason: unknown, fallback: string) {
|
|
236
|
+
if (reason instanceof Error && reason.message) return reason.message;
|
|
237
|
+
if (typeof reason === "string" && reason) return reason;
|
|
238
|
+
if (reason !== null && reason !== undefined) {
|
|
239
|
+
try {
|
|
240
|
+
const text = JSON.stringify(reason);
|
|
241
|
+
if (text) return text;
|
|
242
|
+
} catch {
|
|
243
|
+
// Fall through to the stable message below.
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return fallback;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function getErrorStack(reason: unknown) {
|
|
250
|
+
if (!reason || typeof reason !== "object") return "";
|
|
251
|
+
const stack = (reason as { stack?: unknown }).stack;
|
|
252
|
+
return typeof stack === "string" ? stack : "";
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function isErrorPayload(value: unknown): value is ErrorPayload {
|
|
256
|
+
if (!value || typeof value !== "object") return false;
|
|
257
|
+
const payload = value as Partial<ErrorPayload>;
|
|
258
|
+
return typeof payload.id === "string"
|
|
259
|
+
&& (payload.type === "error" || payload.type === "unhandledrejection")
|
|
260
|
+
&& typeof payload.message === "string"
|
|
261
|
+
&& typeof payload.stack === "string"
|
|
262
|
+
&& typeof payload.time === "string"
|
|
263
|
+
&& typeof payload.url === "string"
|
|
264
|
+
&& typeof payload.userAgent === "string"
|
|
265
|
+
&& typeof payload.platform === "string"
|
|
266
|
+
&& typeof payload.language === "string";
|
|
267
|
+
}
|
package/src/buldng.css
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/* --------------------------------------------------
|
|
2
|
+
LOCAL FONTS
|
|
3
|
+
-------------------------------------------------- */
|
|
4
|
+
@font-face {
|
|
5
|
+
font-family: Inter;
|
|
6
|
+
src: url("/assets/InterVariable.woff2") format("woff2");
|
|
7
|
+
font-weight: 100 900;
|
|
8
|
+
font-display: swap;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
@font-face {
|
|
12
|
+
font-family: "JetBrains Mono";
|
|
13
|
+
src: url("/assets/JetBrainsMono-Regular.woff2") format("woff2");
|
|
14
|
+
font-weight: 400;
|
|
15
|
+
font-display: swap;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/* --------------------------------------------------
|
|
19
|
+
CAMERA GEOMETRY — JS-SIZED, CSS-LAID-OUT
|
|
20
|
+
Matches layout.ts:
|
|
21
|
+
CAM-PORTRAIT
|
|
22
|
+
CAM-LANDSCAPE-A
|
|
23
|
+
CAM-LANDSCAPE-B
|
|
24
|
+
-------------------------------------------------- */
|
|
25
|
+
|
|
26
|
+
#b-camera {
|
|
27
|
+
position: absolute;
|
|
28
|
+
inset: 0;
|
|
29
|
+
margin: auto;
|
|
30
|
+
container-type: size;
|
|
31
|
+
overflow: hidden;
|
|
32
|
+
background: var(--back);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* --------------------------------------------------
|
|
36
|
+
CAMERA INTERNAL AREAS
|
|
37
|
+
-------------------------------------------------- */
|
|
38
|
+
|
|
39
|
+
#b-camera-style {
|
|
40
|
+
display: grid;
|
|
41
|
+
height: 100%;
|
|
42
|
+
width: 100%;
|
|
43
|
+
grid-template-areas:
|
|
44
|
+
"header"
|
|
45
|
+
"client"
|
|
46
|
+
"footer";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
#b-top-bar {
|
|
50
|
+
grid-area: header;
|
|
51
|
+
overflow: hidden;
|
|
52
|
+
container-type: size;
|
|
53
|
+
width: 100%;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
#b-client-area {
|
|
57
|
+
grid-area: client;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
#b-bottom-bar {
|
|
61
|
+
grid-area: footer;
|
|
62
|
+
overflow: hidden;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* --------------------------------------------------
|
|
66
|
+
PORTRAIT MODE
|
|
67
|
+
JS sets width/height; CSS sets layout only
|
|
68
|
+
-------------------------------------------------- */
|
|
69
|
+
|
|
70
|
+
.CAM-PORTRAIT#b-camera-style {
|
|
71
|
+
grid-template-rows: 20% auto 5%
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* --------------------------------------------------
|
|
75
|
+
LANDSCAPE-A MODE
|
|
76
|
+
JS sets width/height; CSS sets layout only
|
|
77
|
+
-------------------------------------------------- */
|
|
78
|
+
|
|
79
|
+
.CAM-LANDSCAPE-A#b-camera-style {
|
|
80
|
+
grid-template-rows: 15% 80% 5%;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/* --------------------------------------------------
|
|
84
|
+
LANDSCAPE-B MODE
|
|
85
|
+
JS sets width/height; CSS sets layout only
|
|
86
|
+
-------------------------------------------------- */
|
|
87
|
+
|
|
88
|
+
.CAM-LANDSCAPE-B#b-camera-style {
|
|
89
|
+
grid-template-rows: 15% 80% 5%;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* --------------------------------------------------
|
|
93
|
+
GLOBAL THEME VARIABLES
|
|
94
|
+
-------------------------------------------------- */
|
|
95
|
+
:root[data-theme="light"] {
|
|
96
|
+
--back: #f7f7f7;
|
|
97
|
+
--panel: #ffffff;
|
|
98
|
+
--button: #f0f0f0;
|
|
99
|
+
--text: #222222;
|
|
100
|
+
--line: #9c9c9c;
|
|
101
|
+
--highlight1: #4aa3ff;
|
|
102
|
+
--highlight2: #ffd84a;
|
|
103
|
+
--logo-bg: #e0e0e0;
|
|
104
|
+
--logo-fg: #222;
|
|
105
|
+
--logo-shadow: 0 2px 4px rgb(0 0 0 0.15);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
:root[data-theme="dark"] {
|
|
109
|
+
--back: #111111;
|
|
110
|
+
--panel: #1b1b1b;
|
|
111
|
+
--button: #4a4a4a;
|
|
112
|
+
--text: #eeeeee;
|
|
113
|
+
--line: #848484;
|
|
114
|
+
--highlight1: #4aa3ff;
|
|
115
|
+
--highlight2: #ffd84a;
|
|
116
|
+
--logo-bg: #333;
|
|
117
|
+
--logo-fg: #eee;
|
|
118
|
+
--logo-shadow: 0 2px 4px rgb(0 0 0 0.4);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/* --------------------------------------------------
|
|
122
|
+
GLOBAL BODY
|
|
123
|
+
-------------------------------------------------- */
|
|
124
|
+
html {
|
|
125
|
+
margin: 0;
|
|
126
|
+
padding: 0;
|
|
127
|
+
height:100%;
|
|
128
|
+
width:100%;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
body {
|
|
132
|
+
background: var(--back);
|
|
133
|
+
margin: 0;
|
|
134
|
+
padding: 0;
|
|
135
|
+
height: 100%;
|
|
136
|
+
width: 100%;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
html, body, button, input, select, textarea {
|
|
140
|
+
font-family: Inter, sans-serif;
|
|
141
|
+
font-size: var(--std-font-size);
|
|
142
|
+
color: var(--text);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
button {
|
|
146
|
+
background: var(--button);
|
|
147
|
+
}
|
|
148
|
+
|
package/src/clean.sh
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
# Sentinel must exist in the current directory
|
|
5
|
+
if [ ! -f BULDNG_DEST ]; then
|
|
6
|
+
echo "clean.sh: refusing — sentinel missing"
|
|
7
|
+
exit 1
|
|
8
|
+
fi
|
|
9
|
+
|
|
10
|
+
# Delete everything in the current directory, including subdirectories
|
|
11
|
+
rm -rf ./*
|
|
12
|
+
|
|
13
|
+
# Delete the script itself
|
|
14
|
+
rm -f "$0"
|
package/src/layout.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/* --------------------------------------------------
|
|
2
|
+
GLOBAL VIEWPORT STATE
|
|
3
|
+
-------------------------------------------------- */
|
|
4
|
+
function applyCameraSize(mode: string) {
|
|
5
|
+
const cam = document.getElementById("b-camera");
|
|
6
|
+
if (!cam) return;
|
|
7
|
+
|
|
8
|
+
const vw = window.innerWidth;
|
|
9
|
+
const vh = window.innerHeight;
|
|
10
|
+
|
|
11
|
+
let width, height;
|
|
12
|
+
|
|
13
|
+
if (mode === "PORTRAIT") {
|
|
14
|
+
height = vh;
|
|
15
|
+
width = vw;
|
|
16
|
+
} else if (mode === "LANDSCAPE_A") {
|
|
17
|
+
height = vh;
|
|
18
|
+
width = vh * 4/3;
|
|
19
|
+
} else {
|
|
20
|
+
// LANDSCAPE_B
|
|
21
|
+
width = vw;
|
|
22
|
+
height = vw * 3/4;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
cam.style.width = `${width}px`;
|
|
26
|
+
cam.style.height = `${height}px`;
|
|
27
|
+
|
|
28
|
+
// Center WITHOUT transform (no containing-block issues)
|
|
29
|
+
cam.style.position = "absolute";
|
|
30
|
+
cam.style.top = "0";
|
|
31
|
+
cam.style.bottom = "0";
|
|
32
|
+
cam.style.left = "0";
|
|
33
|
+
cam.style.right = "0";
|
|
34
|
+
cam.style.margin = "auto";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const buldrViewport = {
|
|
38
|
+
width: window.innerWidth,
|
|
39
|
+
height: window.innerHeight,
|
|
40
|
+
aspect: window.innerWidth / window.innerHeight, // w/h
|
|
41
|
+
mode: "PORTRAIT", // PORTRAIT | LANDSCAPE_A | LANDSCAPE_B
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/* --------------------------------------------------
|
|
45
|
+
MODE COMPUTATION
|
|
46
|
+
-------------------------------------------------- */
|
|
47
|
+
|
|
48
|
+
function computeMode() {
|
|
49
|
+
const a = buldrViewport.aspect;
|
|
50
|
+
|
|
51
|
+
if (a >= 4/3) return "LANDSCAPE_A";
|
|
52
|
+
if (a <= 3/4) return "PORTRAIT";
|
|
53
|
+
return "LANDSCAPE_B";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* --------------------------------------------------
|
|
57
|
+
APPLY CAMERA MODE CLASS
|
|
58
|
+
-------------------------------------------------- */
|
|
59
|
+
|
|
60
|
+
function applyCameraMode(mode: string) {
|
|
61
|
+
const cam = document.getElementById("b-camera");
|
|
62
|
+
if (!cam) return;
|
|
63
|
+
const style = document.getElementById("b-camera-style");
|
|
64
|
+
if (!style) return;
|
|
65
|
+
|
|
66
|
+
// Remove all camera classes
|
|
67
|
+
cam.classList.remove("CAM-PORTRAIT", "CAM-LANDSCAPE-A", "CAM-LANDSCAPE-B");
|
|
68
|
+
style.classList.remove("CAM-PORTRAIT", "CAM-LANDSCAPE-A", "CAM-LANDSCAPE-B");
|
|
69
|
+
// Add the new one
|
|
70
|
+
if (mode === "PORTRAIT") {
|
|
71
|
+
cam.classList.add("CAM-PORTRAIT");
|
|
72
|
+
style.classList.add("CAM-PORTRAIT");
|
|
73
|
+
} else if (mode === "LANDSCAPE_A") {
|
|
74
|
+
cam.classList.add("CAM-LANDSCAPE-A");
|
|
75
|
+
style.classList.add("CAM-LANDSCAPE-A");
|
|
76
|
+
} else {
|
|
77
|
+
cam.classList.add("CAM-LANDSCAPE-B");
|
|
78
|
+
style.classList.add("CAM-LANDSCAPE-B");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* --------------------------------------------------
|
|
83
|
+
APPLY GLOBAL CSS MODE (PORTRAIT / LANDSCAPE)
|
|
84
|
+
-------------------------------------------------- */
|
|
85
|
+
function applyCssMode(mode: string) {
|
|
86
|
+
const html = document.documentElement;
|
|
87
|
+
|
|
88
|
+
if (mode === "PORTRAIT") {
|
|
89
|
+
html.classList.remove("LANDSCAPE");
|
|
90
|
+
html.classList.add("PORTRAIT");
|
|
91
|
+
} else {
|
|
92
|
+
html.classList.remove("PORTRAIT");
|
|
93
|
+
html.classList.add("LANDSCAPE");
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/* --------------------------------------------------
|
|
97
|
+
FONT SIZE COMPUTATION (NEW)
|
|
98
|
+
-------------------------------------------------- */
|
|
99
|
+
|
|
100
|
+
const FACTOR_PORTRAIT = 0.035;
|
|
101
|
+
const FACTOR_LANDSCAPE_B = 0.035;
|
|
102
|
+
const FACTOR_LANDSCAPE_A = 0.020;
|
|
103
|
+
|
|
104
|
+
function applyFontSize(mode: string) {
|
|
105
|
+
const cam = document.getElementById("b-camera");
|
|
106
|
+
if (!cam) return;
|
|
107
|
+
|
|
108
|
+
const camWidth = cam.getBoundingClientRect().width;
|
|
109
|
+
|
|
110
|
+
let factor = FACTOR_PORTRAIT;
|
|
111
|
+
if (mode === "LANDSCAPE_A") factor = FACTOR_LANDSCAPE_A;
|
|
112
|
+
else if (mode === "LANDSCAPE_B") factor = FACTOR_LANDSCAPE_B;
|
|
113
|
+
|
|
114
|
+
const size = camWidth * factor;
|
|
115
|
+
|
|
116
|
+
document.documentElement.style.setProperty("--std-font-size", `${size}px`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/* --------------------------------------------------
|
|
120
|
+
UPDATE + DEBUG
|
|
121
|
+
-------------------------------------------------- */
|
|
122
|
+
|
|
123
|
+
function updateViewportInfo() {
|
|
124
|
+
buldrViewport.width = window.innerWidth;
|
|
125
|
+
buldrViewport.height = window.innerHeight;
|
|
126
|
+
buldrViewport.aspect = window.innerWidth / window.innerHeight;
|
|
127
|
+
|
|
128
|
+
const mode = computeMode();
|
|
129
|
+
buldrViewport.mode = mode;
|
|
130
|
+
|
|
131
|
+
console.log(
|
|
132
|
+
`[BULDR] viewport ${buldrViewport.width}x${buldrViewport.height}, `
|
|
133
|
+
+ `aspect=${buldrViewport.aspect.toFixed(3)}, `
|
|
134
|
+
+ `mode=${mode}`
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
applyCameraMode(mode);
|
|
138
|
+
applyCssMode(mode);
|
|
139
|
+
applyCameraSize(mode);
|
|
140
|
+
applyFontSize(mode);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* --------------------------------------------------
|
|
144
|
+
THEME
|
|
145
|
+
-------------------------------------------------- */
|
|
146
|
+
|
|
147
|
+
function initTheme() {
|
|
148
|
+
const btn = document.getElementById("theme-toggle");
|
|
149
|
+
if (!btn) return;
|
|
150
|
+
|
|
151
|
+
const current = localStorage.getItem("theme") || "light";
|
|
152
|
+
document.documentElement.dataset.theme = current;
|
|
153
|
+
|
|
154
|
+
btn.addEventListener("click", () => {
|
|
155
|
+
const next = document.documentElement.dataset.theme === "light" ? "dark" : "light";
|
|
156
|
+
document.documentElement.dataset.theme = next;
|
|
157
|
+
localStorage.setItem("theme", next);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/* --------------------------------------------------
|
|
162
|
+
VIEWPORT LISTENER
|
|
163
|
+
-------------------------------------------------- */
|
|
164
|
+
|
|
165
|
+
function initViewportListener() {
|
|
166
|
+
updateViewportInfo(); // initial
|
|
167
|
+
|
|
168
|
+
window.addEventListener("resize", () => {
|
|
169
|
+
updateViewportInfo();
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/* --------------------------------------------------
|
|
174
|
+
LAYOUT INIT
|
|
175
|
+
-------------------------------------------------- */
|
|
176
|
+
|
|
177
|
+
function initLayout() {
|
|
178
|
+
initTheme();
|
|
179
|
+
initViewportListener();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Run layout immediately
|
|
183
|
+
initLayout();
|
|
184
|
+
window.__ready._resolveA();
|
package/src/workfile.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// --------------------------------------------------
|
|
2
|
+
// WorkFile — immutable, lazy, tree-structured build node
|
|
3
|
+
// --------------------------------------------------
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import esbuild from "esbuild";
|
|
8
|
+
import { assertDestWrite } from "./buldng-lib.js";
|
|
9
|
+
import { OPS } from "./buldng-ops.js";
|
|
10
|
+
|
|
11
|
+
export class WorkFile {
|
|
12
|
+
constructor(op, config = {}, children = [], tag = null) {
|
|
13
|
+
if (!OPS[op]) {
|
|
14
|
+
throw new Error(`Invalid WorkFile op "${op}" — not one of: ${Object.keys(OPS).join(", ")}`);
|
|
15
|
+
}
|
|
16
|
+
this.op = op; // "file", "literal", "append", "replace", "compile"
|
|
17
|
+
this.config = Object.freeze(config);
|
|
18
|
+
this.children = Object.freeze(children);
|
|
19
|
+
this.tag = tag; // optional tag
|
|
20
|
+
Object.freeze(this); // full immutability
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// --------------------------------------------------
|
|
24
|
+
// execute(): recursively produce output string
|
|
25
|
+
// --------------------------------------------------
|
|
26
|
+
execute() {
|
|
27
|
+
return OPS[this.op](this.config, this.children);
|
|
28
|
+
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// --------------------------------------------------
|
|
32
|
+
// save(): write output to disk
|
|
33
|
+
// --------------------------------------------------
|
|
34
|
+
save(target) {
|
|
35
|
+
if (!target) {
|
|
36
|
+
throw new Error("WorkFile.save(): target filename required");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const abs = assertDestWrite(target);
|
|
40
|
+
const out = this.execute();
|
|
41
|
+
|
|
42
|
+
// If file exists, enforce integrity rule
|
|
43
|
+
if (fs.existsSync(abs)) {
|
|
44
|
+
const existing = fs.readFileSync(abs, "utf8");
|
|
45
|
+
|
|
46
|
+
if (existing === out) {
|
|
47
|
+
// Byte-identical → warn but allow
|
|
48
|
+
console.warn(
|
|
49
|
+
`WorkFile.save(): attempted to overwrite '${target}' with identical content — continuing`
|
|
50
|
+
);
|
|
51
|
+
return "/" + target;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Different → fatal error
|
|
55
|
+
throw new Error(
|
|
56
|
+
`WorkFile.save(): cannot overwrite '${target}' — existing file differs from generated output`
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// File does not exist → write it
|
|
61
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
62
|
+
fs.writeFileSync(abs, out, "utf8");
|
|
63
|
+
// NEW: mark file read-only
|
|
64
|
+
fs.chmodSync(abs, 0o444);
|
|
65
|
+
|
|
66
|
+
console.log(`save: ${abs}`);
|
|
67
|
+
return "/" + target;
|
|
68
|
+
}
|
|
69
|
+
}
|