@scenoco-three/editor 0.4.0 → 0.5.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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -17
  3. package/app/cameraRig.ts +73 -12
  4. package/app/commands.ts +5 -1
  5. package/app/desktopControls.ts +13 -10
  6. package/app/document.ts +474 -427
  7. package/app/editLog.ts +127 -0
  8. package/app/editor.css +51 -0
  9. package/app/gizmo.ts +100 -6
  10. package/app/globals.d.ts +9 -0
  11. package/app/index.html +6 -122
  12. package/app/main.ts +491 -304
  13. package/app/meta.ts +24 -0
  14. package/app/picking.ts +47 -0
  15. package/app/session.ts +513 -308
  16. package/app/touchControls.ts +85 -62
  17. package/app/ui-react/AddMemberPopup.tsx +114 -0
  18. package/app/ui-react/BottomLauncher.tsx +15 -0
  19. package/app/ui-react/EditorUI.tsx +22 -0
  20. package/app/ui-react/FilePicker.tsx +80 -0
  21. package/app/ui-react/HeadBtn.tsx +21 -0
  22. package/app/ui-react/InspectorSheet.tsx +475 -0
  23. package/app/ui-react/NodeListSheet.tsx +56 -0
  24. package/app/ui-react/TopBar.tsx +137 -0
  25. package/app/ui-react/Tree.tsx +106 -0
  26. package/app/ui-react/cx.ts +4 -0
  27. package/app/ui-react/fields.tsx +407 -0
  28. package/app/ui-react/format.ts +30 -0
  29. package/app/ui-react/mount.tsx +31 -0
  30. package/app/ui-react/store.ts +80 -0
  31. package/app/ui-react/types.ts +88 -0
  32. package/app/ui-react/ui.module.css +250 -0
  33. package/app/ui-react/useSize.ts +20 -0
  34. package/app/values.ts +137 -0
  35. package/dist/cli/index.js +93 -70
  36. package/dist/index.d.ts +10 -3
  37. package/dist/index.js +9 -5
  38. package/dist/mcp.d.ts +28 -12
  39. package/dist/mcp.js +172 -131
  40. package/dist/protocol.d.ts +74 -65
  41. package/dist/protocol.js +17 -4
  42. package/dist/server.d.ts +106 -39
  43. package/dist/server.js +389 -361
  44. package/package.json +24 -9
  45. package/app/filePicker.ts +0 -64
  46. package/app/panels.ts +0 -553
  47. package/app/toolbar.ts +0 -131
  48. package/app/wsClient.ts +0 -92
  49. package/dist/args.d.ts +0 -8
  50. package/dist/args.js +0 -28
package/app/editLog.ts ADDED
@@ -0,0 +1,127 @@
1
+ import type { Edit } from './document.js';
2
+
3
+ /** One reversible step. `redo` is replaced rather than appended while a drag coalesces. */
4
+ interface Step {
5
+ label: string;
6
+ undo: () => void;
7
+ redo: () => void;
8
+ }
9
+
10
+ /**
11
+ * The undo stack, the pending splices, and who to tell when either changes.
12
+ *
13
+ * Split out of `SceneDocument` because they are two objects: one answers *what the document
14
+ * says* — which element built this node, what may be edited, what an attribute reads as — and
15
+ * this one answers *what has changed since it was opened*. They were one class of forty-eight
16
+ * methods, and the half you were reading was never the half you wanted.
17
+ *
18
+ * Every step is a pair of closures the document supplies, so this knows nothing about XML,
19
+ * elements or the live scene: it decides only when a change is recorded, folded into the
20
+ * previous one, or reversed.
21
+ */
22
+ export class EditLog {
23
+ private readonly pending: Edit[] = [];
24
+ private readonly undoStack: Step[] = [];
25
+ private redoStack: Step[] = [];
26
+ private readonly listeners = new Set<() => void>();
27
+ private saved = true;
28
+
29
+ /** Everything not yet written to disk. */
30
+ get edits(): readonly Edit[] {
31
+ return this.pending;
32
+ }
33
+
34
+ get canUndo(): boolean {
35
+ return this.undoStack.length > 0;
36
+ }
37
+
38
+ get canRedo(): boolean {
39
+ return this.redoStack.length > 0;
40
+ }
41
+
42
+ get isDirty(): boolean {
43
+ return !this.saved;
44
+ }
45
+
46
+ /**
47
+ * Record one change.
48
+ *
49
+ * `coalesce` folds it into the previous step when it touches the same thing, so a gizmo drag
50
+ * that produced two hundred edits undoes in one.
51
+ */
52
+ commit(label: string, apply: () => void, revert: () => void, coalesce: boolean): void {
53
+ const previous = this.undoStack[this.undoStack.length - 1];
54
+ apply();
55
+ if (coalesce && previous?.label === label) {
56
+ previous.redo = apply;
57
+ } else {
58
+ this.undoStack.push({ label, undo: revert, redo: apply });
59
+ }
60
+ this.redoStack = [];
61
+ this.saved = false;
62
+ this.emit();
63
+ }
64
+
65
+ /** Record a splice with nothing to apply to the live scene — a structural edit. */
66
+ push(edit: Edit, label: string): void {
67
+ this.commit(
68
+ label,
69
+ () => this.pending.push(edit),
70
+ () => this.pending.pop(),
71
+ false,
72
+ );
73
+ }
74
+
75
+ /**
76
+ * Queue an attribute write, replacing any pending write to the same attribute.
77
+ *
78
+ * The **last** write to one attribute wins, so a scrubbed number leaves one edit rather than
79
+ * one per pixel — the file is written once, and the diff shows the value that survived.
80
+ */
81
+ replaceAttribute(edit: Extract<Edit, { op: 'setAttribute' }>): void {
82
+ const index = this.pending.findIndex(
83
+ (e) =>
84
+ e.op === 'setAttribute' &&
85
+ e.at.line === edit.at.line &&
86
+ e.at.column === edit.at.column &&
87
+ e.name === edit.name,
88
+ );
89
+ if (index !== -1) this.pending.splice(index, 1);
90
+ this.pending.push(edit);
91
+ }
92
+
93
+ undo(): void {
94
+ const step = this.undoStack.pop();
95
+ if (!step) return;
96
+ step.undo();
97
+ this.redoStack.push(step);
98
+ this.saved = false;
99
+ this.emit();
100
+ }
101
+
102
+ redo(): void {
103
+ const step = this.redoStack.pop();
104
+ if (!step) return;
105
+ step.redo();
106
+ this.undoStack.push(step);
107
+ this.saved = false;
108
+ this.emit();
109
+ }
110
+
111
+ /** The file has been written: nothing is pending and nothing is undoable past this point. */
112
+ clear(): void {
113
+ this.pending.length = 0;
114
+ this.undoStack.length = 0;
115
+ this.redoStack = [];
116
+ this.saved = true;
117
+ }
118
+
119
+ onChange(listener: () => void): () => void {
120
+ this.listeners.add(listener);
121
+ return () => this.listeners.delete(listener);
122
+ }
123
+
124
+ emit(): void {
125
+ for (const listener of this.listeners) listener();
126
+ }
127
+ }
package/app/editor.css ADDED
@@ -0,0 +1,51 @@
1
+ /*
2
+ * The page shell and the theme.
3
+ *
4
+ * `ui.module.css` styles the chrome and reads these three variables for every panel — so they
5
+ * have to be defined here, on `:root`, not inside the module: a CSS Module hashes class names but
6
+ * a custom property still resolves through the cascade, and an undefined `var(--line)` is not an
7
+ * error. It silently paints nothing, which is exactly how every panel border went missing.
8
+ */
9
+ :root {
10
+ color-scheme: dark;
11
+ /* Translucent, because a panel floats over the viewport and the scene behind it is context. */
12
+ --panel: rgba(20, 24, 32, 0.92);
13
+ --line: #2a3344;
14
+ /* A panel floats over the scene, so its outline has to hold against whatever is behind it —
15
+ brighter than `--line`, which divides rows *inside* a panel and should stay quiet. */
16
+ --edge: #3d4b64;
17
+ --accent: #4af0e0;
18
+ }
19
+
20
+ * { box-sizing: border-box; }
21
+
22
+ html,
23
+ body {
24
+ margin: 0;
25
+ height: 100%;
26
+ overflow: hidden;
27
+ background: #15181f;
28
+ color: #d6e2f0;
29
+ font: 13px/1.5 ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
30
+ }
31
+
32
+ #viewport {
33
+ position: fixed;
34
+ inset: 0;
35
+ width: 100%;
36
+ height: 100%;
37
+ display: block;
38
+ touch-action: none;
39
+ }
40
+
41
+ /*
42
+ * The chrome overlays the viewport but lets pointer events fall through to the canvas; each
43
+ * panel re-enables them on its own surface. Otherwise an invisible full-screen div would eat
44
+ * every click meant for the scene.
45
+ */
46
+ #sc-ui {
47
+ position: fixed;
48
+ inset: 0;
49
+ z-index: 5;
50
+ pointer-events: none;
51
+ }
package/app/gizmo.ts CHANGED
@@ -1,8 +1,21 @@
1
- import type { Camera, Object3D, Scene } from 'three';
1
+ import { Quaternion, Vector3, type Camera, type Object3D, type Scene } from 'three';
2
2
  import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js';
3
3
 
4
4
  export type GizmoMode = 'translate' | 'rotate' | 'scale';
5
5
 
6
+ /** Which axes the handles follow: the world's, or the object's own. */
7
+ export type GizmoSpace = 'world' | 'local';
8
+
9
+ /**
10
+ * How far the pointer must travel before a press on a handle counts as a drag.
11
+ *
12
+ * No click is perfectly still — a real mouse moves a pixel or two between down and up — and
13
+ * `TransformControls` applies every one of those pixels. Without a threshold, clicking an object
14
+ * in scale mode quietly resizes it by a fraction of a percent, which is invisible on screen and
15
+ * permanent in the document: the file gains a `scale` attribute nobody asked for.
16
+ */
17
+ const DRAG_THRESHOLD = 3;
18
+
6
19
  export interface GizmoOptions {
7
20
  /** Live refresh while dragging (e.g. update the inspector numbers). */
8
21
  onObjectChange?: () => void;
@@ -24,26 +37,84 @@ export class Gizmo {
24
37
  private readonly controls: TransformControls;
25
38
  private readonly helper: Object3D;
26
39
 
40
+ private readonly scene: Scene;
41
+
42
+ /** Where the press started, while one is in progress. */
43
+ private pressedAt: { x: number; y: number } | null = null;
44
+ /** True once a press has travelled far enough to be a drag. */
45
+ private travelled = false;
46
+ /** The attached object's transform at the moment of the press, to put back after a click. */
47
+ private readonly grabbed = {
48
+ position: new Vector3(),
49
+ quaternion: new Quaternion(),
50
+ scale: new Vector3(),
51
+ /** Kept alongside the quaternion because it is the form the document writes. */
52
+ eulerAngles: new Vector3(),
53
+ };
54
+
27
55
  constructor(
28
56
  camera: Camera,
29
57
  domElement: HTMLElement,
30
- private readonly scene: Scene,
58
+ scene: Scene,
31
59
  touch: boolean,
32
60
  opts: GizmoOptions = {},
33
61
  ) {
62
+ this.scene = scene;
34
63
  this.controls = new TransformControls(camera, domElement);
35
64
  this.controls.setSize(touch ? 1.4 : 1); // fat-finger handles on touch
36
65
  this.helper = this.controls.getHelper();
37
66
  this.helper.userData.scenocoEditorOnly = true; // keep it out of the Hierarchy + picking
38
67
  scene.add(this.helper);
39
68
 
40
- this.controls.addEventListener('objectChange', () => opts.onObjectChange?.());
69
+ domElement.addEventListener('pointerdown', (event) => {
70
+ this.pressedAt = { x: event.clientX, y: event.clientY };
71
+ this.travelled = false;
72
+ });
73
+ domElement.addEventListener('pointermove', (event) => {
74
+ if (!this.pressedAt || this.travelled) return;
75
+ this.travelled = Math.hypot(event.clientX - this.pressedAt.x, event.clientY - this.pressedAt.y) > DRAG_THRESHOLD;
76
+ });
77
+
78
+ // A press that never travelled changed nothing the user meant to change, so the object goes
79
+ // back to where it was grabbed and the document never hears about it.
80
+ this.controls.addEventListener('objectChange', () => {
81
+ if (this.travelled) opts.onObjectChange?.();
82
+ });
41
83
  this.controls.addEventListener('dragging-changed', (e) => {
42
84
  // Fired true at drag start, false at drag end.
43
- if (!e.value && this.controls.object) opts.onCommit?.(this.controls.object);
85
+ const object = this.controls.object;
86
+ if (!object) return;
87
+ if (e.value) {
88
+ this.grabbed.position.copy(object.position);
89
+ this.grabbed.quaternion.copy(object.quaternion);
90
+ this.grabbed.scale.copy(object.scale);
91
+ this.grabbed.eulerAngles.copy(object.eulerAngles);
92
+ return;
93
+ }
94
+ this.pressedAt = null;
95
+ if (this.travelled) opts.onCommit?.(object);
96
+ else {
97
+ object.position.copy(this.grabbed.position);
98
+ object.quaternion.copy(this.grabbed.quaternion);
99
+ object.scale.copy(this.grabbed.scale);
100
+ }
44
101
  });
45
102
  }
46
103
 
104
+ /**
105
+ * The attached object's value for one channel as it was when the drag began.
106
+ *
107
+ * Undo needs this and cannot work it out later. By the time the first edit is recorded the
108
+ * object has already moved — the write happens *because* it moved — so a "before" captured
109
+ * there is a frame late. For a channel the document writes, undo restores the text and the
110
+ * error never shows; for one it does not write (a rotation left at zero, a scale left at one,
111
+ * which is most of them) there is nothing to restore from, and the object is left wherever
112
+ * that first frame put it.
113
+ */
114
+ grabbedValue(channel: 'position' | 'eulerAngles' | 'scale'): Vector3 {
115
+ return this.grabbed[channel];
116
+ }
117
+
47
118
  /** True while a handle is being dragged — camera controls check this to yield. */
48
119
  get dragging(): boolean {
49
120
  return this.controls.dragging;
@@ -56,12 +127,35 @@ export class Gizmo {
56
127
  return this.controls.mode as GizmoMode;
57
128
  }
58
129
 
130
+ /**
131
+ * Whether the handles line up with the world's axes or the object's own — Unity's
132
+ * Global/Local button.
133
+ *
134
+ * It changes what a drag means, not just how it looks: dragging the green arrow moves a node
135
+ * along world Y in one space and along its own up in the other, and for anything rotated the
136
+ * two are different edits.
137
+ */
138
+ setSpace(space: GizmoSpace): void {
139
+ this.controls.setSpace(space);
140
+ }
141
+ get space(): GizmoSpace {
142
+ return this.controls.space as GizmoSpace;
143
+ }
144
+
145
+ /**
146
+ * Point the handles at an object — never at the handles themselves.
147
+ *
148
+ * Attaching the control to its own helper makes the helper its own parent's target: three walks
149
+ * `updateMatrixWorld` between the two for ever and the page dies on a stack overflow. It is one
150
+ * mis-routed pick away, and there is no legitimate reason to aim a gizmo at a gizmo, so it is
151
+ * refused here rather than trusted to every caller.
152
+ */
59
153
  attach(object: Object3D | null): void {
60
- if (object) this.controls.attach(object);
154
+ if (object && !this.contains(object)) this.controls.attach(object);
61
155
  else this.controls.detach();
62
156
  }
63
157
 
64
- /** True if `object` is part of the gizmo's own helper (so picks can ignore it). */
158
+ /** True if `object` is part of the gizmo's own helper. */
65
159
  contains(object: Object3D): boolean {
66
160
  for (let o: Object3D | null = object; o; o = o.parent) if (o === this.helper) return true;
67
161
  return false;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The websocket path, substituted into the page at serve time by the editor's Vite config.
3
+ *
4
+ * A constant rather than an import because the page and the server share `src/protocol.ts`, and
5
+ * `src/` is not published — the package ships `dist` and `app`. Everything else the page borrows
6
+ * from that module is a type, which erases; this was the only value, so it comes in through
7
+ * `define` and the import can be type-only.
8
+ */
9
+ declare const __SCENOCO_WS_PATH__: string;
package/app/index.html CHANGED
@@ -2,129 +2,13 @@
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <title>SceNoCo Editor</title>
7
- <style>
8
- html, body { margin: 0; height: 100%; background: #15181f; overflow: hidden; color: #d6e2f0; }
9
- canvas { display: block; width: 100%; height: 100%; }
10
- :root { --panel: rgba(20, 24, 32, 0.92); --line: #2a3344; --accent: #4af0e0; }
11
- /* Each side is a slide-out drawer: a panel + a thin arrow tab that stays on
12
- screen when collapsed. */
13
- .sc-drawer { position: fixed; top: 0; bottom: 0; z-index: 5; display: flex;
14
- transition: transform 0.22s ease; }
15
- .sc-left { left: 0; }
16
- .sc-right { right: 0; }
17
- .sc-left.collapsed { transform: translateX(calc(-100% + 22px)); }
18
- .sc-right.collapsed { transform: translateX(calc(100% - 22px)); }
19
- .sc-panel {
20
- width: 280px; height: 100%; background: var(--panel); overflow: auto;
21
- font: 12px/1.5 ui-monospace, monospace; backdrop-filter: blur(6px);
22
- }
23
- .sc-left .sc-panel { border-right: 1px solid var(--line); }
24
- .sc-right .sc-panel { border-left: 1px solid var(--line); }
25
- .sc-tab {
26
- flex: 0 0 22px; background: var(--panel); color: #9fe7ff; cursor: pointer;
27
- border: 0; border-left: 1px solid var(--line); border-right: 1px solid var(--line);
28
- font-size: 15px; display: flex; align-items: center; justify-content: center;
29
- }
30
- .sc-tab:hover { color: var(--accent); background: rgba(74, 240, 224, 0.1); }
31
- .sc-title { padding: 8px 12px; font-weight: 700; color: #9fe7ff; letter-spacing: 0.06em;
32
- text-transform: uppercase; font-size: 11px; border-bottom: 1px solid var(--line); position: sticky; top: 0;
33
- background: var(--panel); }
34
- .sc-node { display: flex; align-items: center; gap: 4px; min-height: 28px; padding: 2px 8px; cursor: pointer;
35
- white-space: nowrap; overflow: hidden; }
36
- .sc-node:hover { background: rgba(74, 240, 224, 0.08); }
37
- .sc-node.sel { background: rgba(74, 240, 224, 0.18); color: #eafdff; }
38
- .sc-node .ty { color: #6f93b8; }
39
- .sc-caret { flex: 0 0 14px; width: 14px; text-align: center; color: #6f93b8; cursor: pointer; user-select: none; }
40
- .sc-inspector { padding: 8px 10px; }
41
- .sc-sec { display: flex; align-items: center; gap: 4px; margin: 10px 0 4px; color: #9fe7ff; font-weight: 700;
42
- border-bottom: 1px solid var(--line); padding-bottom: 3px; cursor: pointer; user-select: none;
43
- text-transform: uppercase; letter-spacing: 0.04em; font-size: 11px; }
44
- .sc-sec.sc-sec-plain { cursor: default; }
45
- .sc-sec .sc-caret { flex: 0 0 12px; width: 12px; }
46
- .sc-sec-body { padding: 1px 0 2px; }
47
- .sc-row { display: flex; justify-content: space-between; align-items: center; gap: 10px; min-height: 30px; padding: 2px 0; }
48
- .sc-row .k { color: #8fa6c4; flex: 0 0 auto; }
49
- .sc-row .v { color: #e7f0fb; text-align: right; word-break: break-word; display: flex; align-items: center;
50
- justify-content: flex-end; gap: 4px; }
51
- .sc-empty { color: #6f8099; padding: 12px 0; }
52
- /* editable controls — 16px font avoids iOS tap-zoom; generous hit areas for touch */
53
- .sc-input { background: #0e1219; color: #e7f0fb; border: 1px solid var(--line); border-radius: 5px;
54
- padding: 4px 6px; font: 16px ui-monospace, monospace; min-height: 28px; box-sizing: border-box; }
55
- .sc-input:focus { outline: none; border-color: var(--accent); }
56
- .sc-num { width: 64px; text-align: right; }
57
- .sc-axes { display: flex; gap: 4px; flex-wrap: wrap; justify-content: flex-end; }
58
- .sc-axis { display: flex; align-items: center; gap: 2px; }
59
- .sc-axis-k { color: #6f93b8; font-size: 11px; width: 12px; text-align: center; }
60
- .sc-scrub { cursor: ew-resize; touch-action: none; }
61
- .sc-toggle { width: 22px; height: 22px; accent-color: var(--accent); }
62
- .sc-color { display: flex; align-items: center; gap: 5px; }
63
- .sc-swatch { width: 30px; height: 28px; padding: 0; border: 1px solid var(--line); border-radius: 5px;
64
- background: none; cursor: pointer; }
65
- .sc-hex { width: 78px; }
66
- .sc-select { min-width: 96px; cursor: pointer; }
67
- #hint { position: fixed; bottom: 10px; left: 50%; transform: translateX(-50%); padding: 6px 14px;
68
- border-radius: 8px; background: rgba(0,0,0,.55); color: #9fb5cf; font: 11px ui-monospace, monospace;
69
- pointer-events: none; }
70
- /* floating toolbars (touch-reachable, top center): play panel + movement panel */
71
- .sc-toolbars { position: fixed; top: 10px; left: 50%; transform: translateX(-50%); z-index: 6;
72
- display: flex; align-items: flex-start; gap: 8px; }
73
- .sc-toolbar { display: flex; align-items: center; gap: 2px; padding: 5px; border-radius: 12px;
74
- background: var(--panel); border: 1px solid var(--line); backdrop-filter: blur(6px); }
75
- .sc-tbtn { position: relative; width: 40px; height: 40px; display: flex; align-items: center;
76
- justify-content: center; padding: 0; border: 1px solid transparent; border-radius: 9px;
77
- background: transparent; color: #cfe0f2; cursor: pointer; }
78
- .sc-tbtn svg { width: 22px; height: 22px; display: block; }
79
- .sc-tbtn:hover:not(:disabled) { background: rgba(74, 240, 224, 0.12); color: #eafdff; }
80
- .sc-tbtn.active { background: rgba(74, 240, 224, 0.2); border-color: var(--accent); color: #eafdff; }
81
- .sc-tbtn:disabled { opacity: 0.3; cursor: default; }
82
- .sc-tbtn.sc-save.dirty { color: var(--accent); }
83
- .sc-tbtn.sc-save.dirty::after { content: ''; position: absolute; top: 7px; right: 7px;
84
- width: 7px; height: 7px; border-radius: 50%; background: var(--accent); }
85
- .sc-tbtn.sc-save.saving { opacity: 0.5; }
86
- .sc-tsep { width: 1px; height: 24px; background: var(--line); margin: 0 3px; }
87
- /* conflict banner: unsaved edits vs. an external file change */
88
- #sc-conflict { position: fixed; top: 62px; left: 50%; transform: translateX(-50%); z-index: 7;
89
- display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-radius: 10px;
90
- background: rgba(40, 26, 26, 0.96); border: 1px solid #6b3a3a; color: #ffd9d9;
91
- font: 13px ui-monospace, monospace; backdrop-filter: blur(6px); }
92
- #sc-conflict button { border: 1px solid #6b3a3a; border-radius: 7px; background: transparent;
93
- color: #ffd9d9; padding: 5px 10px; cursor: pointer; font: 12px ui-monospace, monospace; }
94
- #sc-conflict button:first-of-type { background: var(--accent); color: #0e1219; border-color: var(--accent); }
95
- #sc-conflict button:hover { filter: brightness(1.1); }
96
- /* component-load error banner: last-good is live, this says what's broken */
97
- #sc-error { position: fixed; top: 62px; left: 50%; transform: translateX(-50%); z-index: 7;
98
- display: flex; align-items: flex-start; gap: 10px; max-width: min(680px, 92vw);
99
- padding: 9px 13px; border-radius: 10px; background: rgba(44, 22, 14, 0.97);
100
- border: 1px solid #7a4326; color: #ffdcc2; font: 12px/1.45 ui-monospace, monospace;
101
- backdrop-filter: blur(6px); box-shadow: 0 10px 34px rgba(0, 0, 0, 0.5); }
102
- #sc-error > span { white-space: pre-wrap; overflow: auto; max-height: 34vh; }
103
- #sc-error button { flex: 0 0 auto; border: 1px solid #7a4326; border-radius: 7px; background: transparent;
104
- color: #ffdcc2; padding: 5px 10px; cursor: pointer; font: 12px ui-monospace, monospace; }
105
- #sc-error button:hover { filter: brightness(1.15); }
106
- /* start-up file picker: shown when the editor opens with no ?file= */
107
- .sc-picker { position: fixed; inset: 0; z-index: 8; display: flex; align-items: center;
108
- justify-content: center; background: rgba(10, 12, 18, 0.72); backdrop-filter: blur(4px); }
109
- .sc-picker-panel { width: min(440px, 88vw); max-height: 76vh; display: flex; flex-direction: column;
110
- background: var(--panel); border: 1px solid var(--line); border-radius: 14px; overflow: hidden;
111
- font: 13px/1.5 ui-monospace, monospace; box-shadow: 0 18px 60px rgba(0, 0, 0, 0.5); }
112
- .sc-picker-title { padding: 14px 18px; font-weight: 700; color: #9fe7ff; letter-spacing: 0.06em;
113
- text-transform: uppercase; font-size: 12px; border-bottom: 1px solid var(--line); }
114
- .sc-picker-list { overflow: auto; padding: 6px; }
115
- .sc-picker-row { width: 100%; display: flex; align-items: center; justify-content: space-between;
116
- gap: 10px; padding: 9px 12px; border: 0; border-radius: 8px; background: transparent; color: #e7f0fb;
117
- font: 13px ui-monospace, monospace; text-align: left; cursor: pointer; }
118
- .sc-picker-row:hover { background: rgba(74, 240, 224, 0.12); color: #eafdff; }
119
- .sc-picker-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
120
- .sc-picker-kind { flex: 0 0 auto; color: #6f93b8; font-size: 11px; text-transform: uppercase;
121
- letter-spacing: 0.04em; border: 1px solid var(--line); border-radius: 5px; padding: 1px 6px; }
122
- .sc-picker-empty { padding: 22px 18px; color: #8fa6c4; text-align: center; }
123
- </style>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
6
+ <title>SceNoCo editor</title>
7
+ <link rel="stylesheet" href="./editor.css" />
124
8
  </head>
125
9
  <body>
126
- <canvas id="app"></canvas>
127
- <div id="hint">tap to select · right-drag look · WASD move · Q/E down/up · G/R/Y gizmo move/rotate/scale</div>
128
- <script type="module" src="/main.ts"></script>
10
+ <canvas id="viewport"></canvas>
11
+ <div id="sc-ui"></div>
12
+ <script type="module" src="./main.ts"></script>
129
13
  </body>
130
14
  </html>