+14
eslint.config.js
+14
eslint.config.js
···
3
3
import { defineConfig } from "eslint/config";
4
4
import reactHooks from "eslint-plugin-react-hooks";
5
5
import tseslint from "typescript-eslint";
6
+
import { noMixedClassPrefixes } from "./eslint/no-mixed-class-prefixes.js";
6
7
7
8
export default defineConfig([
8
9
{
···
13
14
},
14
15
reactHooks.configs.flat.recommended,
15
16
tseslint.configs.base,
17
+
{
18
+
files: ["**/*.tsx", "**/*.jsx"],
19
+
plugins: {
20
+
local: {
21
+
rules: {
22
+
"no-mixed-class-prefixes": noMixedClassPrefixes,
23
+
},
24
+
},
25
+
},
26
+
rules: {
27
+
"local/no-mixed-class-prefixes": "error",
28
+
},
29
+
},
16
30
]);
+125
eslint/no-mixed-class-prefixes.js
+125
eslint/no-mixed-class-prefixes.js
···
1
+
// @ts-check
2
+
3
+
/**
4
+
* ESLint rule to enforce CSS class naming conventions:
5
+
* 1. CSS imports must match the component file name
6
+
* 2. All classNames in the file must use the file's prefix (poor man's CSS modules)
7
+
*/
8
+
9
+
/** @type {import('eslint').Rule.RuleModule} */
10
+
export const noMixedClassPrefixes = {
11
+
meta: {
12
+
type: "problem",
13
+
docs: {
14
+
description: "Enforce CSS class naming matches component file name",
15
+
},
16
+
schema: [],
17
+
messages: {
18
+
mismatchedClass: "Class '{{className}}' must start with '{{expectedPrefix}}' (the file name)",
19
+
mismatchedCssImport:
20
+
"CSS file '{{cssFile}}' does not match component file '{{componentFile}}'",
21
+
},
22
+
},
23
+
24
+
create(context) {
25
+
const filename = context.filename || context.getFilename();
26
+
const basename = filename.split("/").pop() || "";
27
+
const filePrefix = basename.replace(/\.(tsx?|jsx?)$/, "");
28
+
29
+
// Only apply to PascalCase component files
30
+
if (!/^[A-Z]/.test(filePrefix)) {
31
+
return {};
32
+
}
33
+
34
+
/**
35
+
* Check if className is valid for this file
36
+
* Valid: FilePrefix, FilePrefix-foo, FilePrefix--bar, FilePrefix-foo--bar
37
+
* @param {string} className
38
+
* @returns {boolean}
39
+
*/
40
+
function isValidClassName(className) {
41
+
if (className === filePrefix) return true;
42
+
if (className.startsWith(filePrefix + "-")) return true;
43
+
return false;
44
+
}
45
+
46
+
/**
47
+
* Check classes in a string
48
+
* @param {import('estree').Node} node
49
+
* @param {string} value
50
+
*/
51
+
function checkClassString(node, value) {
52
+
const classes = value.split(/\s+/).filter(Boolean);
53
+
54
+
for (const className of classes) {
55
+
// Only check PascalCase classes (component classes)
56
+
if (!/^[A-Z]/.test(className)) continue;
57
+
58
+
if (!isValidClassName(className)) {
59
+
context.report({
60
+
node,
61
+
messageId: "mismatchedClass",
62
+
data: {
63
+
className,
64
+
expectedPrefix: filePrefix,
65
+
},
66
+
});
67
+
}
68
+
}
69
+
}
70
+
71
+
return {
72
+
// Check CSS imports match file name
73
+
ImportDeclaration(node) {
74
+
const source = node.source.value;
75
+
if (typeof source !== "string") return;
76
+
if (!source.endsWith(".css")) return;
77
+
78
+
const cssBasename = source.split("/").pop() || "";
79
+
const cssPrefix = cssBasename.replace(/\.css$/, "");
80
+
81
+
if (cssPrefix !== filePrefix) {
82
+
context.report({
83
+
node,
84
+
messageId: "mismatchedCssImport",
85
+
data: {
86
+
cssFile: cssBasename,
87
+
componentFile: basename,
88
+
},
89
+
});
90
+
}
91
+
},
92
+
93
+
// Check className attributes
94
+
JSXAttribute(node) {
95
+
if (node.name.name !== "className") return;
96
+
97
+
const value = node.value;
98
+
99
+
// className="foo bar"
100
+
if (value && value.type === "Literal" && typeof value.value === "string") {
101
+
checkClassString(value, value.value);
102
+
}
103
+
104
+
// className={...}
105
+
if (value && value.type === "JSXExpressionContainer") {
106
+
const expr = value.expression;
107
+
108
+
// className={"foo bar"}
109
+
if (expr.type === "Literal" && typeof expr.value === "string") {
110
+
checkClassString(expr, expr.value);
111
+
}
112
+
113
+
// className={`foo ${bar}`}
114
+
if (expr.type === "TemplateLiteral") {
115
+
for (const quasi of expr.quasis) {
116
+
if (quasi.value.raw) {
117
+
checkClassString(quasi, quasi.value.raw);
118
+
}
119
+
}
120
+
}
121
+
}
122
+
},
123
+
};
124
+
},
125
+
};
+44
-44
src/client/ui/App.css
+44
-44
src/client/ui/App.css
···
47
47
color: var(--text-bright);
48
48
}
49
49
50
-
/* ExampleSelect */
50
+
/* App-exampleSelect */
51
51
52
-
.ExampleSelect {
52
+
.App-exampleSelect {
53
53
display: flex;
54
54
align-items: center;
55
55
gap: 10px;
···
57
57
border-left: 1px solid var(--border);
58
58
}
59
59
60
-
.ExampleSelect-label {
60
+
.App-exampleSelect-label {
61
61
font-size: 11px;
62
62
color: var(--text-dim);
63
63
text-transform: uppercase;
64
64
letter-spacing: 0.5px;
65
65
}
66
66
67
-
.ExampleSelect-selectWrapper {
67
+
.App-exampleSelect-selectWrapper {
68
68
min-width: 150px;
69
69
}
70
70
71
-
.ExampleSelect-saveBtn,
72
-
.ExampleSelect-embedBtn {
71
+
.App-exampleSelect-saveBtn,
72
+
.App-exampleSelect-embedBtn {
73
73
background: var(--surface);
74
74
border: 1px solid var(--border);
75
75
color: var(--text);
···
81
81
justify-content: center;
82
82
}
83
83
84
-
.ExampleSelect-saveBtn:hover:not(:disabled),
85
-
.ExampleSelect-embedBtn:hover:not(:disabled) {
84
+
.App-exampleSelect-saveBtn:hover:not(:disabled),
85
+
.App-exampleSelect-embedBtn:hover:not(:disabled) {
86
86
border-color: #444;
87
87
background: #2a2a2a;
88
88
}
89
89
90
-
.ExampleSelect-saveBtn:disabled,
91
-
.ExampleSelect-embedBtn:disabled {
90
+
.App-exampleSelect-saveBtn:disabled,
91
+
.App-exampleSelect-embedBtn:disabled {
92
92
opacity: 0.4;
93
93
cursor: not-allowed;
94
94
}
95
95
96
-
/* BuildSwitcher */
96
+
/* App-buildSwitcher */
97
97
98
-
.BuildSwitcher {
98
+
.App-buildSwitcher {
99
99
display: flex;
100
100
align-items: center;
101
101
gap: 8px;
102
102
}
103
103
104
-
.BuildSwitcher-label {
104
+
.App-buildSwitcher-label {
105
105
font-size: 11px;
106
106
color: var(--text-dim);
107
107
text-transform: uppercase;
108
108
letter-spacing: 0.5px;
109
109
}
110
110
111
-
.BuildSwitcher-version {
111
+
.App-buildSwitcher-version {
112
112
min-width: 150px;
113
113
}
114
114
115
-
.BuildSwitcher-mode {
115
+
.App-buildSwitcher-mode {
116
116
min-width: 70px;
117
117
}
118
118
119
-
/* EmbedModal */
119
+
/* App-embedModal */
120
120
121
-
.EmbedModal-overlay {
121
+
.App-embedModal-overlay {
122
122
position: fixed;
123
123
inset: 0;
124
124
background: rgba(0, 0, 0, 0.7);
···
128
128
z-index: 1000;
129
129
}
130
130
131
-
.EmbedModal {
131
+
.App-embedModal {
132
132
background: var(--surface);
133
133
border: 1px solid var(--border);
134
134
border-radius: 8px;
···
139
139
flex-direction: column;
140
140
}
141
141
142
-
.EmbedModal-header {
142
+
.App-embedModal-header {
143
143
display: flex;
144
144
align-items: center;
145
145
justify-content: space-between;
···
147
147
border-bottom: 1px solid var(--border);
148
148
}
149
149
150
-
.EmbedModal-title {
150
+
.App-embedModal-title {
151
151
margin: 0;
152
152
font-size: 16px;
153
153
font-weight: 600;
154
154
color: var(--text-bright);
155
155
}
156
156
157
-
.EmbedModal-closeBtn {
157
+
.App-embedModal-closeBtn {
158
158
background: none;
159
159
border: none;
160
160
color: var(--text-dim);
···
164
164
line-height: 1;
165
165
}
166
166
167
-
.EmbedModal-closeBtn:hover {
167
+
.App-embedModal-closeBtn:hover {
168
168
color: var(--text-bright);
169
169
}
170
170
171
-
.EmbedModal-body {
171
+
.App-embedModal-body {
172
172
padding: 16px;
173
173
overflow: auto;
174
174
}
175
175
176
-
.EmbedModal-description {
176
+
.App-embedModal-description {
177
177
margin: 0 0 12px;
178
178
font-size: 13px;
179
179
color: var(--text);
180
180
}
181
181
182
-
.EmbedModal-textarea {
182
+
.App-embedModal-textarea {
183
183
width: 100%;
184
184
height: 250px;
185
185
background: var(--bg);
···
192
192
resize: none;
193
193
}
194
194
195
-
.EmbedModal-textarea:focus {
195
+
.App-embedModal-textarea:focus {
196
196
outline: none;
197
197
border-color: #555;
198
198
}
199
199
200
-
.EmbedModal-footer {
200
+
.App-embedModal-footer {
201
201
padding: 16px;
202
202
border-top: 1px solid var(--border);
203
203
display: flex;
204
204
justify-content: flex-end;
205
205
}
206
206
207
-
.EmbedModal-copyBtn {
207
+
.App-embedModal-copyBtn {
208
208
background: #ffd54f;
209
209
border: none;
210
210
color: #000;
···
215
215
cursor: pointer;
216
216
}
217
217
218
-
.EmbedModal-copyBtn:hover {
218
+
.App-embedModal-copyBtn:hover {
219
219
background: #ffe566;
220
220
}
221
221
222
222
/* Responsive */
223
223
224
224
@media (max-width: 900px) {
225
-
.ExampleSelect-label,
226
-
.BuildSwitcher-label {
225
+
.App-exampleSelect-label,
226
+
.App-buildSwitcher-label {
227
227
display: none;
228
228
}
229
229
}
···
244
244
display: none;
245
245
}
246
246
247
-
.ExampleSelect {
247
+
.App-exampleSelect {
248
248
border-left: none;
249
249
}
250
250
···
253
253
min-width: 0;
254
254
}
255
255
256
-
.ExampleSelect {
256
+
.App-exampleSelect {
257
257
padding-left: 6px;
258
258
margin-left: 2px;
259
259
gap: 4px;
260
260
}
261
261
262
-
.ExampleSelect-selectWrapper {
262
+
.App-exampleSelect-selectWrapper {
263
263
min-width: 0;
264
264
}
265
265
266
-
.BuildSwitcher-version,
267
-
.BuildSwitcher-mode {
266
+
.App-buildSwitcher-version,
267
+
.App-buildSwitcher-mode {
268
268
min-width: 0;
269
269
}
270
270
271
-
.BuildSwitcher {
271
+
.App-buildSwitcher {
272
272
padding-left: 6px;
273
273
gap: 4px;
274
274
}
···
284
284
font-size: 10px;
285
285
}
286
286
287
-
.ExampleSelect {
287
+
.App-exampleSelect {
288
288
padding-left: 4px;
289
289
margin-left: 0;
290
290
border-left: none;
291
291
}
292
292
293
-
.ExampleSelect-selectWrapper {
293
+
.App-exampleSelect-selectWrapper {
294
294
max-width: 110px;
295
295
}
296
296
297
-
.BuildSwitcher {
297
+
.App-buildSwitcher {
298
298
padding-left: 4px;
299
299
border-left: none;
300
300
}
301
301
302
-
.BuildSwitcher-version,
303
-
.BuildSwitcher-mode {
302
+
.App-buildSwitcher-version,
303
+
.App-buildSwitcher-mode {
304
304
max-width: 80px;
305
305
}
306
306
}
307
307
308
308
@media (max-width: 360px) {
309
-
.BuildSwitcher-version,
310
-
.BuildSwitcher-mode {
309
+
.App-buildSwitcher-version,
310
+
.App-buildSwitcher-mode {
311
311
max-width: 75px;
312
312
}
313
313
}
+19
-19
src/client/ui/App.tsx
+19
-19
src/client/ui/App.tsx
···
27
27
};
28
28
29
29
return (
30
-
<div className="BuildSwitcher">
31
-
<label className="BuildSwitcher-label">React</label>
32
-
<div className="BuildSwitcher-version">
30
+
<div className="App-buildSwitcher">
31
+
<label className="App-buildSwitcher-label">React</label>
32
+
<div className="App-buildSwitcher-version">
33
33
<Select value={version} onChange={handleVersionChange} disabled={isDisabled}>
34
34
{(REACT_VERSIONS as string[]).map((v) => (
35
35
<option key={v} value={v}>
···
38
38
))}
39
39
</Select>
40
40
</div>
41
-
<div className="BuildSwitcher-mode">
41
+
<div className="App-buildSwitcher-mode">
42
42
<Select value={isDev ? "dev" : "prod"} onChange={handleModeChange} disabled={isDisabled}>
43
43
<option value="prod">prod</option>
44
44
<option value="dev">dev</option>
···
142
142
};
143
143
144
144
return (
145
-
<div className="EmbedModal-overlay" onClick={onClose}>
146
-
<div className="EmbedModal" onClick={(e: MouseEvent) => e.stopPropagation()}>
147
-
<div className="EmbedModal-header">
148
-
<h2 className="EmbedModal-title">Embed this example</h2>
149
-
<button className="EmbedModal-closeBtn" onClick={onClose}>
145
+
<div className="App-embedModal-overlay" onClick={onClose}>
146
+
<div className="App-embedModal" onClick={(e: MouseEvent) => e.stopPropagation()}>
147
+
<div className="App-embedModal-header">
148
+
<h2 className="App-embedModal-title">Embed this example</h2>
149
+
<button className="App-embedModal-closeBtn" onClick={onClose}>
150
150
×
151
151
</button>
152
152
</div>
153
-
<div className="EmbedModal-body">
154
-
<p className="EmbedModal-description">Copy and paste this code into your HTML:</p>
153
+
<div className="App-embedModal-body">
154
+
<p className="App-embedModal-description">Copy and paste this code into your HTML:</p>
155
155
<textarea
156
156
ref={textareaRef}
157
-
className="EmbedModal-textarea"
157
+
className="App-embedModal-textarea"
158
158
readOnly
159
159
value={embedCode}
160
160
onClick={(e) => (e.target as HTMLTextAreaElement).select()}
161
161
/>
162
162
</div>
163
-
<div className="EmbedModal-footer">
164
-
<button className="EmbedModal-copyBtn" onClick={handleCopy}>
163
+
<div className="App-embedModal-footer">
164
+
<button className="App-embedModal-copyBtn" onClick={handleCopy}>
165
165
{copied ? "Copied!" : "Copy to clipboard"}
166
166
</button>
167
167
</div>
···
249
249
<>
250
250
<header className="App-header">
251
251
<h1 className="App-title">RSC Explorer</h1>
252
-
<div className="ExampleSelect">
253
-
<label className="ExampleSelect-label">Example</label>
254
-
<div className="ExampleSelect-selectWrapper">
252
+
<div className="App-exampleSelect">
253
+
<label className="App-exampleSelect-label">Example</label>
254
+
<div className="App-exampleSelect-selectWrapper">
255
255
<Select value={currentSample ?? ""} onChange={handleSampleChange}>
256
256
{!currentSample && <option value="">Custom</option>}
257
257
{Object.entries(SAMPLES).map(([key, sample]) => (
···
262
262
</Select>
263
263
</div>
264
264
<button
265
-
className="ExampleSelect-saveBtn"
265
+
className="App-exampleSelect-saveBtn"
266
266
onClick={handleSave}
267
267
disabled={!isDirty}
268
268
title="Save to URL"
···
281
281
</svg>
282
282
</button>
283
283
<button
284
-
className="ExampleSelect-embedBtn"
284
+
className="App-exampleSelect-embedBtn"
285
285
onClick={() => setShowEmbedModal(true)}
286
286
title="Embed"
287
287
>
+3
-3
src/client/ui/CodeEditor.css
+3
-3
src/client/ui/CodeEditor.css
···
1
1
/* CodeEditor component styles */
2
2
3
-
.CodeEditor-container {
3
+
.CodeEditor {
4
4
flex: 1;
5
5
min-height: 0;
6
6
position: relative;
···
8
8
background: var(--bg);
9
9
}
10
10
11
-
.CodeEditor-container .cm-editor {
11
+
.CodeEditor .cm-editor {
12
12
position: absolute !important;
13
13
top: 0;
14
14
left: 0;
···
18
18
background: transparent;
19
19
}
20
20
21
-
.CodeEditor-container .cm-editor .cm-scroller {
21
+
.CodeEditor .cm-editor .cm-scroller {
22
22
overflow: auto !important;
23
23
}
+5
-11
src/client/ui/CodeEditor.tsx
+5
-11
src/client/ui/CodeEditor.tsx
···
5
5
import { tags } from "@lezer/highlight";
6
6
import { history, historyKeymap, defaultKeymap } from "@codemirror/commands";
7
7
import { closeBrackets, closeBracketsKeymap } from "@codemirror/autocomplete";
8
+
import { Pane } from "./Pane.tsx";
8
9
import "./CodeEditor.css";
9
10
10
11
const highlightStyle = HighlightStyle.define([
···
44
45
defaultValue: string;
45
46
onChange: (code: string) => void;
46
47
label: string;
47
-
paneClass?: string;
48
48
};
49
49
50
-
export function CodeEditor({
51
-
defaultValue,
52
-
onChange,
53
-
label,
54
-
paneClass,
55
-
}: CodeEditorProps): React.ReactElement {
50
+
export function CodeEditor({ defaultValue, onChange, label }: CodeEditorProps): React.ReactElement {
56
51
const [initialDefaultValue] = useState(defaultValue);
57
52
const containerRef = useRef<HTMLDivElement>(null);
58
53
···
87
82
}, [initialDefaultValue]);
88
83
89
84
return (
90
-
<div className={`Workspace-pane${paneClass ? ` ${paneClass}` : ""}`}>
91
-
<div className="Workspace-paneHeader">{label}</div>
92
-
<div className="CodeEditor-container" ref={containerRef} />
93
-
</div>
85
+
<Pane label={label}>
86
+
<div className="CodeEditor" ref={containerRef} />
87
+
</Pane>
94
88
);
95
89
}
+39
-39
src/client/ui/FlightLog.css
+39
-39
src/client/ui/FlightLog.css
···
48
48
}
49
49
}
50
50
51
-
/* FlightLogEntry */
51
+
/* FlightLog-entry */
52
52
53
-
.FlightLogEntry {
53
+
.FlightLog-entry {
54
54
background: var(--surface);
55
55
border: 1px solid var(--border);
56
56
border-radius: 4px;
···
58
58
border-left: 3px solid #555;
59
59
}
60
60
61
-
.FlightLogEntry + .FlightLogEntry {
61
+
.FlightLog-entry + .FlightLog-entry {
62
62
margin-top: 12px;
63
63
}
64
64
65
-
.FlightLogEntry--active {
65
+
.FlightLog-entry--active {
66
66
border-left-color: #ffd54f;
67
67
}
68
68
69
-
.FlightLogEntry--done {
69
+
.FlightLog-entry--done {
70
70
border-left-color: #555;
71
71
opacity: 0.8;
72
72
}
73
73
74
-
.FlightLogEntry--pending {
74
+
.FlightLog-entry--pending {
75
75
border-left-color: #333;
76
76
opacity: 0.4;
77
77
}
78
78
79
-
.FlightLogEntry-header {
79
+
.FlightLog-entry-header {
80
80
display: flex;
81
81
align-items: center;
82
82
justify-content: space-between;
···
86
86
border-bottom: 1px solid var(--border);
87
87
}
88
88
89
-
.FlightLogEntry-label {
89
+
.FlightLog-entry-label {
90
90
color: var(--text);
91
91
font-weight: 500;
92
92
}
93
93
94
-
.FlightLogEntry-headerRight {
94
+
.FlightLog-entry-headerRight {
95
95
display: flex;
96
96
align-items: center;
97
97
gap: 8px;
98
98
}
99
99
100
-
.FlightLogEntry-deleteBtn {
100
+
.FlightLog-entry-deleteBtn {
101
101
background: transparent;
102
102
border: none;
103
103
color: var(--text-dim);
···
111
111
color 0.15s;
112
112
}
113
113
114
-
.FlightLogEntry-deleteBtn:hover {
114
+
.FlightLog-entry-deleteBtn:hover {
115
115
opacity: 1;
116
116
color: #e57373;
117
117
}
118
118
119
-
.FlightLogEntry-request {
119
+
.FlightLog-entry-request {
120
120
padding: 8px 10px;
121
121
background: rgba(0, 0, 0, 0.2);
122
122
border-bottom: 1px solid var(--border);
123
123
}
124
124
125
-
.FlightLogEntry-requestArgs {
125
+
.FlightLog-entry-requestArgs {
126
126
margin: 0;
127
127
font-family: var(--font-mono);
128
128
font-size: 11px;
···
132
132
word-break: break-all;
133
133
}
134
134
135
-
/* RenderLogView */
135
+
/* FlightLog-renderView */
136
136
137
-
.RenderLogView {
137
+
.FlightLog-renderView {
138
138
border-top: 1px solid var(--border);
139
139
padding: 8px;
140
140
}
141
141
142
-
.RenderLogView-split {
142
+
.FlightLog-renderView-split {
143
143
display: flex;
144
144
gap: 8px;
145
145
align-items: stretch;
146
146
}
147
147
148
-
.RenderLogView-linesWrapper {
148
+
.FlightLog-linesWrapper {
149
149
flex: 1;
150
150
min-width: 0;
151
151
position: relative;
152
152
min-height: 150px;
153
153
}
154
154
155
-
.RenderLogView-lines {
155
+
.FlightLog-lines {
156
156
position: absolute;
157
157
top: 0;
158
158
left: 0;
···
167
167
overflow: auto;
168
168
}
169
169
170
-
.RenderLogView-line {
170
+
.FlightLog-line {
171
171
display: block;
172
172
padding: 6px 8px;
173
173
margin-bottom: 3px;
···
178
178
transition: all 0.15s ease;
179
179
}
180
180
181
-
.RenderLogView-line:last-child {
181
+
.FlightLog-line:last-child {
182
182
margin-bottom: 0;
183
183
}
184
184
185
-
.RenderLogView-line--done {
185
+
.FlightLog-line--done {
186
186
color: #999;
187
187
background: rgba(255, 255, 255, 0.03);
188
188
border-left-color: #555;
189
189
}
190
190
191
-
.RenderLogView-line--next {
191
+
.FlightLog-line--next {
192
192
color: #e0e0e0;
193
193
background: rgba(255, 213, 79, 0.12);
194
194
border-left-color: #ffd54f;
195
195
}
196
196
197
-
.RenderLogView-line--pending {
197
+
.FlightLog-line--pending {
198
198
color: #444;
199
199
background: transparent;
200
200
border-left-color: #333;
201
201
opacity: 0.4;
202
202
}
203
203
204
-
.RenderLogView-tree {
204
+
.FlightLog-tree {
205
205
flex: 1;
206
206
min-width: 0;
207
207
border-radius: 4px;
···
211
211
flex-direction: column;
212
212
}
213
213
214
-
.RenderLogView-tree:has(.FlightTreeView) {
214
+
.FlightLog-tree:has(.TreeView) {
215
215
background: #000;
216
216
}
217
217
218
-
/* RawActionForm */
218
+
/* FlightLog-rawForm */
219
219
220
-
.RawActionForm {
220
+
.FlightLog-rawForm {
221
221
margin-top: 8px;
222
222
padding: 10px;
223
223
background: var(--surface);
···
228
228
gap: 8px;
229
229
}
230
230
231
-
.RawActionForm-textarea {
231
+
.FlightLog-rawForm-textarea {
232
232
width: 100%;
233
233
padding: 8px;
234
234
background: var(--bg);
···
241
241
min-height: 100px;
242
242
}
243
243
244
-
.RawActionForm-textarea:focus {
244
+
.FlightLog-rawForm-textarea:focus {
245
245
outline: none;
246
246
border-color: #555;
247
247
}
248
248
249
-
.RawActionForm-buttons {
249
+
.FlightLog-rawForm-buttons {
250
250
display: flex;
251
251
gap: 8px;
252
252
}
253
253
254
-
.RawActionForm-submitBtn {
254
+
.FlightLog-rawForm-submitBtn {
255
255
padding: 5px 12px;
256
256
border-radius: 3px;
257
257
font-size: 11px;
···
261
261
color: #000;
262
262
}
263
263
264
-
.RawActionForm-submitBtn:disabled {
264
+
.FlightLog-rawForm-submitBtn:disabled {
265
265
background: #555;
266
266
color: #888;
267
267
cursor: not-allowed;
268
268
}
269
269
270
-
.RawActionForm-cancelBtn {
270
+
.FlightLog-rawForm-cancelBtn {
271
271
padding: 5px 12px;
272
272
border-radius: 3px;
273
273
font-size: 11px;
···
277
277
color: var(--text-dim);
278
278
}
279
279
280
-
.RawActionForm-cancelBtn:hover {
280
+
.FlightLog-rawForm-cancelBtn:hover {
281
281
border-color: #555;
282
282
color: var(--text);
283
283
}
284
284
285
-
/* AddActionButton */
285
+
/* FlightLog-addButton */
286
286
287
-
.AddActionButton-wrapper {
287
+
.FlightLog-addButton-wrapper {
288
288
display: flex;
289
289
justify-content: center;
290
290
margin-top: 8px;
291
291
}
292
292
293
-
.AddActionButton {
293
+
.FlightLog-addButton {
294
294
width: 24px;
295
295
height: 24px;
296
296
padding: 0;
···
304
304
transition: all 0.15s;
305
305
}
306
306
307
-
.AddActionButton:hover {
307
+
.FlightLog-addButton:hover {
308
308
background: #3a3a3a;
309
309
border-color: #666;
310
310
color: var(--text);
···
313
313
/* Responsive */
314
314
315
315
@media (max-width: 768px) {
316
-
.RawActionForm-textarea {
316
+
.FlightLog-rawForm-textarea {
317
317
font-size: 16px !important;
318
318
}
319
319
}
+29
-26
src/client/ui/FlightLog.tsx
+29
-26
src/client/ui/FlightLog.tsx
···
30
30
31
31
const getLineClass = (i: number): string => {
32
32
const globalChunk = chunkStart + i;
33
-
if (globalChunk < cursor) return "RenderLogView-line--done";
34
-
if (globalChunk === cursor) return "RenderLogView-line--next";
35
-
return "RenderLogView-line--pending";
33
+
if (globalChunk < cursor) return "FlightLog-line--done";
34
+
if (globalChunk === cursor) return "FlightLog-line--next";
35
+
return "FlightLog-line--pending";
36
36
};
37
37
38
38
const showTree = cursor >= chunkStart;
39
39
40
40
return (
41
-
<div className="RenderLogView">
42
-
<div className="RenderLogView-split">
43
-
<div className="RenderLogView-linesWrapper">
44
-
<pre className="RenderLogView-lines">
41
+
<div className="FlightLog-renderView">
42
+
<div className="FlightLog-renderView-split">
43
+
<div className="FlightLog-linesWrapper">
44
+
<pre className="FlightLog-lines">
45
45
{rows.map((line, i) => (
46
46
<span
47
47
key={i}
48
48
ref={i === nextLineIndex ? activeRef : null}
49
-
className={`RenderLogView-line ${getLineClass(i)}`}
49
+
className={`FlightLog-line ${getLineClass(i)}`}
50
50
>
51
51
{escapeHtml(line)}
52
52
</span>
53
53
))}
54
54
</pre>
55
55
</div>
56
-
<div className="RenderLogView-tree">
56
+
<div className="FlightLog-tree">
57
57
{showTree && <FlightTreeView flightPromise={flightPromise ?? null} inEntry />}
58
58
</div>
59
59
</div>
···
75
75
onDelete,
76
76
}: FlightLogEntryProps): React.ReactElement {
77
77
const modifierClass = entry.isActive
78
-
? "FlightLogEntry--active"
78
+
? "FlightLog-entry--active"
79
79
: entry.isDone
80
-
? "FlightLogEntry--done"
81
-
: "FlightLogEntry--pending";
80
+
? "FlightLog-entry--done"
81
+
: "FlightLog-entry--pending";
82
82
83
83
return (
84
-
<div className={`FlightLogEntry ${modifierClass}`}>
85
-
<div className="FlightLogEntry-header">
86
-
<span className="FlightLogEntry-label">
84
+
<div className={`FlightLog-entry ${modifierClass}`}>
85
+
<div className="FlightLog-entry-header">
86
+
<span className="FlightLog-entry-label">
87
87
{entry.type === "render" ? "Render" : `Action: ${entry.name}`}
88
88
</span>
89
-
<span className="FlightLogEntry-headerRight">
89
+
<span className="FlightLog-entry-headerRight">
90
90
{entry.canDelete && (
91
91
<button
92
-
className="FlightLogEntry-deleteBtn"
92
+
className="FlightLog-entry-deleteBtn"
93
93
onClick={() => onDelete(index)}
94
94
title="Delete"
95
95
>
···
99
99
</span>
100
100
</div>
101
101
{entry.type === "action" && entry.args && (
102
-
<div className="FlightLogEntry-request">
103
-
<pre className="FlightLogEntry-requestArgs">{entry.args}</pre>
102
+
<div className="FlightLog-entry-request">
103
+
<pre className="FlightLog-entry-requestArgs">{entry.args}</pre>
104
104
</div>
105
105
)}
106
106
<RenderLogView entry={entry} cursor={cursor} />
···
157
157
))}
158
158
{availableActions.length > 0 &&
159
159
(showRawInput ? (
160
-
<div className="RawActionForm">
160
+
<div className="FlightLog-rawForm">
161
161
<Select value={selectedAction} onChange={(e) => setSelectedAction(e.target.value)}>
162
162
{availableActions.map((action) => (
163
163
<option key={action} value={action}>
···
169
169
placeholder="Paste a request payload from a real action"
170
170
value={rawPayload}
171
171
onChange={(e) => setRawPayload(e.target.value)}
172
-
className="RawActionForm-textarea"
172
+
className="FlightLog-rawForm-textarea"
173
173
rows={6}
174
174
/>
175
-
<div className="RawActionForm-buttons">
175
+
<div className="FlightLog-rawForm-buttons">
176
176
<button
177
-
className="RawActionForm-submitBtn"
177
+
className="FlightLog-rawForm-submitBtn"
178
178
onClick={handleAddRaw}
179
179
disabled={!rawPayload.trim()}
180
180
>
181
181
Add
182
182
</button>
183
-
<button className="RawActionForm-cancelBtn" onClick={() => setShowRawInput(false)}>
183
+
<button
184
+
className="FlightLog-rawForm-cancelBtn"
185
+
onClick={() => setShowRawInput(false)}
186
+
>
184
187
Cancel
185
188
</button>
186
189
</div>
187
190
</div>
188
191
) : (
189
-
<div className="AddActionButton-wrapper">
190
-
<button className="AddActionButton" onClick={handleShowRawInput} title="Add action">
192
+
<div className="FlightLog-addButton-wrapper">
193
+
<button className="FlightLog-addButton" onClick={handleShowRawInput} title="Add action">
191
194
+
192
195
</button>
193
196
</div>
+3
-3
src/client/ui/LivePreview.tsx
+3
-3
src/client/ui/LivePreview.tsx
···
1
1
import React, { Suspense, Component, useState, useEffect, type ReactNode } from "react";
2
2
import type { EntryView, Thenable } from "../runtime/index.ts";
3
+
import { Pane } from "./Pane.tsx";
3
4
import "./LivePreview.css";
4
5
5
6
type PreviewErrorBoundaryProps = {
···
107
108
}
108
109
109
110
return (
110
-
<div className="Workspace-pane Workspace-pane--preview">
111
-
<div className="Workspace-paneHeader">preview</div>
111
+
<Pane label="preview">
112
112
<div className="LivePreview-playback">
113
113
<div className="LivePreview-controls">
114
114
<button
···
187
187
</PreviewErrorBoundary>
188
188
) : null}
189
189
</div>
190
-
</div>
190
+
</Pane>
191
191
);
192
192
}
+17
src/client/ui/Pane.css
+17
src/client/ui/Pane.css
···
1
+
/* Pane component styles */
2
+
3
+
.Pane {
4
+
display: flex;
5
+
flex-direction: column;
6
+
overflow: hidden;
7
+
}
8
+
9
+
.Pane-header {
10
+
padding: 6px 12px;
11
+
font-size: 10px;
12
+
text-transform: uppercase;
13
+
letter-spacing: 1px;
14
+
color: var(--text-dim);
15
+
flex-shrink: 0;
16
+
border-bottom: 1px solid var(--border);
17
+
}
+16
src/client/ui/Pane.tsx
+16
src/client/ui/Pane.tsx
···
1
+
import React, { type ReactNode } from "react";
2
+
import "./Pane.css";
3
+
4
+
type PaneProps = {
5
+
label: string;
6
+
children: ReactNode;
7
+
};
8
+
9
+
export function Pane({ label, children }: PaneProps): React.ReactElement {
10
+
return (
11
+
<div className="Pane">
12
+
<div className="Pane-header">{label}</div>
13
+
{children}
14
+
</div>
15
+
);
16
+
}
+31
-31
src/client/ui/TreeView.css
+31
-31
src/client/ui/TreeView.css
···
1
1
/* TreeView component styles */
2
2
3
-
.FlightTreeView {
3
+
.TreeView {
4
4
flex: 1;
5
5
min-height: 0;
6
6
padding: 12px;
···
11
11
background: var(--bg);
12
12
}
13
13
14
-
.FlightTreeView--inEntry {
14
+
.TreeView--inEntry {
15
15
padding: 8px;
16
16
font-size: 11px;
17
17
line-height: 1.6;
18
18
}
19
19
20
-
.FlightTreeView-output {
20
+
.TreeView-output {
21
21
margin: 0;
22
22
white-space: pre-wrap;
23
23
word-break: break-word;
24
24
color: var(--text);
25
25
}
26
26
27
-
/* PendingFallback */
27
+
/* TreeView-pending */
28
28
29
-
.PendingFallback {
29
+
.TreeView-pending {
30
30
display: inline-block;
31
31
color: #64b5f6;
32
32
background: rgba(100, 181, 246, 0.15);
···
36
36
font-size: 10px;
37
37
font-weight: 500;
38
38
letter-spacing: 0.5px;
39
-
animation: pendingPulse 2s ease-in-out infinite;
39
+
animation: treeViewPendingPulse 2s ease-in-out infinite;
40
40
}
41
41
42
-
@keyframes pendingPulse {
42
+
@keyframes treeViewPendingPulse {
43
43
0%,
44
44
100% {
45
45
opacity: 0.7;
···
49
49
}
50
50
}
51
51
52
-
/* ErrorFallback */
52
+
/* TreeView-error */
53
53
54
-
.ErrorFallback {
54
+
.TreeView-error {
55
55
display: inline-block;
56
56
color: #e57373;
57
57
background: rgba(229, 115, 115, 0.15);
···
63
63
letter-spacing: 0.5px;
64
64
}
65
65
66
-
/* JSXValue types */
66
+
/* TreeView value types */
67
67
68
-
.JSXValue-null {
68
+
.TreeView-null {
69
69
color: #5c6370;
70
70
font-style: italic;
71
71
}
72
72
73
-
.JSXValue-undefined {
73
+
.TreeView-undefined {
74
74
color: #5c6370;
75
75
font-style: italic;
76
76
}
77
77
78
-
.JSXValue-string {
78
+
.TreeView-string {
79
79
color: #98c379;
80
80
}
81
81
82
-
.JSXValue-number {
82
+
.TreeView-number {
83
83
color: #d19a66;
84
84
}
85
85
86
-
.JSXValue-boolean {
86
+
.TreeView-boolean {
87
87
color: #56b6c2;
88
88
}
89
89
90
-
.JSXValue-symbol {
90
+
.TreeView-symbol {
91
91
color: #c678dd;
92
92
}
93
93
94
-
.JSXValue-function {
94
+
.TreeView-function {
95
95
color: #61afef;
96
96
font-style: italic;
97
97
}
98
98
99
-
.JSXValue-circular {
99
+
.TreeView-circular {
100
100
color: #e57373;
101
101
font-style: italic;
102
102
}
103
103
104
-
.JSXValue-date {
104
+
.TreeView-date {
105
105
color: #e5c07b;
106
106
}
107
107
108
-
.JSXValue-collection {
108
+
.TreeView-collection {
109
109
color: var(--text-dim);
110
110
}
111
111
112
-
.JSXValue-iterator {
112
+
.TreeView-iterator {
113
113
color: var(--text-dim);
114
114
font-style: italic;
115
115
}
116
116
117
-
.JSXValue-stream {
117
+
.TreeView-stream {
118
118
color: var(--text-dim);
119
119
font-style: italic;
120
120
}
121
121
122
-
.JSXValue-key {
122
+
.TreeView-key {
123
123
color: #61afef;
124
124
}
125
125
126
-
.JSXValue-empty {
126
+
.TreeView-empty {
127
127
color: #5c6370;
128
128
font-style: italic;
129
129
}
130
130
131
-
.JSXValue-unknown {
131
+
.TreeView-unknown {
132
132
color: var(--text-dim);
133
133
}
134
134
135
-
/* JSXElement */
135
+
/* TreeView element */
136
136
137
-
.JSXElement-tag {
137
+
.TreeView-tag {
138
138
color: #e06c75;
139
139
}
140
140
141
-
.JSXElement-clientTag {
141
+
.TreeView-clientTag {
142
142
color: #c678dd;
143
143
}
144
144
145
-
.JSXElement-reactTag {
145
+
.TreeView-reactTag {
146
146
color: #e5c07b;
147
147
}
148
148
149
-
.JSXElement-propName {
149
+
.TreeView-propName {
150
150
color: #61afef;
151
151
}
152
152
153
-
.JSXElement-children {
153
+
.TreeView-children {
154
154
margin-left: 16px;
155
155
border-left: 1px solid #333;
156
156
padding-left: 12px;
+45
-45
src/client/ui/TreeView.tsx
+45
-45
src/client/ui/TreeView.tsx
···
29
29
}
30
30
31
31
function PendingFallback(): React.ReactElement {
32
-
return <span className="PendingFallback">Pending</span>;
32
+
return <span className="TreeView-pending">Pending</span>;
33
33
}
34
34
35
35
type ErrorFallbackProps = {
···
38
38
39
39
function ErrorFallback({ error }: ErrorFallbackProps): React.ReactElement {
40
40
const message = error instanceof Error ? error.message : String(error);
41
-
return <span className="ErrorFallback">Error: {message}</span>;
41
+
return <span className="TreeView-error">Error: {message}</span>;
42
42
}
43
43
44
44
type ErrorBoundaryProps = {
···
114
114
115
115
// `ancestors` tracks the current path for cycle detection
116
116
function JSXValue({ value, indent = 0, ancestors = [] }: JSXValueProps): React.ReactElement {
117
-
if (value === null) return <span className="JSXValue-null">null</span>;
118
-
if (value === undefined) return <span className="JSXValue-undefined">undefined</span>;
117
+
if (value === null) return <span className="TreeView-null">null</span>;
118
+
if (value === undefined) return <span className="TreeView-undefined">undefined</span>;
119
119
120
120
if (typeof value === "string") {
121
121
const display = value.length > 50 ? value.slice(0, 50) + "..." : value;
122
-
return <span className="JSXValue-string">"{escapeHtml(display)}"</span>;
122
+
return <span className="TreeView-string">"{escapeHtml(display)}"</span>;
123
123
}
124
124
if (typeof value === "number") {
125
125
const display = Object.is(value, -0) ? "-0" : String(value);
126
-
return <span className="JSXValue-number">{display}</span>;
126
+
return <span className="TreeView-number">{display}</span>;
127
127
}
128
128
if (typeof value === "bigint") {
129
-
return <span className="JSXValue-number">{String(value)}n</span>;
129
+
return <span className="TreeView-number">{String(value)}n</span>;
130
130
}
131
-
if (typeof value === "boolean") return <span className="JSXValue-boolean">{String(value)}</span>;
131
+
if (typeof value === "boolean") return <span className="TreeView-boolean">{String(value)}</span>;
132
132
if (typeof value === "symbol") {
133
-
return <span className="JSXValue-symbol">{value.toString()}</span>;
133
+
return <span className="TreeView-symbol">{value.toString()}</span>;
134
134
}
135
135
if (typeof value === "function") {
136
136
return (
137
-
<span className="JSXValue-function">
137
+
<span className="TreeView-function">
138
138
[Function: {(value as { name?: string }).name || "anonymous"}]
139
139
</span>
140
140
);
···
142
142
143
143
if (typeof value === "object" && value !== null) {
144
144
if (ancestors.includes(value)) {
145
-
return <span className="JSXValue-circular">[Circular]</span>;
145
+
return <span className="TreeView-circular">[Circular]</span>;
146
146
}
147
147
}
148
148
···
150
150
typeof value === "object" && value !== null ? [...ancestors, value] : ancestors;
151
151
152
152
if (value instanceof Date) {
153
-
return <span className="JSXValue-date">Date({value.toISOString()})</span>;
153
+
return <span className="TreeView-date">Date({value.toISOString()})</span>;
154
154
}
155
155
156
156
if (value instanceof Map) {
157
-
if (value.size === 0) return <span className="JSXValue-collection">Map(0) {"{}"}</span>;
157
+
if (value.size === 0) return <span className="TreeView-collection">Map(0) {"{}"}</span>;
158
158
const pad = " ".repeat(indent + 1);
159
159
const closePad = " ".repeat(indent);
160
160
return (
161
161
<>
162
-
<span className="JSXValue-collection">
162
+
<span className="TreeView-collection">
163
163
Map({value.size}) {"{\n"}
164
164
</span>
165
165
{Array.from(value.entries()).map(([k, v], i) => (
···
178
178
}
179
179
180
180
if (value instanceof Set) {
181
-
if (value.size === 0) return <span className="JSXValue-collection">Set(0) {"{}"}</span>;
181
+
if (value.size === 0) return <span className="TreeView-collection">Set(0) {"{}"}</span>;
182
182
const pad = " ".repeat(indent + 1);
183
183
const closePad = " ".repeat(indent);
184
184
return (
185
185
<>
186
-
<span className="JSXValue-collection">
186
+
<span className="TreeView-collection">
187
187
Set({value.size}) {"{\n"}
188
188
</span>
189
189
{Array.from(value).map((v, i) => (
···
202
202
203
203
if (value instanceof FormData) {
204
204
const entries = Array.from(value.entries());
205
-
if (entries.length === 0) return <span className="JSXValue-collection">FormData {"{}"}</span>;
205
+
if (entries.length === 0) return <span className="TreeView-collection">FormData {"{}"}</span>;
206
206
const pad = " ".repeat(indent + 1);
207
207
const closePad = " ".repeat(indent);
208
208
return (
209
209
<>
210
-
<span className="JSXValue-collection">FormData {"{\n"}</span>
210
+
<span className="TreeView-collection">FormData {"{\n"}</span>
211
211
{entries.map(([k, v], i) => (
212
212
<React.Fragment key={i}>
213
213
{pad}
214
-
<span className="JSXValue-key">{k}</span>:{" "}
214
+
<span className="TreeView-key">{k}</span>:{" "}
215
215
<JSXValue value={v} indent={indent + 1} ancestors={nextAncestors} />
216
216
{i < entries.length - 1 ? "," : ""}
217
217
{"\n"}
···
225
225
226
226
if (value instanceof Blob) {
227
227
return (
228
-
<span className="JSXValue-collection">
228
+
<span className="TreeView-collection">
229
229
Blob({value.size} bytes, "{value.type || "application/octet-stream"}")
230
230
</span>
231
231
);
···
237
237
const preview = Array.from(arr.slice(0, 5)).join(", ");
238
238
const suffix = arr.length > 5 ? ", ..." : "";
239
239
return (
240
-
<span className="JSXValue-collection">
240
+
<span className="TreeView-collection">
241
241
{name}({arr.length}) [{preview}
242
242
{suffix}]
243
243
</span>
244
244
);
245
245
}
246
246
if (value instanceof ArrayBuffer) {
247
-
return <span className="JSXValue-collection">ArrayBuffer({value.byteLength} bytes)</span>;
247
+
return <span className="TreeView-collection">ArrayBuffer({value.byteLength} bytes)</span>;
248
248
}
249
249
250
250
if (Array.isArray(value)) {
···
253
253
254
254
const renderItem = (i: number): React.ReactElement => {
255
255
if (!(i in value)) {
256
-
return <span className="JSXValue-empty">empty</span>;
256
+
return <span className="TreeView-empty">empty</span>;
257
257
}
258
258
return <JSXValue value={value[i]} indent={indent + 1} ancestors={nextAncestors} />;
259
259
};
···
298
298
const obj = value as Record<string | symbol, unknown>;
299
299
300
300
if (typeof obj.next === "function" && typeof obj[Symbol.iterator] === "function") {
301
-
return <span className="JSXValue-iterator">Iterator {"{}"}</span>;
301
+
return <span className="TreeView-iterator">Iterator {"{}"}</span>;
302
302
}
303
303
304
304
if (typeof obj[Symbol.asyncIterator] === "function") {
305
-
return <span className="JSXValue-iterator">AsyncIterator {"{}"}</span>;
305
+
return <span className="TreeView-iterator">AsyncIterator {"{}"}</span>;
306
306
}
307
307
308
308
if (value instanceof ReadableStream) {
309
-
return <span className="JSXValue-stream">ReadableStream {"{}"}</span>;
309
+
return <span className="TreeView-stream">ReadableStream {"{}"}</span>;
310
310
}
311
311
312
312
if (typeof obj.then === "function") {
···
341
341
{"{ "}
342
342
{entries.map(([k, v], i) => (
343
343
<React.Fragment key={k}>
344
-
<span className="JSXValue-key">{k}</span>:{" "}
344
+
<span className="TreeView-key">{k}</span>:{" "}
345
345
<JSXValue value={v} indent={indent} ancestors={nextAncestors} />
346
346
{i < entries.length - 1 ? ", " : ""}
347
347
</React.Fragment>
···
358
358
{entries.map(([k, v], i) => (
359
359
<React.Fragment key={k}>
360
360
{pad}
361
-
<span className="JSXValue-key">{k}</span>:{" "}
361
+
<span className="TreeView-key">{k}</span>:{" "}
362
362
<JSXValue value={v} indent={indent + 1} ancestors={nextAncestors} />
363
363
{i < entries.length - 1 ? "," : ""}
364
364
{"\n"}
···
370
370
);
371
371
}
372
372
373
-
return <span className="JSXValue-unknown">{String(value)}</span>;
373
+
return <span className="TreeView-unknown">{String(value)}</span>;
374
374
}
375
375
376
376
type JSXElementProps = {
···
385
385
const padInner = " ".repeat(indent + 1);
386
386
387
387
let tagName: string;
388
-
let tagClass = "JSXElement-tag";
388
+
let tagClass = "TreeView-tag";
389
389
if (typeof type === "string") {
390
390
tagName = type;
391
391
} else if (typeof type === "function") {
392
392
const funcType = type as { displayName?: string; name?: string };
393
393
tagName = funcType.displayName || funcType.name || "Component";
394
-
tagClass = "JSXElement-clientTag";
394
+
tagClass = "TreeView-clientTag";
395
395
} else if (typeof type === "symbol") {
396
396
switch (type) {
397
397
case Symbol.for("react.fragment"):
···
418
418
default:
419
419
tagName = "Unknown";
420
420
}
421
-
tagClass = "JSXElement-reactTag";
421
+
tagClass = "TreeView-reactTag";
422
422
} else if (type && typeof type === "object" && (type as { $$typeof?: symbol }).$$typeof) {
423
423
const lazyType = type as ReactLazy;
424
424
if (lazyType.$$typeof === Symbol.for("react.lazy")) {
···
435
435
);
436
436
}
437
437
tagName = "Component";
438
-
tagClass = "JSXElement-clientTag";
438
+
tagClass = "TreeView-clientTag";
439
439
} else {
440
440
tagName = "Unknown";
441
441
}
···
456
456
{key != null && (
457
457
<>
458
458
{" "}
459
-
<span className="JSXElement-propName">key</span>=
460
-
<span className="JSXValue-string">"{key}"</span>
459
+
<span className="TreeView-propName">key</span>=
460
+
<span className="TreeView-string">"{key}"</span>
461
461
</>
462
462
)}
463
463
{propEntries.map(([k, v]) => (
···
475
475
{key != null && (
476
476
<>
477
477
{" "}
478
-
<span className="JSXElement-propName">key</span>=
479
-
<span className="JSXValue-string">"{key}"</span>
478
+
<span className="TreeView-propName">key</span>=
479
+
<span className="TreeView-string">"{key}"</span>
480
480
</>
481
481
)}
482
482
{propEntries.map(([k, v]) => (
···
511
511
return (
512
512
<>
513
513
{" "}
514
-
<span className="JSXElement-propName">{name}</span>=
515
-
<span className="JSXValue-string">"{escapeHtml(value)}"</span>
514
+
<span className="TreeView-propName">{name}</span>=
515
+
<span className="TreeView-string">"{escapeHtml(value)}"</span>
516
516
</>
517
517
);
518
518
}
···
522
522
return (
523
523
<>
524
524
{" "}
525
-
<span className="JSXElement-propName">{name}</span>={"{"}
525
+
<span className="TreeView-propName">{name}</span>={"{"}
526
526
{"\n"}
527
527
{pad}
528
528
<JSXValue value={value} indent={indent} ancestors={ancestors} />
···
538
538
return (
539
539
<>
540
540
{" "}
541
-
<span className="JSXElement-propName">{name}</span>={"{["}
541
+
<span className="TreeView-propName">{name}</span>={"{["}
542
542
{"\n"}
543
543
{value.map((v, i) => (
544
544
<React.Fragment key={i}>
···
556
556
return (
557
557
<>
558
558
{" "}
559
-
<span className="JSXElement-propName">{name}</span>={"{"}
559
+
<span className="TreeView-propName">{name}</span>={"{"}
560
560
<JSXValue value={value} indent={indent} ancestors={ancestors} />
561
561
{"}"}
562
562
</>
···
609
609
flightPromise,
610
610
inEntry,
611
611
}: FlightTreeViewProps): React.ReactElement {
612
-
const className = inEntry ? "FlightTreeView FlightTreeView--inEntry" : "FlightTreeView";
612
+
const className = inEntry ? "TreeView TreeView--inEntry" : "TreeView";
613
613
614
614
if (!flightPromise) {
615
615
return (
616
616
<div className={className}>
617
-
<pre className="FlightTreeView-output">
617
+
<pre className="TreeView-output">
618
618
<PendingFallback />
619
619
</pre>
620
620
</div>
···
623
623
624
624
return (
625
625
<div className={className}>
626
-
<pre className="FlightTreeView-output">
626
+
<pre className="TreeView-output">
627
627
<ErrorBoundary>
628
628
<Suspense fallback={<PendingFallback />}>
629
629
<Await promise={flightPromise}>
+61
-24
src/client/ui/Workspace.css
+61
-24
src/client/ui/Workspace.css
···
12
12
overflow: hidden;
13
13
}
14
14
15
-
.Workspace-pane {
15
+
/* Grid positioning */
16
+
17
+
.Workspace-server,
18
+
.Workspace-client,
19
+
.Workspace-flight,
20
+
.Workspace-preview {
16
21
display: flex;
17
-
flex-direction: column;
18
-
overflow: hidden;
22
+
min-width: 0;
23
+
min-height: 0;
19
24
}
20
25
21
-
.Workspace-pane--server {
26
+
.Workspace-server > *,
27
+
.Workspace-client > *,
28
+
.Workspace-flight > *,
29
+
.Workspace-preview > * {
30
+
flex: 1;
31
+
min-width: 0;
32
+
min-height: 0;
33
+
}
34
+
35
+
.Workspace-server {
22
36
grid-area: server;
23
37
border-right: 1px solid var(--border);
24
38
border-bottom: 1px solid var(--border);
25
39
}
26
40
27
-
.Workspace-pane--client {
41
+
.Workspace-client {
28
42
grid-area: client;
29
43
border-right: 1px solid var(--border);
30
44
}
31
45
32
-
.Workspace-pane--flight {
46
+
.Workspace-flight {
33
47
grid-area: flight;
34
48
border-bottom: 1px solid var(--border);
35
49
}
36
50
37
-
.Workspace-pane--preview {
51
+
.Workspace-preview {
38
52
grid-area: preview;
39
53
}
40
54
41
-
.Workspace-paneHeader {
42
-
padding: 6px 12px;
43
-
font-size: 10px;
44
-
text-transform: uppercase;
45
-
letter-spacing: 1px;
46
-
color: var(--text-dim);
47
-
flex-shrink: 0;
48
-
border-bottom: 1px solid var(--border);
49
-
}
55
+
/* Loading states */
50
56
51
-
/* WorkspaceLoading states */
52
-
53
-
.WorkspaceLoading-output {
57
+
.Workspace-loadingOutput {
54
58
flex: 1;
55
59
min-height: 0;
56
60
margin: 0;
···
65
69
color: var(--text-dim);
66
70
}
67
71
68
-
.WorkspaceLoading-preview {
72
+
.Workspace-loadingPreview {
69
73
flex: 1;
70
74
padding: 20px;
71
75
background: #fff;
···
76
80
line-height: 1.5;
77
81
}
78
82
79
-
.WorkspaceLoading-empty {
83
+
.Workspace-loadingEmpty {
80
84
color: var(--text-dim);
81
85
font-style: italic;
82
86
}
83
87
84
-
.WorkspaceLoading-empty--waiting::after {
88
+
.Workspace-loadingEmpty--waiting::after {
85
89
content: "...";
86
-
animation: workspaceLoadingPulse 1.5s ease-in-out infinite;
90
+
animation: workspacePulse 1.5s ease-in-out infinite;
87
91
}
88
92
89
-
@keyframes workspaceLoadingPulse {
93
+
/* Error states */
94
+
95
+
.Workspace-errorOutput {
96
+
flex: 1;
97
+
min-height: 0;
98
+
margin: 0;
99
+
padding: 12px;
100
+
font-family: var(--font-mono);
101
+
font-size: 12px;
102
+
line-height: 1.6;
103
+
overflow: auto;
104
+
white-space: pre-wrap;
105
+
word-break: break-all;
106
+
background: var(--bg);
107
+
color: #e57373;
108
+
}
109
+
110
+
.Workspace-errorPreview {
111
+
flex: 1;
112
+
padding: 20px;
113
+
background: #fff;
114
+
color: #111;
115
+
overflow: auto;
116
+
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
117
+
font-size: 16px;
118
+
line-height: 1.5;
119
+
}
120
+
121
+
.Workspace-errorMessage {
122
+
color: #c0392b;
123
+
font-style: italic;
124
+
}
125
+
126
+
@keyframes workspacePulse {
90
127
0%,
91
128
100% {
92
129
opacity: 0.3;
+52
-46
src/client/ui/Workspace.tsx
+52
-46
src/client/ui/Workspace.tsx
···
3
3
import { CodeEditor } from "./CodeEditor.tsx";
4
4
import { FlightLog } from "./FlightLog.tsx";
5
5
import { LivePreview } from "./LivePreview.tsx";
6
+
import { Pane } from "./Pane.tsx";
6
7
import "./Workspace.css";
7
8
8
9
type WorkspaceProps = {
···
49
50
50
51
return (
51
52
<main className="Workspace">
52
-
<CodeEditor
53
-
label="server"
54
-
defaultValue={serverCode}
55
-
onChange={handleServerChange}
56
-
paneClass="Workspace-pane--server"
57
-
/>
58
-
<CodeEditor
59
-
label="client"
60
-
defaultValue={clientCode}
61
-
onChange={handleClientChange}
62
-
paneClass="Workspace-pane--client"
63
-
/>
53
+
<div className="Workspace-server">
54
+
<CodeEditor label="server" defaultValue={serverCode} onChange={handleServerChange} />
55
+
</div>
56
+
<div className="Workspace-client">
57
+
<CodeEditor label="client" defaultValue={clientCode} onChange={handleClientChange} />
58
+
</div>
64
59
{session ? (
65
60
<WorkspaceContent session={session} onReset={reset} key={session.id} />
66
61
) : (
···
73
68
function WorkspaceLoading(): React.ReactElement {
74
69
return (
75
70
<>
76
-
<div className="Workspace-pane Workspace-pane--flight">
77
-
<div className="Workspace-paneHeader">flight</div>
78
-
<div className="WorkspaceLoading-output">
79
-
<span className="WorkspaceLoading-empty WorkspaceLoading-empty--waiting">Compiling</span>
80
-
</div>
71
+
<div className="Workspace-flight">
72
+
<Pane label="flight">
73
+
<div className="Workspace-loadingOutput">
74
+
<span className="Workspace-loadingEmpty Workspace-loadingEmpty--waiting">
75
+
Compiling
76
+
</span>
77
+
</div>
78
+
</Pane>
81
79
</div>
82
-
<div className="Workspace-pane Workspace-pane--preview">
83
-
<div className="Workspace-paneHeader">preview</div>
84
-
<div className="WorkspaceLoading-preview">
85
-
<span className="WorkspaceLoading-empty WorkspaceLoading-empty--waiting">Compiling</span>
86
-
</div>
80
+
<div className="Workspace-preview">
81
+
<Pane label="preview">
82
+
<div className="Workspace-loadingPreview">
83
+
<span className="Workspace-loadingEmpty Workspace-loadingEmpty--waiting">
84
+
Compiling
85
+
</span>
86
+
</div>
87
+
</Pane>
87
88
</div>
88
89
</>
89
90
);
···
103
104
if (session.state.status === "error") {
104
105
return (
105
106
<>
106
-
<div className="Workspace-pane Workspace-pane--flight">
107
-
<div className="Workspace-paneHeader">flight</div>
108
-
<pre className="FlightLog-output FlightLog-output--error">{session.state.message}</pre>
107
+
<div className="Workspace-flight">
108
+
<Pane label="flight">
109
+
<pre className="Workspace-errorOutput">{session.state.message}</pre>
110
+
</Pane>
109
111
</div>
110
-
<div className="Workspace-pane Workspace-pane--preview">
111
-
<div className="Workspace-paneHeader">preview</div>
112
-
<div className="LivePreview-container">
113
-
<span className="LivePreview-empty LivePreview-empty--error">Compilation error</span>
114
-
</div>
112
+
<div className="Workspace-preview">
113
+
<Pane label="preview">
114
+
<div className="Workspace-errorPreview">
115
+
<span className="Workspace-errorMessage">Compilation error</span>
116
+
</div>
117
+
</Pane>
115
118
</div>
116
119
</>
117
120
);
···
121
124
122
125
return (
123
126
<>
124
-
<div className="Workspace-pane Workspace-pane--flight">
125
-
<div className="Workspace-paneHeader">flight</div>
126
-
<FlightLog
127
+
<div className="Workspace-flight">
128
+
<Pane label="flight">
129
+
<FlightLog
130
+
entries={entries}
131
+
cursor={cursor}
132
+
availableActions={availableActions}
133
+
onAddRawAction={(name, payload) => session.addRawAction(name, payload)}
134
+
onDeleteEntry={(idx) => session.timeline.deleteEntry(idx)}
135
+
/>
136
+
</Pane>
137
+
</div>
138
+
<div className="Workspace-preview">
139
+
<LivePreview
127
140
entries={entries}
128
141
cursor={cursor}
129
-
availableActions={availableActions}
130
-
onAddRawAction={(name, payload) => session.addRawAction(name, payload)}
131
-
onDeleteEntry={(idx) => session.timeline.deleteEntry(idx)}
142
+
totalChunks={totalChunks}
143
+
isAtStart={isAtStart}
144
+
isAtEnd={isAtEnd}
145
+
onStep={() => session.timeline.stepForward()}
146
+
onSkip={() => session.timeline.skipToEntryEnd()}
147
+
onReset={onReset}
132
148
/>
133
149
</div>
134
-
<LivePreview
135
-
entries={entries}
136
-
cursor={cursor}
137
-
totalChunks={totalChunks}
138
-
isAtStart={isAtStart}
139
-
isAtEnd={isAtEnd}
140
-
onStep={() => session.timeline.stepForward()}
141
-
onSkip={() => session.timeline.skipToEntryEnd()}
142
-
onReset={onReset}
143
-
/>
144
150
</>
145
151
);
146
152
}