A tool for people curious about the React Server Components protocol

clean up more styles + add lint

+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
··· 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
··· 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
··· 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 &times; 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
··· 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 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 }