feat(web/ui): refactor component inline styles into utility classes
Pass 2 of the web UI spacing/typography/visual-rhythm rework. - Add layout, spacing, typography, visual, card, and component utilities - Add form-section, form-row, form-help, text-error, alert-success - Unify .form-group and .form-field; add .status-badge family - Alias legacy button classes to .btn primitives - Refactor ToolTypeListSidebar and ConfigProfileListSidebar to use .sidebar and var(--sidebar-width) instead of hardcoded 280px - Refactor ToolTypeEditorPanel, ConfigProfileEditorPanel, git-mount-editor, and manifest-editor to use utility classes Quality gates: npm run typecheck, npm run lint, npm run build pass. Inline style blocks in target components reduced from 198 to 11.
This commit is contained in:
@@ -86,24 +86,24 @@ export const ConfigProfileEditorPanel = ({
|
||||
const hasSelection = isCreating || selectedProfile;
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "1.5rem", minWidth: 0 }}>
|
||||
<div className="flex-1 overflow-auto p-5 min-w-0">
|
||||
{!hasSelection ? (
|
||||
<div style={{ textAlign: "center", paddingTop: "4rem", color: "var(--muted)" }}>
|
||||
<div style={{ opacity: 0.3, marginBottom: "1rem" }}>
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">
|
||||
<Icon name="folder" size="lg" />
|
||||
</div>
|
||||
<h3 style={{ margin: "0 0 0.5rem 0", fontWeight: 500 }}>Select a config profile</h3>
|
||||
<p style={{ margin: 0 }}>Choose a profile from the list to edit, or create a new one.</p>
|
||||
<h3>Select a config profile</h3>
|
||||
<p>Choose a profile from the list to edit, or create a new one.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ marginBottom: "1.5rem", display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||
<div className="max-w-form">
|
||||
<div className="row justify-between items-start mb-5">
|
||||
<div>
|
||||
<h1 style={{ margin: "0 0 0.5rem 0", fontSize: "1.5rem" }}>
|
||||
<h1 className="m-0 mb-2 text-xl">
|
||||
{isCreating ? "Create Profile" : selectedProfile?.name}
|
||||
</h1>
|
||||
{!isCreating && selectedProfile && (
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
<p className="muted m-0">
|
||||
{selectedProfile.project_id &&
|
||||
`Project: ${projects.find((p) => p.id === selectedProfile.project_id)?.name || selectedProfile.project_id}`}
|
||||
{selectedProfile.project_id && selectedProfile.tool_type_id && " · "}
|
||||
@@ -113,8 +113,8 @@ export const ConfigProfileEditorPanel = ({
|
||||
)}
|
||||
</div>
|
||||
{!isCreating && selectedProfile && (
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
<button className="secondary-button" onClick={onPreview} disabled={previewingId === selectedProfile.id}>
|
||||
<div className="row row-sm">
|
||||
<button className="btn btn-secondary" onClick={onPreview} disabled={previewingId === selectedProfile.id}>
|
||||
{previewingId === selectedProfile.id ? (
|
||||
<><Icon name="loading" size="sm" /> Previewing...</>
|
||||
) : (
|
||||
@@ -125,16 +125,16 @@ export const ConfigProfileEditorPanel = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="error" style={{ marginBottom: "1rem" }}>{error}</div>}
|
||||
{error && <div className="form-error mb-3">{error}</div>}
|
||||
|
||||
{saveStatus === "saved" && (
|
||||
<div style={{ marginBottom: "1rem", padding: "0.75rem 1rem", background: "var(--success-bg, #dcfce7)", color: "var(--success, #166534)", borderRadius: "0.375rem", display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
<div className="alert alert-success mb-3">
|
||||
<Icon name="success" size="sm" />
|
||||
Profile saved successfully
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={onSubmit} className="stack" style={{ gap: "1.25rem", maxWidth: "800px" }}>
|
||||
<form onSubmit={onSubmit} className="stack stack-lg">
|
||||
<div className="form-group">
|
||||
<label htmlFor="profile-name">Name *</label>
|
||||
<input id="profile-name" type="text" value={formData.name} onChange={(e) => onFormChange("name", e.target.value)} placeholder="e.g., Development Environment" className="form-input" required />
|
||||
@@ -145,8 +145,8 @@ export const ConfigProfileEditorPanel = ({
|
||||
<input id="profile-description" type="text" value={formData.description || ""} onChange={(e) => onFormChange("description", e.target.value || undefined)} placeholder="Optional description" className="form-input" />
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ gap: "1rem" }}>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<div className="form-row row-md">
|
||||
<div className="form-group">
|
||||
<label htmlFor="profile-project">Project</label>
|
||||
<select id="profile-project" value={formData.project_id || ""} onChange={(e) => onFormChange("project_id", e.target.value || undefined)} className="form-input">
|
||||
<option value="">None (Global)</option>
|
||||
@@ -155,7 +155,7 @@ export const ConfigProfileEditorPanel = ({
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="profile-tool">Tool Type</label>
|
||||
<select id="profile-tool" value={formData.tool_type_id || ""} onChange={(e) => onFormChange("tool_type_id", e.target.value || undefined)} className="form-input">
|
||||
<option value="">None</option>
|
||||
@@ -174,14 +174,14 @@ export const ConfigProfileEditorPanel = ({
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "0.75rem" }}>
|
||||
<h4 style={{ margin: 0 }}>Includes</h4>
|
||||
<span className="muted" style={{ fontSize: "0.875rem" }}>{includedProfileIds.length} included</span>
|
||||
<div className="row justify-between items-center mb-3">
|
||||
<h4 className="m-0">Includes</h4>
|
||||
<span className="muted text-sm">{includedProfileIds.length} included</span>
|
||||
</div>
|
||||
{includedProfileIds.length === 0 ? (
|
||||
<p className="muted" style={{ fontSize: "0.875rem", margin: "0 0 0.75rem 0" }}>No profiles included. Add profiles to compose configurations.</p>
|
||||
<p className="muted text-sm m-0 mb-3">No profiles included. Add profiles to compose configurations.</p>
|
||||
) : (
|
||||
<div style={{ marginBottom: "0.75rem" }}>
|
||||
<div className="mb-3">
|
||||
{includedProfileIds.map((profileId, index) => {
|
||||
const profile = getIncludedProfile(profileId);
|
||||
if (!profile) return null;
|
||||
@@ -193,19 +193,21 @@ export const ConfigProfileEditorPanel = ({
|
||||
onDragOver={(e) => onDragOver(e, index)}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={(e) => onDrop(e, index)}
|
||||
style={{ display: "flex", alignItems: "center", gap: "0.5rem", padding: "0.5rem 0.75rem", background: dragOverIndex === index ? "var(--brand-bg, #e0e7ff)" : "var(--panel)", border: "1px solid var(--border)", borderRadius: "0.375rem", marginBottom: "0.25rem", cursor: "grab", transition: "background 0.15s" }}
|
||||
className={dragOverIndex === index ? "drag-item drag-item-active" : "drag-item"}
|
||||
>
|
||||
<span style={{ cursor: "grab", color: "var(--muted)" }}><Icon name="drag" size="sm" /></span>
|
||||
<span style={{ flex: 1, fontWeight: 500 }}>{profile.name}</span>
|
||||
<span className="drag-handle"><Icon name="drag" size="sm" /></span>
|
||||
<span className="flex-1 font-medium truncate">{profile.name}</span>
|
||||
<span className="badge">{getScopeLabel(profile)}</span>
|
||||
<button type="button" onClick={() => onRemoveInclude(index)} style={{ background: "none", border: "none", color: "var(--danger)", cursor: "pointer", padding: "0.25rem", borderRadius: "0.25rem" }} title="Remove include"><Icon name="delete" size="sm" /></button>
|
||||
<button type="button" onClick={() => onRemoveInclude(index)} className="delete-btn" title="Remove include">
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{availableProfiles.length > 0 && (
|
||||
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||
<div className="form-group mb-0">
|
||||
<select value="" onChange={(e) => { if (e.target.value) { onAddInclude(e.target.value); e.target.value = ""; } }} className="form-input">
|
||||
<option value="">+ Add Include...</option>
|
||||
{availableProfiles.map((p) => (
|
||||
@@ -217,11 +219,11 @@ export const ConfigProfileEditorPanel = ({
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h4 style={{ margin: "0 0 0.75rem 0" }}>Environment Variables</h4>
|
||||
<h4 className="m-0 mb-3">Environment Variables</h4>
|
||||
{Object.entries(formData.env_vars || {}).map(([key, value], idx) => (
|
||||
<div key={idx} className="form-row" style={{ gap: "0.5rem", marginBottom: "0.5rem" }}>
|
||||
<input type="text" value={key} onChange={(e) => onUpdateEnvVar(key, e.target.value, value)} placeholder="VAR_NAME" className="form-input" style={{ flex: 1 }} />
|
||||
<input type="text" value={value} onChange={(e) => onUpdateEnvVar(key, key, e.target.value)} placeholder="value" className="form-input" style={{ flex: 1 }} />
|
||||
<div key={idx} className="form-row row-sm mb-2">
|
||||
<input type="text" value={key} onChange={(e) => onUpdateEnvVar(key, e.target.value, value)} placeholder="VAR_NAME" className="form-input" />
|
||||
<input type="text" value={value} onChange={(e) => onUpdateEnvVar(key, key, e.target.value)} placeholder="value" className="form-input" />
|
||||
<button type="button" className="ghost-button small" onClick={() => onRemoveEnvVar(key)}><Icon name="delete" size="sm" /></button>
|
||||
</div>
|
||||
))}
|
||||
@@ -229,47 +231,47 @@ export const ConfigProfileEditorPanel = ({
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h4 style={{ margin: "0 0 0.75rem 0" }}>Runtime Hints</h4>
|
||||
<textarea value={JSON.stringify(formData.runtime_hints || {}, null, 2)} onChange={(e) => { try { const parsed = JSON.parse(e.target.value); onFormChange("runtime_hints", parsed); } catch { /* ignore */ } }} placeholder='{"start_command": "npm start"}' rows={4} className="form-input" style={{ fontFamily: "monospace", fontSize: "0.875rem" }} />
|
||||
<h4 className="m-0 mb-3">Runtime Hints</h4>
|
||||
<textarea value={JSON.stringify(formData.runtime_hints || {}, null, 2)} onChange={(e) => { try { const parsed = JSON.parse(e.target.value); onFormChange("runtime_hints", parsed); } catch { /* ignore */ } }} placeholder='{"start_command": "npm start"}' rows={4} className="form-input font-mono text-sm" />
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h4 style={{ margin: "0 0 0.5rem 0" }}>Files</h4>
|
||||
<p className="muted" style={{ margin: "0 0 0.75rem 0", fontSize: "0.875rem" }}>Relative paths written to the instance directory. Use Mounts below for absolute container paths.</p>
|
||||
<h4 className="m-0 mb-2">Files</h4>
|
||||
<p className="muted text-sm m-0 mb-3">Relative paths written to the instance directory. Use Mounts below for absolute container paths.</p>
|
||||
{Object.entries(formData.files || {}).map(([path, content], idx) => (
|
||||
<div key={idx} className="card" style={{ padding: "0.75rem", marginBottom: "0.5rem" }}>
|
||||
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "0.5rem" }}>
|
||||
<input type="text" value={path} onChange={(e) => onUpdateFile(path, e.target.value, content)} placeholder="relative/path/to/file" className="form-input" style={{ flex: 1 }} />
|
||||
<div key={idx} className="card-sm mb-2">
|
||||
<div className="form-row row-sm mb-2">
|
||||
<input type="text" value={path} onChange={(e) => onUpdateFile(path, e.target.value, content)} placeholder="relative/path/to/file" className="form-input" />
|
||||
<button type="button" className="ghost-button small" onClick={() => onRemoveFile(path)}><Icon name="delete" size="sm" /></button>
|
||||
</div>
|
||||
<textarea value={content} onChange={(e) => onUpdateFile(path, path, e.target.value)} placeholder="File content" rows={3} className="form-input" style={{ fontFamily: "monospace", fontSize: "0.875rem" }} />
|
||||
<textarea value={content} onChange={(e) => onUpdateFile(path, path, e.target.value)} placeholder="File content" rows={3} className="form-input font-mono text-sm" />
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="secondary-button" onClick={onAddFile}><Icon name="add" size="sm" /> Add File</button>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h4 style={{ margin: "0 0 0.5rem 0" }}>Mounts</h4>
|
||||
<p className="muted" style={{ margin: "0 0 0.75rem 0", fontSize: "0.875rem" }}>Bind directories into the container at absolute paths. Files are relative to the mount target.</p>
|
||||
<h4 className="m-0 mb-2">Mounts</h4>
|
||||
<p className="muted text-sm m-0 mb-3">Bind directories into the container at absolute paths. Files are relative to the mount target.</p>
|
||||
{(formData.mounts || []).map((mount, index) => (
|
||||
<div key={index} className="card" style={{ padding: "1rem", marginBottom: "0.75rem" }}>
|
||||
<div className="form-row" style={{ gap: "0.5rem", marginBottom: "0.75rem" }}>
|
||||
<input type="text" value={mount.target} onChange={(e) => onUpdateMount(index, { target: e.target.value })} placeholder="/target/path" className="form-input" style={{ flex: 1 }} />
|
||||
<div key={index} className="card-sm mb-3">
|
||||
<div className="form-row row-sm mb-3">
|
||||
<input type="text" value={mount.target} onChange={(e) => onUpdateMount(index, { target: e.target.value })} placeholder="/target/path" className="form-input" />
|
||||
<select value={mount.mode} onChange={(e) => onUpdateMount(index, { mode: e.target.value as "ro" | "rw" })} className="form-input" style={{ width: "120px" }}>
|
||||
<option value="rw">Read/Write</option>
|
||||
<option value="ro">Read-Only</option>
|
||||
</select>
|
||||
<button type="button" className="ghost-button small" onClick={() => onRemoveMount(index)}><Icon name="delete" size="sm" /></button>
|
||||
</div>
|
||||
<div style={{ marginLeft: "1rem" }}>
|
||||
<div className="pl-4 border-l-2 stack stack-sm">
|
||||
{Object.entries(mount.files).map(([path, content], idx) => (
|
||||
<div key={idx} style={{ display: "flex", gap: "0.5rem", marginBottom: "0.5rem" }}>
|
||||
<input type="text" value={path} onChange={(e) => onUpdateMountFile(index, path, e.target.value, content)} placeholder="relative/path" className="form-input" style={{ flex: 1 }} />
|
||||
<textarea value={content} onChange={(e) => onUpdateMountFile(index, path, path, e.target.value)} placeholder="File content" rows={2} className="form-input" style={{ flex: 2, fontFamily: "monospace", fontSize: "0.875rem" }} />
|
||||
<div key={idx} className="form-row row-sm">
|
||||
<input type="text" value={path} onChange={(e) => onUpdateMountFile(index, path, e.target.value, content)} placeholder="relative/path" className="form-input" />
|
||||
<textarea value={content} onChange={(e) => onUpdateMountFile(index, path, path, e.target.value)} placeholder="File content" rows={2} className="form-input font-mono text-sm" />
|
||||
<button type="button" className="ghost-button small" onClick={() => onRemoveMountFile(index, path)}><Icon name="delete" size="sm" /></button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="secondary-button small" onClick={() => onAddMountFile(index)} style={{ fontSize: "0.875rem" }}><Icon name="add" size="sm" /> Add File to Mount</button>
|
||||
<button type="button" className="secondary-button small" onClick={() => onAddMountFile(index)}><Icon name="add" size="sm" /> Add File to Mount</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -280,21 +282,21 @@ export const ConfigProfileEditorPanel = ({
|
||||
<GitMountEditor mounts={formData.git_mounts || []} onChange={(git_mounts) => onFormChange("git_mounts", git_mounts)} />
|
||||
</div>
|
||||
|
||||
<div className="dialog-actions" style={{ marginTop: "1rem", position: "sticky", bottom: "1rem", background: "var(--surface)", padding: "1rem", borderRadius: "0.5rem", border: "1px solid var(--border)" }}>
|
||||
<button type="submit" disabled={saveStatus === "saving"}>
|
||||
<div className="dialog-actions sticky-footer">
|
||||
<button type="submit" disabled={saveStatus === "saving"} className="btn btn-primary">
|
||||
<Icon name={isCreating ? "add" : "save"} size="sm" />
|
||||
{saveStatus === "saving" ? "Saving..." : isCreating ? "Create Profile" : "Save Changes"}
|
||||
</button>
|
||||
{(isCreating || saveStatus !== "idle") && (
|
||||
<button type="button" onClick={onReset} className="button-secondary"><Icon name="cancel" size="sm" /> Discard</button>
|
||||
<button type="button" onClick={onReset} className="btn btn-secondary"><Icon name="cancel" size="sm" /> Discard</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{previewData && (
|
||||
<div className="card stack" style={{ marginTop: "2rem", padding: "1rem" }}>
|
||||
<div className="card stack stack-md mt-6">
|
||||
<h3>Resolved Profile Preview</h3>
|
||||
<pre style={{ overflow: "auto", maxHeight: "400px", fontSize: "0.8125rem" }}>{JSON.stringify(previewData, null, 2)}</pre>
|
||||
<pre className="code-block" style={{ maxHeight: "400px" }}>{JSON.stringify(previewData, null, 2)}</pre>
|
||||
<button className="secondary-button" onClick={onClosePreview}>Close Preview</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user