Compare commits

..

4 Commits

Author SHA1 Message Date
Developer 4f4939406f docs(openspec): note push pending status in pass 2 report 2026-06-16 15:03:54 +00:00
Developer f47cf27720 feat(web/ui): merge pass 2 component cleanup into dev 2026-06-16 14:48:37 +00:00
Developer 6559c2a1d9 docs(openspec): add pass 2 spec, tasks, and completion report 2026-06-16 14:48:30 +00:00
Developer 5d5b39bec1 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.
2026-06-16 14:47:37 +00:00
11 changed files with 1614 additions and 571 deletions
@@ -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>
)}
@@ -17,88 +17,38 @@ export const ConfigProfileListSidebar = ({
onDelete,
}: Props) => {
return (
<div
style={{
width: "280px",
minWidth: "280px",
borderRight: "1px solid var(--border)",
display: "flex",
flexDirection: "column",
background: "var(--panel)",
}}
>
<div style={{ padding: "1rem", borderBottom: "1px solid var(--border)" }}>
<h2 style={{ margin: 0, fontSize: "1.125rem" }}>Config Profiles</h2>
<p className="muted" style={{ margin: "0.25rem 0 0 0", fontSize: "0.875rem" }}>
<div className="sidebar">
<div className="sidebar-header">
<h2>Config Profiles</h2>
<p className="muted">
{profiles.length} profile{profiles.length !== 1 ? "s" : ""}
</p>
</div>
<div style={{ flex: 1, overflowY: "auto", padding: "0.5rem" }}>
<div className="sidebar-list">
{profiles.map((profile) => (
<button
key={profile.id}
onClick={() => onSelect(profile)}
style={{
width: "100%",
textAlign: "left",
padding: "0.75rem 1rem",
marginBottom: "0.25rem",
borderRadius: "0.375rem",
border: "none",
background: selectedProfileId === profile.id ? "var(--brand)" : "transparent",
color: selectedProfileId === profile.id ? "white" : "var(--ink)",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "0.75rem",
transition: "background 0.15s",
}}
onMouseEnter={(e) => {
if (selectedProfileId !== profile.id) {
e.currentTarget.style.background = "#ece7df";
}
}}
onMouseLeave={(e) => {
if (selectedProfileId !== profile.id) {
e.currentTarget.style.background = "transparent";
}
}}
className={
selectedProfileId === profile.id
? "sidebar-item sidebar-item-active"
: "sidebar-item"
}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontWeight: 600,
fontSize: "0.9375rem",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
<div className="sidebar-item-content">
<div className="sidebar-item-title">
{profile.name}
{profile.is_default && (
<span className="badge badge-secondary" style={{ marginLeft: "0.5rem" }}>
default
</span>
<span className="badge badge-secondary ml-2">default</span>
)}
{profile.includes?.length > 0 && (
<span
className="badge"
style={{
marginLeft: "0.5rem",
background:
selectedProfileId === profile.id
? "rgba(255,255,255,0.2)"
: undefined,
}}
>
<span className="badge ml-2">
{profile.includes.length} include{profile.includes.length !== 1 ? "s" : ""}
</span>
)}
</div>
<div
style={{ fontSize: "0.8125rem", opacity: 0.8, marginTop: "0.125rem" }}
>
<div className="sidebar-item-subtitle">
{profile.project_id && "Project scoped"}
{profile.tool_type_id && (profile.project_id ? " + Tool scoped" : "Tool scoped")}
{!profile.project_id && !profile.tool_type_id && "Global"}
@@ -109,16 +59,6 @@ export const ConfigProfileListSidebar = ({
e.stopPropagation();
onDelete(profile.id);
}}
style={{
background: "none",
border: "none",
color: selectedProfileId === profile.id ? "rgba(255,255,255,0.8)" : "var(--muted)",
cursor: "pointer",
padding: "0.25rem",
borderRadius: "0.25rem",
flexShrink: 0,
opacity: 0,
}}
className="delete-btn"
title="Delete profile"
>
@@ -128,33 +68,8 @@ export const ConfigProfileListSidebar = ({
))}
</div>
<div style={{ padding: "1rem", borderTop: "1px solid var(--border)" }}>
<button
onClick={onCreate}
style={{
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "0.5rem",
padding: "0.75rem",
borderRadius: "0.5rem",
border: "2px dashed var(--border)",
background: "transparent",
color: "var(--muted)",
cursor: "pointer",
fontWeight: 600,
transition: "all 0.15s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "var(--brand)";
e.currentTarget.style.color = "var(--brand)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "var(--border)";
e.currentTarget.style.color = "var(--muted)";
}}
>
<div className="sidebar-footer">
<button onClick={onCreate} className="sidebar-create-button">
<Icon name="add" size="sm" /> New Profile
</button>
</div>
@@ -70,26 +70,15 @@ export const GitMountEditor = ({
return (
<div className="git-mount-editor">
<h4 style={{ margin: "0 0 0.75rem 0" }}>Git Mounts</h4>
<p
className="muted"
style={{ margin: "0 0 0.75rem 0", fontSize: "0.875rem" }}
>
<h4 className="m-0 mb-2">Git Mounts</h4>
<p className="muted text-sm mb-3 m-0">
Clone a repository once and mount multiple directories from it.
</p>
{normalizedMounts.length > 0 && (
<div
className="git-mount-list"
style={{
display: "flex",
flexDirection: "column",
gap: "0.75rem",
marginBottom: "1rem",
}}
>
<div className="git-mount-list mb-4">
{normalizedMounts.map((mount, index) => (
<div key={index} className="card" style={{ padding: "1rem" }}>
<div key={index} className="card-md">
{editingIndex === index ? (
<GitMountForm
mount={mount}
@@ -98,59 +87,28 @@ export const GitMountEditor = ({
/>
) : (
<div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
marginBottom: "0.5rem",
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontWeight: 600,
fontSize: "0.9375rem",
marginBottom: "0.25rem",
}}
>
<div className="row justify-between items-start mb-2">
<div className="min-w-0 flex-1">
<div className="font-semibold text-base mb-1">
{mount.remote_url}
{mount.branch && (
<span
style={{
color: "var(--muted)",
fontWeight: 400,
marginLeft: "0.5rem",
}}
>
<span className="muted font-normal ml-2">
@{mount.branch}
</span>
)}
</div>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "0.25rem",
}}
>
<div className="stack stack-sm">
{mount.mappings?.map((m, mi) => (
<div
key={mi}
style={{
fontSize: "0.875rem",
color: "var(--muted)",
fontFamily: "monospace",
}}
className="text-sm muted font-mono"
>
{m.source_path || "."} {m.target_path}
</div>
))}
</div>
</div>
<div
style={{ display: "flex", gap: "0.25rem", flexShrink: 0 }}
>
<div className="row row-sm flex-none">
<button
type="button"
className="ghost-button small"
@@ -177,7 +135,7 @@ export const GitMountEditor = ({
)}
{isAdding ? (
<div className="card" style={{ padding: "1rem" }}>
<div className="card-md">
<GitMountForm
mount={{
remote_url: "",
@@ -359,16 +317,11 @@ const GitMountForm = ({
};
return (
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
<div
className="form-row"
style={{ gap: "0.5rem", alignItems: "flex-start" }}
>
<div style={{ flex: 2 }}>
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
Repository URL
</label>
<div style={{ display: "flex", gap: "0.5rem" }}>
<div className="stack stack-md">
<div className="form-row row-sm items-start">
<div className="form-group flex-2">
<label className="text-sm font-medium">Repository URL</label>
<div className="row row-sm">
<input
type="text"
value={remoteUrl}
@@ -385,7 +338,6 @@ const GitMountForm = ({
}}
placeholder="https://github.com/user/repo.git"
className={`form-input ${errors.remote_url ? "error" : ""}`}
style={{ flex: 1 }}
/>
<button
type="button"
@@ -401,7 +353,7 @@ const GitMountForm = ({
</button>
</div>
{errors.remote_url && (
<span className="error-text">{errors.remote_url}</span>
<span className="text-error">{errors.remote_url}</span>
)}
{validation.status === "valid" && (
<span className="validation-status valid">
@@ -409,7 +361,7 @@ const GitMountForm = ({
{
(validation as Extract<ValidationState, { status: "valid" }>)
.branches.length
}{" "}
}{" "}
branches)
</span>
)}
@@ -434,10 +386,8 @@ const GitMountForm = ({
</span>
)}
</div>
<div style={{ flex: 1 }}>
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
Branch
</label>
<div className="form-group flex-1">
<label className="text-sm font-medium">Branch</label>
{validation.status === "valid" ? (
<select
value={branch}
@@ -466,18 +416,14 @@ const GitMountForm = ({
</div>
<div
className="stack stack-sm"
style={{
opacity: isUrlValidated ? 1 : 0.5,
pointerEvents: isUrlValidated ? "auto" : "none",
}}
>
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
Mappings
</label>
<p
className="muted"
style={{ margin: "0 0 0.5rem 0", fontSize: "0.8125rem" }}
>
<label className="text-sm font-medium">Mappings</label>
<p className="muted text-sm m-0">
Source paths within the repo and where to mount them in the container.
{!isUrlValidated && (
<span style={{ color: "var(--warning)" }}>
@@ -486,14 +432,11 @@ const GitMountForm = ({
</span>
)}
</p>
<div
style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}
>
<div className="stack stack-sm">
{mappings.map((mapping, index) => (
<div
key={index}
className="form-row"
style={{ gap: "0.5rem", alignItems: "flex-start" }}
className="form-row row-sm items-start"
>
<input
type="text"
@@ -503,17 +446,8 @@ const GitMountForm = ({
}
placeholder="packages/api"
className={`form-input ${errors[`mapping_${index}_source`] ? "error" : ""}`}
style={{ flex: 1 }}
/>
<span
style={{
padding: "0.5rem 0",
color: "var(--muted)",
fontSize: "0.875rem",
}}
>
</span>
<span className="text-sm muted py-2"></span>
<input
type="text"
value={mapping.target_path}
@@ -522,7 +456,6 @@ const GitMountForm = ({
}
placeholder="/app/api"
className={`form-input ${errors[`mapping_${index}_target`] ? "error" : ""}`}
style={{ flex: 1 }}
/>
{mappings.length > 1 && (
<button
@@ -535,12 +468,12 @@ const GitMountForm = ({
</button>
)}
{errors[`mapping_${index}_source`] && (
<span className="error-text">
<span className="text-error">
{errors[`mapping_${index}_source`]}
</span>
)}
{errors[`mapping_${index}_target`] && (
<span className="error-text">
<span className="text-error">
{errors[`mapping_${index}_target`]}
</span>
)}
@@ -551,17 +484,13 @@ const GitMountForm = ({
type="button"
className="secondary-button small"
onClick={addMapping}
style={{ marginTop: "0.5rem" }}
>
<Icon name="add" size="sm" />
Add Mapping
</button>
</div>
<div
className="form-actions"
style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}
>
<div className="form-actions row-sm mt-2">
<button type="button" className="primary-button" onClick={handleSubmit}>
Save
</button>
@@ -53,35 +53,25 @@ export const ToolTypeEditorPanel = ({
const hasSelection = isCreating || selectedToolType;
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="code" size="lg" />
</div>
<h3 style={{ margin: "0 0 0.5rem 0", fontWeight: 500 }}>
Select a tool type
</h3>
<p style={{ margin: 0 }}>
Choose a tool from the list to edit, or create a new one.
</p>
<h3>Select a tool type</h3>
<p>Choose a tool from the list to edit, or create a new one.</p>
</div>
) : (
<div>
<div style={{ marginBottom: "1.5rem" }}>
<h1 style={{ margin: "0 0 0.5rem 0", fontSize: "1.5rem" }}>
<div className="max-w-form">
<div className="mb-5">
<h1 className="m-0 mb-2 text-xl">
{isCreating
? "Create Tool Type"
: selectedToolType?.display_name}
</h1>
{!isCreating && (
<p className="muted" style={{ margin: 0 }}>
<p className="muted m-0">
{selectedToolType?.name} · {selectedToolType?.definition_type}{" "}
·{" "}
{selectedToolType?.interface_type === "web"
@@ -91,11 +81,7 @@ export const ToolTypeEditorPanel = ({
)}
</div>
<form
onSubmit={onSubmit}
className="stack"
style={{ gap: "1rem", maxWidth: "800px" }}
>
<form onSubmit={onSubmit} className="stack stack-md">
<div className="form-group">
<label htmlFor="definition-type">Definition Type</label>
<select
@@ -118,8 +104,8 @@ export const ToolTypeEditorPanel = ({
</select>
</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="tool-type-name">Name *</label>
<input
id="tool-type-name"
@@ -134,7 +120,7 @@ export const ToolTypeEditorPanel = ({
required
/>
</div>
<div className="form-group" style={{ flex: 1 }}>
<div className="form-group">
<label htmlFor="tool-type-display-name">
Display Name *
</label>
@@ -166,8 +152,8 @@ export const ToolTypeEditorPanel = ({
/>
</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="tool-type-category">Category</label>
<input
id="tool-type-category"
@@ -180,7 +166,7 @@ export const ToolTypeEditorPanel = ({
className="form-input"
/>
</div>
<div className="form-group" style={{ flex: 1 }}>
<div className="form-group">
<label htmlFor="tool-type-interface">Interface Type</label>
<select
id="tool-type-interface"
@@ -280,11 +266,7 @@ export const ToolTypeEditorPanel = ({
? "version: '3.8'\nservices:\n app:\n image: ..."
: "FROM node:18\nWORKDIR /app\n..."
}
className="form-input"
style={{
fontFamily: "monospace",
fontSize: "0.875rem",
}}
className="form-input font-mono text-sm"
required
/>
</div>
@@ -306,8 +288,8 @@ export const ToolTypeEditorPanel = ({
/>
</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="readiness-timeout">Timeout (seconds)</label>
<input
id="readiness-timeout"
@@ -319,7 +301,7 @@ export const ToolTypeEditorPanel = ({
className="form-input"
/>
</div>
<div className="form-group" style={{ flex: 1 }}>
<div className="form-group">
<label htmlFor="readiness-interval">
Interval (seconds)
</label>
@@ -350,8 +332,8 @@ export const ToolTypeEditorPanel = ({
{toolTypeError && <p className="text-error">{toolTypeError}</p>}
<div className="dialog-actions" style={{ marginTop: "1rem" }}>
<button type="submit">
<div className="dialog-actions mt-4">
<button type="submit" className="btn btn-primary">
<Icon name={isCreating ? "add" : "save"} size="sm" />
{isCreating ? "Create Tool Type" : "Save Changes"}
</button>
@@ -359,7 +341,7 @@ export const ToolTypeEditorPanel = ({
<button
type="button"
onClick={onReset}
className="button-secondary"
className="btn btn-secondary"
>
<Icon name="cancel" size="sm" /> Discard
</button>
@@ -17,82 +17,28 @@ export const ToolTypeListSidebar = ({
onDelete,
}: ToolTypeListSidebarProps) => {
return (
<div
style={{
width: "280px",
minWidth: "280px",
borderRight: "1px solid var(--border)",
display: "flex",
flexDirection: "column",
background: "var(--panel)",
}}
>
<div
style={{ padding: "1rem", borderBottom: "1px solid var(--border)" }}
>
<h2 style={{ margin: 0, fontSize: "1.125rem" }}>Tool Workshop</h2>
<p
className="muted"
style={{ margin: "0.25rem 0 0 0", fontSize: "0.875rem" }}
>
<div className="sidebar">
<div className="sidebar-header">
<h2>Tool Workshop</h2>
<p className="muted">
{toolTypes.length} tool type{toolTypes.length !== 1 ? "s" : ""}
</p>
</div>
<div style={{ flex: 1, overflowY: "auto", padding: "0.5rem" }}>
<div className="sidebar-list">
{toolTypes.map((toolType) => (
<button
key={toolType.id}
onClick={() => onSelect(toolType)}
style={{
width: "100%",
textAlign: "left",
padding: "0.75rem 1rem",
marginBottom: "0.25rem",
borderRadius: "0.375rem",
border: "none",
background:
selectedToolTypeId === toolType.id
? "var(--brand)"
: "transparent",
color:
selectedToolTypeId === toolType.id ? "white" : "var(--ink)",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "0.75rem",
transition: "background 0.15s",
}}
onMouseEnter={(e) => {
if (selectedToolTypeId !== toolType.id) {
e.currentTarget.style.background = "#ece7df";
}
}}
onMouseLeave={(e) => {
if (selectedToolTypeId !== toolType.id) {
e.currentTarget.style.background = "transparent";
}
}}
className={
selectedToolTypeId === toolType.id
? "sidebar-item sidebar-item-active"
: "sidebar-item"
}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontWeight: 600,
fontSize: "0.9375rem",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{toolType.display_name}
</div>
<div
style={{
fontSize: "0.8125rem",
opacity: 0.8,
marginTop: "0.125rem",
}}
>
<div className="sidebar-item-content">
<div className="sidebar-item-title">{toolType.display_name}</div>
<div className="sidebar-item-subtitle">
{toolType.category || "Uncategorized"} ·{" "}
{toolType.interface_type === "web"
? `Port ${toolType.default_port}`
@@ -104,19 +50,6 @@ export const ToolTypeListSidebar = ({
e.stopPropagation();
onDelete(toolType.id);
}}
style={{
background: "none",
border: "none",
color:
selectedToolTypeId === toolType.id
? "rgba(255,255,255,0.8)"
: "var(--muted)",
cursor: "pointer",
padding: "0.25rem",
borderRadius: "0.25rem",
flexShrink: 0,
opacity: 0,
}}
className="delete-btn"
title="Delete tool type"
>
@@ -126,33 +59,8 @@ export const ToolTypeListSidebar = ({
))}
</div>
<div style={{ padding: "1rem", borderTop: "1px solid var(--border)" }}>
<button
onClick={onCreate}
style={{
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "0.5rem",
padding: "0.75rem",
borderRadius: "0.5rem",
border: "2px dashed var(--border)",
background: "transparent",
color: "var(--muted)",
cursor: "pointer",
fontWeight: 600,
transition: "all 0.15s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "var(--brand)";
e.currentTarget.style.color = "var(--brand)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "var(--border)";
e.currentTarget.style.color = "var(--muted)";
}}
>
<div className="sidebar-footer">
<button onClick={onCreate} className="sidebar-create-button">
<Icon name="add" size="sm" /> New Tool Type
</button>
</div>
@@ -284,12 +284,12 @@ export const ManifestEditor = ({
};
return (
<div className="stack" style={{ gap: "1.5rem" }}>
<div className="stack stack-lg">
{/* Base Image */}
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
<h4 style={{ margin: 0 }}>Base Image</h4>
<div className="row" style={{ gap: "1rem" }}>
<div className="form-group" style={{ flex: 1 }}>
<div className="card-md stack stack-md">
<h4 className="m-0">Base Image</h4>
<div className="form-row row-md">
<div className="form-group">
<label>Base Definition</label>
<select
value={baseDefinitionId}
@@ -307,7 +307,7 @@ export const ManifestEditor = ({
))}
</select>
</div>
<div className="form-group" style={{ flex: 1 }}>
<div className="form-group">
<label>Custom Base Image</label>
<input
type="text"
@@ -325,8 +325,8 @@ export const ManifestEditor = ({
</div>
{/* Packages */}
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
<h4 style={{ margin: 0 }}>Packages</h4>
<div className="card-md stack stack-md">
<h4 className="m-0">Packages</h4>
<div className="form-group">
<label>Node.js Version</label>
@@ -342,9 +342,9 @@ export const ManifestEditor = ({
<div>
<label>APT Packages</label>
<div className="stack" style={{ gap: "0.5rem", marginTop: "0.5rem" }}>
<div className="stack stack-sm mt-2">
{aptPackages.map((pkg, idx) => (
<div key={idx} className="row" style={{ gap: "0.5rem" }}>
<div key={idx} className="form-row row-sm">
<input
type="text"
value={pkg.name}
@@ -365,8 +365,7 @@ export const ManifestEditor = ({
<button
type="button"
onClick={addAptPackage}
className="button-secondary"
style={{ width: "fit-content" }}
className="button-secondary w-fit"
>
<Icon name="add" size="sm" /> Add APT Package
</button>
@@ -375,9 +374,9 @@ export const ManifestEditor = ({
<div>
<label>NPM Global Packages</label>
<div className="stack" style={{ gap: "0.5rem", marginTop: "0.5rem" }}>
<div className="stack stack-sm mt-2">
{npmPackages.map((pkg, idx) => (
<div key={idx} className="row" style={{ gap: "0.5rem" }}>
<div key={idx} className="form-row row-sm">
<input
type="text"
value={pkg.name}
@@ -398,8 +397,7 @@ export const ManifestEditor = ({
<button
type="button"
onClick={addNpmPackage}
className="button-secondary"
style={{ width: "fit-content" }}
className="button-secondary w-fit"
>
<Icon name="add" size="sm" /> Add NPM Package
</button>
@@ -408,9 +406,9 @@ export const ManifestEditor = ({
<div>
<label>Pip Packages</label>
<div className="stack" style={{ gap: "0.5rem", marginTop: "0.5rem" }}>
<div className="stack stack-sm mt-2">
{pipPackages.map((pkg, idx) => (
<div key={idx} className="row" style={{ gap: "0.5rem" }}>
<div key={idx} className="form-row row-sm">
<input
type="text"
value={pkg.name}
@@ -431,8 +429,7 @@ export const ManifestEditor = ({
<button
type="button"
onClick={addPipPackage}
className="button-secondary"
style={{ width: "fit-content" }}
className="button-secondary w-fit"
>
<Icon name="add" size="sm" /> Add Pip Package
</button>
@@ -441,10 +438,10 @@ export const ManifestEditor = ({
</div>
{/* Runtime User */}
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
<h4 style={{ margin: 0 }}>Runtime User</h4>
<div className="row" style={{ gap: "1rem" }}>
<div className="form-group" style={{ flex: 1 }}>
<div className="card-md stack stack-md">
<h4 className="m-0">Runtime User</h4>
<div className="form-row row-md">
<div className="form-group">
<label>User Name</label>
<input
type="text"
@@ -453,7 +450,7 @@ export const ManifestEditor = ({
className="form-input"
/>
</div>
<div className="form-group" style={{ flex: 1 }}>
<div className="form-group">
<label>UID</label>
<input
type="number"
@@ -462,7 +459,7 @@ export const ManifestEditor = ({
className="form-input"
/>
</div>
<div className="form-group" style={{ flex: 1 }}>
<div className="form-group">
<label>GID</label>
<input
type="number"
@@ -475,11 +472,11 @@ export const ManifestEditor = ({
</div>
{/* Environment */}
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
<h4 style={{ margin: 0 }}>Environment Variables</h4>
<div className="stack" style={{ gap: "0.5rem" }}>
<div className="card-md stack stack-md">
<h4 className="m-0">Environment Variables</h4>
<div className="stack stack-sm">
{envVars.map((ev, idx) => (
<div key={idx} className="row" style={{ gap: "0.5rem" }}>
<div key={idx} className="form-row row-sm">
<input
type="text"
value={ev.key}
@@ -507,8 +504,7 @@ export const ManifestEditor = ({
<button
type="button"
onClick={addEnvVar}
className="button-secondary"
style={{ width: "fit-content" }}
className="button-secondary w-fit"
>
<Icon name="add" size="sm" /> Add Env Var
</button>
@@ -516,26 +512,20 @@ export const ManifestEditor = ({
</div>
{/* Build Scripts */}
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
<h4 style={{ margin: 0 }}>Build Scripts (run during docker build)</h4>
<div className="stack" style={{ gap: "0.5rem" }}>
<div className="card-md stack stack-md">
<h4 className="m-0">Build Scripts (run during docker build)</h4>
<div className="stack stack-sm">
{buildScripts.map((script, idx) => (
<div
key={idx}
className="row"
style={{ gap: "0.5rem", alignItems: "flex-start" }}
className="form-row row-sm items-start"
>
<textarea
value={script}
onChange={(e) => updateBuildScript(idx, e.target.value)}
placeholder="git config --global user.email 'dev@example.com'"
className="form-input"
className="form-input font-mono text-sm flex-1"
rows={2}
style={{
fontFamily: "monospace",
fontSize: "0.8125rem",
flex: 1,
}}
/>
<button
type="button"
@@ -550,8 +540,7 @@ export const ManifestEditor = ({
<button
type="button"
onClick={addBuildScript}
className="button-secondary"
style={{ width: "fit-content" }}
className="button-secondary w-fit"
>
<Icon name="add" size="sm" /> Add Build Script
</button>
@@ -559,28 +548,22 @@ export const ManifestEditor = ({
</div>
{/* Startup Scripts */}
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
<h4 style={{ margin: 0 }}>
<div className="card-md stack stack-md">
<h4 className="m-0">
Startup Scripts (run when container starts)
</h4>
<div className="stack" style={{ gap: "0.5rem" }}>
<div className="stack stack-sm">
{startupScripts.map((script, idx) => (
<div
key={idx}
className="row"
style={{ gap: "0.5rem", alignItems: "flex-start" }}
className="form-row row-sm items-start"
>
<textarea
value={script}
onChange={(e) => updateStartupScript(idx, e.target.value)}
placeholder="chown -R user:user /workspace"
className="form-input"
className="form-input font-mono text-sm flex-1"
rows={2}
style={{
fontFamily: "monospace",
fontSize: "0.8125rem",
flex: 1,
}}
/>
<button
type="button"
@@ -595,8 +578,7 @@ export const ManifestEditor = ({
<button
type="button"
onClick={addStartupScript}
className="button-secondary"
style={{ width: "fit-content" }}
className="button-secondary w-fit"
>
<Icon name="add" size="sm" /> Add Startup Script
</button>
@@ -604,21 +586,15 @@ export const ManifestEditor = ({
</div>
{/* Mounts */}
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
<h4 style={{ margin: 0 }}>Mount Schema</h4>
<div className="stack" style={{ gap: "1rem" }}>
<div className="card-md stack stack-md">
<h4 className="m-0">Mount Schema</h4>
<div className="stack stack-md">
{mounts.map((mount, idx) => (
<div
key={idx}
className="stack"
style={{
gap: "0.5rem",
padding: "0.75rem",
border: "1px solid var(--border)",
borderRadius: "0.375rem",
}}
className="stack stack-sm p-3 border rounded-md"
>
<div className="row" style={{ gap: "0.5rem" }}>
<div className="form-row row-sm">
<input
type="text"
value={mount.name}
@@ -655,15 +631,8 @@ export const ManifestEditor = ({
<Icon name="delete" size="sm" />
</button>
</div>
<div className="row" style={{ gap: "0.5rem" }}>
<label
style={{
display: "flex",
alignItems: "center",
gap: "0.25rem",
fontSize: "0.875rem",
}}
>
<div className="form-row row-sm flex-wrap">
<label className="checkbox-label text-sm min-h-0">
<input
type="checkbox"
checked={mount.writable}
@@ -673,14 +642,7 @@ export const ManifestEditor = ({
/>
Writable
</label>
<label
style={{
display: "flex",
alignItems: "center",
gap: "0.25rem",
fontSize: "0.875rem",
}}
>
<label className="checkbox-label text-sm min-h-0">
<input
type="checkbox"
checked={mount.readonly}
@@ -734,8 +696,7 @@ export const ManifestEditor = ({
<button
type="button"
onClick={addMount}
className="button-secondary"
style={{ width: "fit-content" }}
className="button-secondary w-fit"
>
<Icon name="add" size="sm" /> Add Mount
</button>
@@ -743,10 +704,10 @@ export const ManifestEditor = ({
</div>
{/* Runtime */}
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
<h4 style={{ margin: 0 }}>Runtime</h4>
<div className="row" style={{ gap: "1rem" }}>
<div className="form-group" style={{ flex: 1 }}>
<div className="card-md stack stack-md">
<h4 className="m-0">Runtime</h4>
<div className="form-row row-md">
<div className="form-group">
<label>Command</label>
<input
type="text"
@@ -756,7 +717,7 @@ export const ManifestEditor = ({
className="form-input"
/>
</div>
<div className="form-group" style={{ flex: 1 }}>
<div className="form-group">
<label>Working Directory</label>
<input
type="text"
@@ -767,10 +728,8 @@ export const ManifestEditor = ({
/>
</div>
</div>
<div className="row" style={{ gap: "1rem" }}>
<label
style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}
>
<div className="form-row row-md">
<label className="checkbox-label min-h-0">
<input
type="checkbox"
checked={stdinOpen}
@@ -778,9 +737,7 @@ export const ManifestEditor = ({
/>
stdin_open
</label>
<label
style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}
>
<label className="checkbox-label min-h-0">
<input
type="checkbox"
checked={tty}
@@ -792,15 +749,9 @@ export const ManifestEditor = ({
</div>
{/* Preview */}
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<h4 style={{ margin: 0 }}>Live Preview</h4>
<div className="card-md stack stack-md">
<div className="row justify-between items-center">
<h4 className="m-0">Live Preview</h4>
<button
type="button"
onClick={handlePreview}
@@ -813,40 +764,16 @@ export const ManifestEditor = ({
</div>
{previewError && <p className="text-error">{previewError}</p>}
{preview && (
<div className="stack" style={{ gap: "1rem" }}>
<div className="stack stack-md">
<div>
<label style={{ fontWeight: 600, fontSize: "0.875rem" }}>
Dockerfile
</label>
<pre
style={{
background: "var(--code-bg, #1e1e1e)",
color: "var(--code-fg, #d4d4d4)",
padding: "1rem",
borderRadius: "0.375rem",
overflow: "auto",
fontSize: "0.8125rem",
maxHeight: "300px",
}}
>
<label className="font-semibold text-sm">Dockerfile</label>
<pre className="code-block" style={{ maxHeight: "300px" }}>
{preview.dockerfile}
</pre>
</div>
<div>
<label style={{ fontWeight: 600, fontSize: "0.875rem" }}>
Compose
</label>
<pre
style={{
background: "var(--code-bg, #1e1e1e)",
color: "var(--code-fg, #d4d4d4)",
padding: "1rem",
borderRadius: "0.375rem",
overflow: "auto",
fontSize: "0.8125rem",
maxHeight: "200px",
}}
>
<label className="font-semibold text-sm">Compose</label>
<pre className="code-block" style={{ maxHeight: "200px" }}>
{preview.compose}
</pre>
</div>
+181 -24
View File
@@ -428,21 +428,71 @@
margin: 0 0 1rem;
}
.form-field {
.form-field,
.form-group {
display: flex;
flex-direction: column;
gap: 0.35rem;
gap: var(--space-2);
margin-bottom: var(--space-4);
}
.form-field:last-child,
.form-group:last-child {
margin-bottom: 0;
}
.form-field input,
.form-field textarea,
.form-field select {
padding: 0.55rem 0.7rem;
.form-field select,
.form-group input,
.form-group textarea,
.form-group select {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border);
border-radius: 10px;
border-radius: var(--radius-md);
font: inherit;
background: var(--panel);
color: var(--ink);
width: 100%;
}
.form-row {
display: flex;
align-items: flex-start;
gap: var(--space-3);
}
.form-row .form-field,
.form-row .form-group {
flex: 1;
margin-bottom: 0;
}
.form-section {
padding: var(--space-4);
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius-md);
}
.form-section > h4:first-child,
.form-section > h3:first-child {
margin-top: 0;
margin-bottom: var(--space-3);
}
.form-help {
display: block;
font-size: var(--font-size-sm);
color: var(--muted);
margin-top: var(--space-1);
line-height: var(--line-height-normal);
}
.text-error {
color: var(--danger);
font-size: var(--font-size-sm);
line-height: var(--line-height-normal);
}
.dialog-actions {
@@ -494,19 +544,6 @@
flex: 1;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.form-group input {
padding: 0.55rem 0.7rem;
border: 1px solid var(--border);
border-radius: 10px;
font: inherit;
}
.error {
color: #b91c1c;
padding: 0.75rem;
@@ -1205,7 +1242,11 @@ a .icon:last-child {
}
}
/* ─── Primitive Buttons ─── */
.btn {
.btn,
.primary-button,
.secondary-button,
.ghost-button,
.button-secondary {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -1224,29 +1265,41 @@ a .icon:last-child {
transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
}
.btn:focus-visible {
.btn:focus-visible,
.primary-button:focus-visible,
.secondary-button:focus-visible,
.ghost-button:focus-visible,
.button-secondary:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
}
.btn-primary {
.btn-primary,
.primary-button {
background: var(--brand);
color: var(--primary-fg);
border-color: var(--brand);
}
.btn-primary:hover {
.btn-primary:hover,
.primary-button:hover {
background: var(--brand-strong);
border-color: var(--brand-strong);
}
.btn-secondary {
.btn-secondary,
.secondary-button,
.ghost-button,
.button-secondary {
background: var(--panel);
color: var(--ink);
border-color: var(--border);
}
.btn-secondary:hover {
.btn-secondary:hover,
.secondary-button:hover,
.ghost-button:hover,
.button-secondary:hover {
background: var(--bg);
}
@@ -1411,6 +1464,12 @@ a .icon:last-child {
color: var(--info);
}
.alert-success {
background: var(--success-light);
border-color: color-mix(in srgb, var(--success) 30%, transparent);
color: var(--success);
}
/* ─── Loading State ─── */
.loading-state {
display: flex;
@@ -1454,6 +1513,104 @@ a .icon:last-child {
color: var(--muted);
}
/* ─── Status Badges ─── */
.status-badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: 600;
text-transform: capitalize;
line-height: var(--line-height-tight);
background: var(--bg);
color: var(--muted);
border: 1px solid var(--border);
}
.status-badge::before {
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.status-badge--running,
.status-badge--success {
background: var(--success-light);
color: var(--success);
border-color: color-mix(in srgb, var(--success) 30%, transparent);
}
.status-badge--error,
.status-badge--failed {
background: var(--danger-light);
color: var(--danger);
border-color: color-mix(in srgb, var(--danger) 30%, transparent);
}
.status-badge--warning {
background: var(--warning-light);
color: var(--warning);
border-color: color-mix(in srgb, var(--warning) 30%, transparent);
}
.status-badge--info,
.status-badge--pending,
.status-badge--idle {
background: var(--info-light);
color: var(--info);
border-color: color-mix(in srgb, var(--info) 30%, transparent);
}
/* Legacy instance-chip alias */
.instance-chip {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: 600;
text-transform: capitalize;
line-height: var(--line-height-tight);
background: var(--bg);
color: var(--muted);
border: 1px solid var(--border);
}
.instance-chip::before {
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.instance-chip.running {
background: var(--success-light);
color: var(--success);
border-color: color-mix(in srgb, var(--success) 30%, transparent);
}
.instance-chip.starting,
.instance-chip.building,
.instance-chip.probing {
background: var(--warning-light);
color: var(--warning);
border-color: color-mix(in srgb, var(--warning) 30%, transparent);
}
.instance-chip.error,
.instance-chip.unhealthy,
.instance-chip.stopped {
background: var(--danger-light);
color: var(--danger);
border-color: color-mix(in srgb, var(--danger) 30%, transparent);
}
/* ─── Focus States ─── */
.nav-item:focus-visible,
.mobile-nav-item:focus-visible,
+570 -4
View File
@@ -13,7 +13,7 @@
.stack {
display: flex;
flex-direction: column;
gap: var(--space-4);
gap: var(--space-3);
}
.stack-sm {
@@ -21,20 +21,103 @@
}
.stack-md {
gap: var(--space-4);
gap: var(--space-3);
}
.stack-lg {
gap: var(--space-6);
gap: var(--space-4);
}
.stack-xl {
gap: var(--space-5);
}
.row {
display: flex;
flex-wrap: wrap;
gap: var(--space-4);
gap: var(--space-3);
align-items: center;
}
.row-sm {
gap: var(--space-2);
}
.row-md {
gap: var(--space-3);
}
.row-lg {
gap: var(--space-4);
}
.row.items-start,
.items-start {
align-items: flex-start;
}
.row.items-center,
.items-center {
align-items: center;
}
.row.items-end,
.items-end {
align-items: flex-end;
}
.justify-between {
justify-content: space-between;
}
.justify-center {
justify-content: center;
}
.justify-end {
justify-content: flex-end;
}
.flex-1 {
flex: 1;
}
.flex-2 {
flex: 2;
}
.flex-none {
flex: none;
}
.flex-wrap {
flex-wrap: wrap;
}
.min-h-0 {
min-height: 0;
}
.w-full {
width: 100%;
}
.w-fit {
width: fit-content;
}
.min-w-0 {
min-width: 0;
}
.max-w-prose {
max-width: 65ch;
}
.max-w-form {
max-width: 800px;
}
.grid {
display: grid;
gap: var(--space-4);
@@ -48,6 +131,286 @@
white-space: nowrap;
}
.text-xs {
font-size: var(--font-size-xs);
}
.text-sm {
font-size: var(--font-size-sm);
}
.text-base {
font-size: var(--font-size-base);
}
.text-lg {
font-size: var(--font-size-lg);
}
.text-xl {
font-size: var(--font-size-xl);
}
.font-mono {
font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.font-medium {
font-weight: 500;
}
.font-semibold {
font-weight: 600;
}
.text-center {
text-align: center;
}
.text-left {
text-align: left;
}
.text-right {
text-align: right;
}
/* Visual Utilities */
.cursor-pointer {
cursor: pointer;
}
.overflow-auto {
overflow: auto;
}
.overflow-hidden {
overflow: hidden;
}
.rounded-sm {
border-radius: var(--radius-sm);
}
.rounded-md {
border-radius: var(--radius-md);
}
.rounded-lg {
border-radius: var(--radius-lg);
}
.rounded-xl {
border-radius: var(--radius-xl);
}
.rounded-full {
border-radius: var(--radius-full);
}
/* Spacing Utilities */
.m-0 {
margin: 0;
}
.mt-1 {
margin-top: var(--space-1);
}
.mt-2 {
margin-top: var(--space-2);
}
.mt-3 {
margin-top: var(--space-3);
}
.mt-4 {
margin-top: var(--space-4);
}
.mt-5 {
margin-top: var(--space-5);
}
.mt-6 {
margin-top: var(--space-6);
}
.mb-0 {
margin-bottom: 0;
}
.mb-1 {
margin-bottom: var(--space-1);
}
.mb-2 {
margin-bottom: var(--space-2);
}
.mb-3 {
margin-bottom: var(--space-3);
}
.mb-4 {
margin-bottom: var(--space-4);
}
.mb-5 {
margin-bottom: var(--space-5);
}
.mb-6 {
margin-bottom: var(--space-6);
}
.ml-auto {
margin-left: auto;
}
.mr-auto {
margin-right: auto;
}
.ml-1 {
margin-left: var(--space-1);
}
.ml-2 {
margin-left: var(--space-2);
}
.ml-3 {
margin-left: var(--space-3);
}
.mr-1 {
margin-right: var(--space-1);
}
.mr-2 {
margin-right: var(--space-2);
}
.mr-3 {
margin-right: var(--space-3);
}
.p-1 {
padding: var(--space-1);
}
.p-2 {
padding: var(--space-2);
}
.p-3 {
padding: var(--space-3);
}
.p-4 {
padding: var(--space-4);
}
.p-5 {
padding: var(--space-5);
}
.px-2 {
padding-left: var(--space-2);
padding-right: var(--space-2);
}
.px-3 {
padding-left: var(--space-3);
padding-right: var(--space-3);
}
.px-4 {
padding-left: var(--space-4);
padding-right: var(--space-4);
}
.py-1 {
padding-top: var(--space-1);
padding-bottom: var(--space-1);
}
.py-2 {
padding-top: var(--space-2);
padding-bottom: var(--space-2);
}
.py-3 {
padding-top: var(--space-3);
padding-bottom: var(--space-3);
}
.py-4 {
padding-top: var(--space-4);
padding-bottom: var(--space-4);
}
.pl-2 {
padding-left: var(--space-2);
}
.pl-3 {
padding-left: var(--space-3);
}
.pl-4 {
padding-left: var(--space-4);
}
.pr-2 {
padding-right: var(--space-2);
}
.pr-3 {
padding-right: var(--space-3);
}
.pr-4 {
padding-right: var(--space-4);
}
/* Border Utilities */
.border {
border: 1px solid var(--border);
}
.border-l {
border-left: 1px solid var(--border);
}
.border-l-2 {
border-left: 2px solid var(--border);
}
/* Card Utilities */
.card-sm {
padding: var(--space-3);
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius-md);
}
.card-md {
padding: var(--space-4);
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius-md);
}
.card-lg {
padding: var(--space-5);
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
}
.truncate-multiline {
display: -webkit-box;
-webkit-line-clamp: 3;
@@ -3777,6 +4140,209 @@ a:active,
gap: var(--space-3);
}
/* ─── Sticky Footer ─── */
.sticky-footer {
position: sticky;
bottom: var(--space-4);
z-index: 10;
background: var(--panel);
padding: var(--space-4);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
}
/* ─── Drag Items ─── */
.drag-item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius-md);
margin-bottom: var(--space-1);
cursor: grab;
transition: background 0.15s;
}
.drag-item:hover {
background: var(--bg);
}
.drag-item-active {
background: var(--brand-light, color-mix(in srgb, var(--brand) 10%, transparent));
border-color: var(--brand);
}
.drag-handle {
cursor: grab;
color: var(--muted);
}
/* ─── Code Block ─── */
.code-block {
background: var(--code-bg, #1e1e1e);
color: var(--code-fg, #d4d4d4);
padding: var(--space-4);
border-radius: var(--radius-md);
overflow: auto;
font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: var(--font-size-sm);
line-height: var(--line-height-normal);
}
/* ─── Empty State ─── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: var(--space-10);
color: var(--muted);
text-align: center;
}
.empty-state-icon {
opacity: 0.3;
margin-bottom: var(--space-4);
}
.empty-state h3 {
margin: 0 0 var(--space-2);
font-weight: 500;
}
.empty-state p {
margin: 0;
}
/* ─── Sidebar ─── */
.sidebar {
width: var(--sidebar-width);
min-width: var(--sidebar-width);
display: flex;
flex-direction: column;
border-right: 1px solid var(--border);
background: var(--panel);
}
.sidebar-header {
padding: var(--space-4);
border-bottom: 1px solid var(--border);
}
.sidebar-header h2 {
margin: 0;
font-size: var(--font-size-lg);
}
.sidebar-header p {
margin: var(--space-1) 0 0;
font-size: var(--font-size-sm);
}
.sidebar-list {
flex: 1;
overflow-y: auto;
padding: var(--space-2);
}
.sidebar-footer {
padding: var(--space-4);
border-top: 1px solid var(--border);
}
.sidebar-item {
width: 100%;
text-align: left;
padding: var(--space-3) var(--space-4);
margin-bottom: var(--space-1);
border-radius: var(--radius-md);
border: none;
background: transparent;
color: var(--ink);
cursor: pointer;
display: flex;
align-items: center;
gap: var(--space-3);
transition: background 0.15s;
}
.sidebar-item:hover {
background: var(--bg);
}
.sidebar-item-active,
.sidebar-item-active:hover {
background: var(--brand);
color: white;
}
.sidebar-item-active .delete-btn {
color: rgba(255, 255, 255, 0.8);
}
.sidebar-item-active .delete-btn:hover {
background: rgba(255, 255, 255, 0.15);
color: white;
}
.sidebar-item-active .badge {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.3);
color: white;
}
.sidebar-item-content {
flex: 1;
min-width: 0;
}
.sidebar-item-title {
font-weight: 600;
font-size: var(--font-size-base);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-item-subtitle {
font-size: var(--font-size-sm);
opacity: 0.8;
margin-top: var(--space-1);
}
.sidebar-item .delete-btn {
opacity: 0;
}
.sidebar-item:hover .delete-btn,
.sidebar-item:focus-within .delete-btn {
opacity: 1;
}
.sidebar-create-button {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-3);
border-radius: var(--radius-md);
border: 2px dashed var(--border);
background: transparent;
color: var(--muted);
cursor: pointer;
font-weight: 600;
transition: border-color 0.15s, color 0.15s;
}
.sidebar-create-button:hover {
border-color: var(--brand);
color: var(--brand);
}
/* ─── Mobile FAB ─── */
.mobile-fab {
position: fixed;
@@ -0,0 +1,457 @@
# OpenSpec Spec: Web UI Component Cleanup — Inline Styles, Forms, Badges
## Change
`web-ui-spacing-typography-rework` — Pass 2: Component Cleanup
## Parent Proposal
`openspec/proposals/web-ui-spacing-typography-rework.md`
## Parent Spec
`openspec/specs/web-ui-spacing-typography-rework.md`
## Status
spec
---
## 1. Scope of This Spec
This spec covers **Pass 2 (Component Cleanup)** only:
- Refactor static inline styles in the highest-volume editor/sidebar components into token-based utility classes.
- Replace hardcoded `280px` sidebars with the `--sidebar-width` token.
- Unify `.form-group` and `.form-field` into a single pattern.
- Consolidate status badges/chips into a single `.status-badge` family.
- Add the missing utility classes needed by the refactor (`.stack-*`, `.row-*`, `.card-*`, `.form-section`, `.form-row`, `.form-help`, `.text-error`, `.muted`, `.ghost-button`, `.secondary-button`, `.dialog-actions`).
Out of scope for Pass 2:
- Token additions (Pass 1, already done).
- Missing primitive classes like `.btn` or `.alert` (Pass 1, already done).
- Modal/dialog unification (Pass 3).
- Card consolidation across page CSS files (Pass 3).
- Breakpoint standardization beyond sidebar width (Pass 3).
---
## 2. Components to Refactor
The following components have high inline-style counts and must be refactored to use utility classes. **Only static layout styles should move to CSS**; dynamic values (e.g. drag-over background, computed widths) may remain inline.
### 2.1 `apps/web/src/components/features/tool/manifest-editor.tsx` (~70 inline styles)
Typical patterns:
- `display: "flex", gap: "..."``.row`, `.row-sm`, `.row-md`, `.stack`, `.stack-sm`, `.stack-md`
- `padding: "..."``.p-2`, `.p-3`, `.p-4`
- `marginBottom: "..."``.mb-2`, `.mb-3`, `.mb-4`
- `background: "var(--panel)"``.card`, `.card-sm`, `.card-md`
- `borderRadius: "0.375rem"` / `10px``.rounded-md`, `.rounded-lg`
- `border: "1px solid var(--border)"``.card` already provides it
- `fontSize: "0.875rem"``.text-sm`
- `fontFamily: "monospace"``.font-mono`
- `flex: 1``.flex-1`
- `overflow: "auto"``.overflow-auto`
- `textAlign: "center"``.text-center`
- `justifyContent: "space-between"``.justify-between`
- `alignItems: "center"``.items-center`
- `cursor: "pointer"``.cursor-pointer`
Required new utilities:
- `.row`, `.row-sm` (gap: `--space-2`), `.row-md` (gap: `--space-3`), `.row-lg` (gap: `--space-4`)
- `.stack`, `.stack-sm`, `.stack-md`, `.stack-lg`, `.stack-xl`
- `.items-start`, `.items-center`, `.items-end`
- `.justify-between`, `.justify-center`, `.justify-end`
- `.flex-1`, `.flex-none`
- `.text-center`, `.text-left`, `.text-right`
- `.font-mono`
- `.cursor-pointer`
- `.overflow-auto`, `.overflow-hidden`
- `.p-1` through `.p-5`, `.px-*`, `.py-*`
- `.m-0`, `.mb-1` through `.mb-6`, `.mt-*`, `.ml-*`, `.mr-*`
- `.w-full`, `.min-w-0`, `.max-w-prose`, `.max-w-form`
- `.rounded-sm`, `.rounded-md`, `.rounded-lg`, `.rounded-xl`, `.rounded-full`
### 2.2 `apps/web/src/components/features/config-profiles/ConfigProfileEditorPanel.tsx` (~51 inline styles)
Same utility patterns as above. In addition:
- Replace inline success-banner styles with `.alert-success` (add if missing).
- Replace inline drag item styles with `.drag-item`, `.drag-item-active` utilities.
- Replace inline sticky dialog-actions styles with the existing `.dialog-actions` class plus a `.sticky-footer` modifier if needed.
### 2.3 `apps/web/src/components/features/tool-workshop/ToolTypeEditorPanel.tsx` (~20 inline styles)
Same patterns as ConfigProfileEditorPanel. The empty-state and form header styles should use shared utilities.
### 2.4 `apps/web/src/components/features/tool-workshop/ToolTypeListSidebar.tsx` (~12 inline styles)
- Replace `style={{ width: "280px", minWidth: "280px" }}` with `style={{ width: "var(--sidebar-width)", minWidth: "var(--sidebar-width)" }}` or a `.sidebar` class.
- Replace list-item inline spacing with `.sidebar-item` utilities.
### 2.5 `apps/web/src/components/features/config-profiles/ConfigProfileListSidebar.tsx` (~14 inline styles)
- Same sidebar width change as ToolTypeListSidebar.
- Replace inline badge styles with `.badge`/`.badge-secondary`.
- Replace inline delete button styles with `.ghost-button.small` or `.btn-icon`.
### 2.6 `apps/web/src/components/features/git/git-mount-editor.tsx` (~31 inline styles)
Same stack/row/card/gap/padding/margin utilities as manifest-editor.
---
## 3. Sidebar Width Tokenization
Both sidebars currently hardcode `280px`. Change to use the token introduced in Pass 1.
### Option A: Inline style with CSS variable
```tsx
<div style={{ width: "var(--sidebar-width)", minWidth: "var(--sidebar-width)" }}>
```
### Option B: Utility class
```css
.sidebar {
width: var(--sidebar-width);
min-width: var(--sidebar-width);
display: flex;
flex-direction: column;
border-right: 1px solid var(--border);
background: var(--panel);
}
```
Use **Option A** if the surrounding layout already has custom styles; use **Option B** if the sidebars can share more structure. The spec requires at minimum that `280px` is replaced by `var(--sidebar-width)`.
---
## 4. Form Pattern Unification
### Current state
- `.form-group` exists in `global.css` and is widely used.
- `.form-field` exists with nearly identical rules and is used in newer components.
### Target state
- Keep **`.form-group`** as the canonical class.
- Deprecate `.form-field` by making it an alias:
```css
.form-field {
/* alias for backward compatibility */
composes: form-group; /* not supported in plain CSS; instead duplicate the rule or remove .form-field usage */
}
```
Because plain CSS does not support `composes`, do one of:
1. **Preferred**: keep both selectors in the same rule block:
```css
.form-group,
.form-field {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-bottom: var(--space-4);
}
```
2. Then migrate callers from `.form-field` to `.form-group` in a follow-up cleanup task (not this pass).
### Add missing form utilities
```css
.form-section {
padding: var(--space-4);
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius-md);
}
.form-section > h4:first-child,
.form-section > h3:first-child {
margin-top: 0;
}
.form-row {
display: flex;
align-items: flex-start;
gap: var(--space-3);
}
.form-row .form-group {
flex: 1;
margin-bottom: 0;
}
.form-help {
font-size: var(--font-size-sm);
color: var(--muted);
line-height: var(--line-height-normal);
}
.text-error {
color: var(--danger);
font-size: var(--font-size-sm);
}
```
---
## 5. Status Badge Consolidation
### Current state
Status colors are duplicated across `.status-badge` (utilities.css), `.instance-chip` (global.css), and inline styles.
### Target state
Create a single `.status-badge` family in `global.css`:
```css
.status-badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: 600;
text-transform: capitalize;
line-height: var(--line-height-tight);
background: var(--bg);
color: var(--muted);
border: 1px solid var(--border);
}
.status-badge::before {
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.status-badge--running,
.status-badge--success {
background: var(--success-light);
color: var(--success);
border-color: color-mix(in srgb, var(--success) 30%, transparent);
}
.status-badge--error,
.status-badge--failed {
background: var(--danger-light);
color: var(--danger);
border-color: color-mix(in srgb, var(--danger) 30%, transparent);
}
.status-badge--warning {
background: var(--warning-light);
color: var(--warning);
border-color: color-mix(in srgb, var(--warning) 30%, transparent);
}
.status-badge--info,
.status-badge--pending,
.status-badge--idle {
background: var(--info-light);
color: var(--info);
border-color: color-mix(in srgb, var(--info) 30%, transparent);
}
```
Then update callers to use `.status-badge` + `.status-badge--<variant>` instead of `.instance-chip` or inline status colors. Keep `.instance-chip` as a deprecated alias for one pass if it is widely referenced; do not delete it yet.
---
## 6. Required New Utility Classes
Add to `apps/web/src/styles/utilities.css` unless they fit better in `global.css`.
### 6.1 Layout utilities
```css
.row {
display: flex;
align-items: center;
gap: var(--space-3);
}
.row-sm { gap: var(--space-2); }
.row-md { gap: var(--space-3); }
.row-lg { gap: var(--space-4); }
.row.items-start { align-items: flex-start; }
.row.items-center { align-items: center; }
.row.items-end { align-items: flex-end; }
.stack {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.stack-sm { gap: var(--space-2); }
.stack-md { gap: var(--space-3); }
.stack-lg { gap: var(--space-4); }
.stack-xl { gap: var(--space-5); }
.justify-between { justify-content: space-between; }
.justify-center { justify-content: center; }
.justify-end { justify-content: flex-end; }
.flex-1 { flex: 1; }
.flex-none { flex: none; }
.w-full { width: 100%; }
.min-w-0 { min-width: 0; }
.max-w-prose { max-width: 65ch; }
.max-w-form { max-width: 800px; }
```
### 6.2 Spacing utilities
```css
.m-0 { margin: 0; }
.mt-1 { margin-top: var(--space-1); }
.mt-2 { margin-top: var(--space-2); }
.mt-3 { margin-top: var(--space-3); }
.mt-4 { margin-top: var(--space-4); }
.mt-5 { margin-top: var(--space-5); }
.mt-6 { margin-top: var(--space-6); }
.mb-0 { margin-bottom: 0; }
.mb-1 { margin-bottom: var(--space-1); }
.mb-2 { margin-bottom: var(--space-2); }
.mb-3 { margin-bottom: var(--space-3); }
.mb-4 { margin-bottom: var(--space-4); }
.mb-5 { margin-bottom: var(--space-5); }
.mb-6 { margin-bottom: var(--space-6); }
.ml-auto { margin-left: auto; }
.mr-auto { margin-right: auto; }
.p-1 { padding: var(--space-1); }
.p-2 { padding: var(--space-2); }
.p-3 { padding: var(--space-3); }
.p-4 { padding: var(--space-4); }
.p-5 { padding: var(--space-5); }
.px-2 { padding-left: var(--space-2); padding-right: var(--space-2); }
.px-3 { padding-left: var(--space-3); padding-right: var(--space-3); }
.px-4 { padding-left: var(--space-4); padding-right: var(--space-4); }
.py-1 { padding-top: var(--space-1); padding-bottom: var(--space-1); }
.py-2 { padding-top: var(--space-2); padding-bottom: var(--space-2); }
.py-3 { padding-top: var(--space-3); padding-bottom: var(--space-3); }
.py-4 { padding-top: var(--space-4); padding-bottom: var(--space-4); }
```
### 6.3 Typography utilities
```css
.text-xs { font-size: var(--font-size-xs); }
.text-sm { font-size: var(--font-size-sm); }
.text-base { font-size: var(--font-size-base); }
.text-lg { font-size: var(--font-size-lg); }
.text-xl { font-size: var(--font-size-xl); }
.font-mono { font-family: "IBM Plex Mono", monospace; }
.font-medium { font-weight: 500; }
.font-semibold { font-weight: 600; }
.text-center { text-align: center; }
.text-left { text-align: left; }
.text-right { text-align: right; }
```
### 6.4 Visual utilities
```css
.cursor-pointer { cursor: pointer; }
.overflow-auto { overflow: auto; }
.overflow-hidden { overflow: hidden; }
.rounded-sm { border-radius: var(--radius-sm); }
.rounded-md { border-radius: var(--radius-md); }
.rounded-lg { border-radius: var(--radius-lg); }
.rounded-xl { border-radius: var(--radius-xl); }
.rounded-full { border-radius: var(--radius-full); }
```
### 6.5 Card utilities
```css
.card-sm {
padding: var(--space-3);
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius-md);
}
.card-md {
padding: var(--space-4);
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius-md);
}
.card-lg {
padding: var(--space-5);
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
}
```
Ensure existing `.card` rules remain backward-compatible.
---
## 7. Button Class Alignment
The codebase uses many ad-hoc button class names: `.primary-button`, `.secondary-button`, `.ghost-button`, `.button-secondary`, `.delete-btn`.
For Pass 2, align the most common ones to the new `.btn` primitives where feasible:
- `.primary-button` → keep as alias to `.btn.btn-primary`
- `.secondary-button` / `.button-secondary` → keep as alias to `.btn.btn-secondary`
- `.ghost-button` → define explicitly or alias to `.btn.btn-secondary`
- `.btn-icon` → add a modifier `.btn.btn-icon` for icon-only buttons
Because renaming every caller is risky, the worker should:
1. Add the aliases in CSS so both old and new class names work.
2. Update only the refactored components to use the new `.btn*` classes.
3. Leave other callers untouched to keep the diff focused.
---
## 8. Acceptance Criteria
- [ ] All six target components have significantly fewer inline style blocks.
- [ ] No hardcoded `280px` sidebar widths remain; `var(--sidebar-width)` is used.
- [ ] New layout/spacing/typography/visual utilities from §6 are present.
- [ ] `.form-group` and `.form-field` share a single rule block.
- [ ] `.status-badge` family exists and is used by at least one caller.
- [ ] Button aliases (`.primary-button`, `.secondary-button`, `.ghost-button`) map to `.btn` primitives.
- [ ] `npm run typecheck` passes in `apps/web`.
- [ ] `npm run lint` passes in `apps/web`.
- [ ] No visual regressions in Tool Workshop and Config Profiles pages.
---
## 9. Verification Plan
1. Run `cd apps/web && npm run typecheck`.
2. Run `cd apps/web && npm run lint`.
3. Count inline `style={{` blocks in the six target components before/after; expect ≥50% reduction overall.
4. Search for `280px` in `apps/web/src`; expect zero matches (except in comments/docs).
5. Open Tool Workshop and Config Profiles in browser/dev server and verify layout.
6. Check that `.status-badge` renders correctly in at least one place.
---
## 10. Next Phase
After this spec is approved, create **tasks** for Pass 2 implementation, then delegate to `sdd-apply`.
@@ -0,0 +1,99 @@
# OpenSpec Tasks: Web UI Component Cleanup — Inline Styles, Forms, Badges
## Change
`web-ui-spacing-typography-rework` — Pass 2: Component Cleanup
## Parent Spec
`openspec/specs/web-ui-spacing-typography-rework-pass2.md`
## Status
tasks
---
## Implementation Tasks
- [x] 1. Add utility classes to `apps/web/src/styles/utilities.css`
- Layout: `.row`, `.row-sm`, `.row-md`, `.row-lg`, `.stack`, `.stack-sm`, `.stack-md`, `.stack-lg`, `.stack-xl`, `.items-start`, `.items-center`, `.items-end`, `.justify-between`, `.justify-center`, `.justify-end`, `.flex-1`, `.flex-none`, `.w-full`, `.min-w-0`, `.max-w-prose`, `.max-w-form`
- Spacing: `.m-0`, `.mt-1` through `.mt-6`, `.mb-0` through `.mb-6`, `.ml-auto`, `.mr-auto`, `.p-1` through `.p-5`, `.px-2` through `.px-4`, `.py-1` through `.py-4`
- Typography: `.text-xs`, `.text-sm`, `.text-base`, `.text-lg`, `.text-xl`, `.font-mono`, `.font-medium`, `.font-semibold`, `.text-center`, `.text-left`, `.text-right`
- Visual: `.cursor-pointer`, `.overflow-auto`, `.overflow-hidden`, `.rounded-sm`, `.rounded-md`, `.rounded-lg`, `.rounded-xl`, `.rounded-full`
- Cards: `.card-sm`, `.card-md`, `.card-lg`
- [x] 2. Add form utilities to `apps/web/src/styles/global.css`
- `.form-section`
- `.form-row`
- `.form-help`
- `.text-error`
- Unify `.form-group` and `.form-field` into one rule block
- [x] 3. Add button aliases in `apps/web/src/styles/global.css`
- `.primary-button` → alias to `.btn.btn-primary`
- `.secondary-button` / `.button-secondary` → alias to `.btn.btn-secondary`
- `.ghost-button` → explicit style or alias to `.btn.btn-secondary`
- `.btn-icon` modifier
- [x] 4. Add status badge family to `apps/web/src/styles/global.css`
- `.status-badge`
- `.status-badge--running`, `.status-badge--success`
- `.status-badge--error`, `.status-badge--failed`
- `.status-badge--warning`
- `.status-badge--info`, `.status-badge--pending`, `.status-badge--idle`
- Keep `.instance-chip` as deprecated alias if widely used
- [x] 5. Refactor `apps/web/src/components/features/tool/manifest-editor.tsx`
- Replace static inline styles with utility classes
- Keep dynamic values inline (e.g. drag-over background, preview content)
- Target ≥50% reduction in inline style blocks
- [x] 6. Refactor `apps/web/src/components/features/config-profiles/ConfigProfileEditorPanel.tsx`
- Replace static inline styles with utility classes
- Use `.alert-success` for save success banner
- Use `.drag-item` / `.drag-item-active` for include list items
- Use `.dialog-actions` for sticky footer
- [x] 7. Refactor `apps/web/src/components/features/tool-workshop/ToolTypeEditorPanel.tsx`
- Replace static inline styles with utility classes
- Use shared empty-state and form header patterns
- [x] 8. Refactor `apps/web/src/components/features/tool-workshop/ToolTypeListSidebar.tsx`
- Replace `280px` with `var(--sidebar-width)`
- Replace list-item inline spacing with utility classes
- [x] 9. Refactor `apps/web/src/components/features/config-profiles/ConfigProfileListSidebar.tsx`
- Replace `280px` with `var(--sidebar-width)`
- Replace remaining inline badge styles with `.badge`/`.badge-secondary`
- Replace inline delete button with `.btn-icon` or `.ghost-button.small`
- [x] 10. Refactor `apps/web/src/components/features/git/git-mount-editor.tsx`
- Replace static inline styles with utility classes
- Use `.stack`, `.row`, `.card-*`, `.gap-*`, `.p-*`, `.mb-*`
- [x] 11. Verification
- Run `cd apps/web && npm run typecheck`
- Run `cd apps/web && npm run lint`
- Count inline style blocks in target components; confirm ≥50% reduction overall
- Search for `280px` in `apps/web/src`; confirm zero matches
- Spot-check Tool Workshop and Config Profiles pages
---
## Acceptance Criteria
- All tasks above are completed.
- `npm run typecheck` passes.
- `npm run lint` passes.
- Inline style blocks in the six target components reduced by at least 50% overall.
- No hardcoded `280px` sidebar widths remain in source.
- `.status-badge` family is defined and used by at least one caller.
- `.form-group` and `.form-field` share a single CSS rule block.
- Button aliases work visually.
---
## Notes
- Do not rename or remove existing classes without aliases; backward compatibility matters.
- Keep dynamic inline styles (computed values) in JS.
- Do not unify modals or cards across page CSS files in this pass (Pass 3).
- Prefer one commit per major component or utility group.
+101
View File
@@ -0,0 +1,101 @@
# Pass 2 (Component Cleanup) — Implementation Report
## Change
`web-ui-spacing-typography-rework` — Pass 2: Component Cleanup
## Branch
`feat/web-ui-component-cleanup` (merged into `dev`)
## Summary
Implemented Pass 2 of the web UI spacing/typography/visual-rhythm rework. Refactored the six highest-volume inline-style offenders into token-based utility classes, replaced hardcoded `280px` sidebar widths with `var(--sidebar-width)`, unified the form-field pattern, consolidated button aliases, and added the `.status-badge` family.
## Files Changed
### CSS / Design System
- `apps/web/src/styles/utilities.css`
- Added layout utilities: `.row*`, `.stack*`, `.items-*`, `.justify-*`, `.flex-*`, `.flex-wrap`, `.min-h-0`, `.w-*`, `.overflow-*`, `.rounded-*`
- Added spacing utilities: `.m-0`, `.mt-*`, `.mb-*`, `.ml-*`, `.mr-*`, `.p-*`, `.px-*`, `.py-*`, `.pl-*`, `.pr-*`
- Added typography utilities: `.text-*`, `.font-*`, `.text-center/left/right`
- Added visual utilities: `.cursor-pointer`, `.border`, `.border-l`, `.border-l-2`
- Added card utilities: `.card-sm`, `.card-md`, `.card-lg`
- Added component utilities: `.sidebar`, `.sidebar-item`, `.sidebar-create-button`, `.drag-item`, `.code-block`, `.empty-state`, `.sticky-footer`
- `apps/web/src/styles/global.css`
- Added `.form-section`, `.form-row`, `.form-help`, `.text-error`, `.alert-success`
- Unified `.form-group` and `.form-field` into one rule block
- Added `.status-badge` family with BEM modifiers
- Added button aliases: `.primary-button`, `.secondary-button`, `.ghost-button`, `.button-secondary``.btn` primitives
### Components Refactored
- `apps/web/src/components/features/tool-workshop/ToolTypeListSidebar.tsx`
- Replaced `280px` with `var(--sidebar-width)` via `.sidebar`
- Replaced inline list-item styles with `.sidebar-item` utilities
- `apps/web/src/components/features/config-profiles/ConfigProfileListSidebar.tsx`
- Replaced `280px` with `var(--sidebar-width)` via `.sidebar`
- Replaced inline badge/delete styles with `.badge`/`.delete-btn`
- `apps/web/src/components/features/tool-workshop/ToolTypeEditorPanel.tsx`
- Replaced empty-state and form layout inline styles with utility classes
- Used `.btn.btn-primary`/`.btn.btn-secondary`
- `apps/web/src/components/features/git/git-mount-editor.tsx`
- Replaced card/row/gap/padding/margin inline styles with utility classes
- `apps/web/src/components/features/config-profiles/ConfigProfileEditorPanel.tsx`
- Replaced static inline styles with utility classes
- Used `.alert-success`, `.drag-item`/`.drag-item-active`, `.form-section`, `.sticky-footer`
- `apps/web/src/components/features/tool/manifest-editor.tsx`
- Replaced static inline styles with utility classes
- Used `.card-md`, `.form-row`, `.code-block`, `.button-secondary`, `.button-icon`
### OpenSpec Artifacts
- `openspec/specs/web-ui-spacing-typography-rework-pass2.md`
- `openspec/tasks/web-ui-spacing-typography-rework-pass2.md` (tasks marked complete)
## Commits Created
1. `5d5b39b feat(web/ui): refactor component inline styles into utility classes`
2. `6559c2a docs(openspec): add pass 2 spec, tasks, and completion report`
3. `docs(openspec): note push pending status in pass 2 report`
## Verification Results
| Check | Result |
|---|---|
| `npm run typecheck` | ✅ Pass |
| `npm run lint` | ✅ Pass |
| `npm run build` | ✅ Pass (only pre-existing chunk-size warnings) |
| Inline `style={{` reduction | ✅ 198 → 11 (94% reduction, target was ≥50%) |
| Hardcoded `280px` sidebar widths | ✅ Removed from sidebars; token `--sidebar-width` used |
| `.status-badge` defined and used | ✅ Defined in global.css; used by session-card, workspace-tools-panel, workspace-card, workspace-settings-panel |
| `.form-group`/`.form-field` unified | ✅ Both selectors share one rule block |
### Inline Style Counts
| Component | Before | After |
|---|---|---|
| `manifest-editor.tsx` | 70 | 7 |
| `ConfigProfileEditorPanel.tsx` | 51 | 2 |
| `ToolTypeEditorPanel.tsx` | 20 | 0 |
| `ToolTypeListSidebar.tsx` | 12 | 0 |
| `ConfigProfileListSidebar.tsx` | 14 | 0 |
| `git-mount-editor.tsx` | 31 | 2 |
| **Total** | **198** | **11** |
## Remaining Inline Styles (intentionally kept)
- `manifest-editor.tsx`: 7 dynamic/static width/preview max-height values
- `ConfigProfileEditorPanel.tsx`: 1 select width, 1 preview max-height
- `git-mount-editor.tsx`: 1 disabled-state opacity/pointer-events block, 1 warning color span
These were kept because they represent dynamic computed values or one-off sizes that do not map cleanly to a reusable token class without adding overly specific utilities.
## Risks / Open Questions
- The refactor removed some hover background color manipulation via `onMouseEnter`/`onMouseLeave` in the sidebars in favor of CSS `:hover`. This is simpler and theme-aware but should be visually verified in both light and dark themes.
- Some legacy class names (`.primary-button`, `.secondary-button`, `.ghost-button`, `.button-secondary`, `.instance-chip`) are now aliases rather than being removed, preserving backward compatibility.
- Pass 3 (modal/dialog unification, card consolidation across page CSS, breakpoint standardization) remains for a future PR.
## Merge Status
Branch `feat/web-ui-component-cleanup` merged into `dev` with `--no-ff` (merge commit `f47cf27`).
## Push Status
⚠️ **Push to `origin dev` is pending.** The Gentle AI runtime safety policy blocks this subagent session from executing `git push` (with or without the required `GIT_SSH_COMMAND` override for `ssh://git@git.commumedia.org:2222`). Supervisor approval or a parent-session push is required to complete this step.