diff --git a/apps/api/src/api/git_repositories.py b/apps/api/src/api/git_repositories.py index 9a3eca8..9f05975 100644 --- a/apps/api/src/api/git_repositories.py +++ b/apps/api/src/api/git_repositories.py @@ -6,11 +6,31 @@ import uuid from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Response, status -from pydantic import BaseModel, ConfigDict from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from src.auth.dependencies import get_current_user, get_db_session, get_owned_project +from src.schemas.git_repository import ( + GitRepositoryCreate, + GitRepositoryResponse, + URLParseRequest, + URLParseResponse, + FileListResponse, + FileContentResponse, + BranchesResponse, + FileUpdateRequest, + FileUpdateResponse, + StatusResponse, + BranchCreateRequest, + CheckoutRequest, + CommitRequest, + CommitResponse, + FetchResponse, + PullResponse, + PushResponse, + MergeRequest, + MergeResponse, +) from src.config import Settings from src.models.git_repository import GitRepository from src.models.project import Project @@ -139,40 +159,6 @@ def _init_working_repository(repo_path: str) -> None: ) -class GitRepositoryCreate(BaseModel): - name: str - remote_url: str | None = None - force_original_url: bool = False - - -class URLParseRequest(BaseModel): - url: str - - -class URLParseResponse(BaseModel): - original_url: str - base_url: str | None - is_valid_clone_url: bool - needs_parsing: bool - host: str | None - message: str - error_code: str | None - - -class GitRepositoryResponse(BaseModel): - model_config = ConfigDict(from_attributes=True) - - id: uuid.UUID - name: str - path: str - project_id: uuid.UUID - owner_id: uuid.UUID - is_mirror: bool - remote_url: str | None - last_push: datetime | None - created_at: datetime - updated_at: datetime - @router.get( "/{project_id}/repositories", diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index f87b785..e5b8742 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -9,7 +9,6 @@ from datetime import datetime import httpx from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from fastapi.responses import StreamingResponse -from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -18,6 +17,7 @@ logger = logging.getLogger(__name__) from src.auth.dependencies import get_current_user from src.auth.dependencies import get_db_session from src.auth.dependencies import get_owned_project +from src.schemas.tool_instance import CreateInstanceRequest from src.models.git_repository import GitRepository from src.models.project import Project from src.models.config_profile import ConfigProfile @@ -50,16 +50,6 @@ from src.services.readiness_probe import execute_probe router = APIRouter(prefix="/projects", tags=["tool-instances"]) -class CreateInstanceRequest(BaseModel): - """Request body for creating a tool instance.""" - - model_config = {"extra": "ignore"} - - tool_type_id: str = Field(description="UUID of the tool type to instantiate") - display_name: str | None = Field(default=None, description="Optional display name for the instance") - config_profile_id: str | None = Field(default=None, description="Optional config profile ID to apply to the instance") - - def _modify_compose_file( compose_path: str, port_override: int | None = None, diff --git a/apps/api/src/schemas/__init__.py b/apps/api/src/schemas/__init__.py new file mode 100644 index 0000000..00969af --- /dev/null +++ b/apps/api/src/schemas/__init__.py @@ -0,0 +1 @@ +"""Pydantic request/response schemas.""" diff --git a/apps/api/src/schemas/config_folder.py b/apps/api/src/schemas/config_folder.py new file mode 100644 index 0000000..49bab31 --- /dev/null +++ b/apps/api/src/schemas/config_folder.py @@ -0,0 +1,42 @@ +"""Config folder request/response schemas.""" + +from pydantic import BaseModel, Field + + +class ConfigFolderCreate(BaseModel): + name: str = Field(description="Folder name") + description: str | None = Field(default=None, description="Optional description") + mount_path: str = Field(description="Mount path in container") + files: dict[str, str] | None = Field( + default=None, description="Files as {path: content}" + ) + is_active: bool = Field(default=True, description="Whether folder is active") + + +class ConfigFolderUpdate(BaseModel): + name: str | None = None + description: str | None = None + mount_path: str | None = None + files: dict[str, str] | None = None + is_active: bool | None = None + + +class ProjectOverrideCreate(BaseModel): + project_id: str = Field(description="Project ID to override for") + mount_path: str | None = Field(default=None, description="Override mount path") + files: dict[str, str] | None = Field( + default=None, description="Override files" + ) + is_active: bool | None = Field(default=None, description="Override active state") + + +class ConfigFolderResponse(BaseModel): + id: str + user_id: str + name: str + description: str | None + mount_path: str + files: dict[str, str] | None + is_active: bool + created_at: str + updated_at: str diff --git a/apps/api/src/schemas/config_profile.py b/apps/api/src/schemas/config_profile.py new file mode 100644 index 0000000..5a6579a --- /dev/null +++ b/apps/api/src/schemas/config_profile.py @@ -0,0 +1,131 @@ +"""Config profile request/response schemas.""" + +from typing import Any + +from pydantic import BaseModel, Field, field_validator + +MAX_MOUNT_PATH_LENGTH = 1024 + + +class ConfigProfileCreate(BaseModel): + name: str = Field(description="Profile name (unique per user)") + description: str | None = Field(default=None, description="Optional description") + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + v = v.strip() + if not v: + raise ValueError("Profile name cannot be empty") + if len(v) > 255: + raise ValueError("Profile name must be 255 characters or less") + return v + + +class ConfigProfileUpdate(BaseModel): + name: str | None = Field(default=None, description="Profile name") + description: str | None = Field(default=None, description="Optional description") + + @field_validator("name") + @classmethod + def validate_name(cls, v: str | None) -> str | None: + if v is None: + return v + v = v.strip() + if not v: + raise ValueError("Profile name cannot be empty") + if len(v) > 255: + raise ValueError("Profile name must be 255 characters or less") + return v + + +class ConfigProfileResponse(BaseModel): + id: str + user_id: str + name: str + description: str | None + created_at: str + updated_at: str + + +class ConfigProfileDetailResponse(ConfigProfileResponse): + includes: list[dict[str, Any]] + mounts: list[dict[str, Any]] + + +class ConfigIncludeCreate(BaseModel): + included_profile_id: str = Field(description="UUID of the profile to include") + order_index: int = Field(default=0, description="Order index for include resolution") + + +class ConfigIncludeUpdate(BaseModel): + order_index: int = Field(description="Order index for include resolution") + + +class ConfigIncludeResponse(BaseModel): + id: str + profile_id: str + included_profile_id: str + included_profile_name: str | None + order_index: int + created_at: str + updated_at: str + + +class ConfigMountCreate(BaseModel): + target_path: str = Field(description="Absolute target path in container") + mode: str = Field(default="rw", description="Mount mode (rw or ro)") + files: dict[str, str] | None = Field( + default=None, description="Files as {path: content}" + ) + order_index: int = Field(default=0, description="Order index for mount resolution") + + @field_validator("target_path") + @classmethod + def validate_target_path(cls, v: str) -> str: + if not v.startswith("/"): + raise ValueError("Target path must be absolute (start with /)") + if ".." in v: + raise ValueError("Target path cannot contain parent directory references (..)") + if len(v) > MAX_MOUNT_PATH_LENGTH: + raise ValueError(f"Target path must be {MAX_MOUNT_PATH_LENGTH} characters or less") + return v + + +class ConfigMountUpdate(BaseModel): + target_path: str | None = Field(default=None, description="Absolute target path in container") + mode: str | None = Field(default=None, description="Mount mode (rw or ro)") + files: dict[str, str] | None = Field( + default=None, description="Files as {path: content}" + ) + order_index: int | None = Field(default=None, description="Order index for mount resolution") + + @field_validator("target_path") + @classmethod + def validate_target_path(cls, v: str | None) -> str | None: + if v is None: + return v + if not v.startswith("/"): + raise ValueError("Target path must be absolute (start with /)") + if ".." in v: + raise ValueError("Target path cannot contain parent directory references (..)") + if len(v) > MAX_MOUNT_PATH_LENGTH: + raise ValueError(f"Target path must be {MAX_MOUNT_PATH_LENGTH} characters or less") + return v + + +class ConfigMountResponse(BaseModel): + id: str + profile_id: str + target_path: str + mode: str + files: dict[str, str] | None + order_index: int + created_at: str + updated_at: str + + +class DefaultProfilesUpdate(BaseModel): + default_profiles: dict[str, str] = Field( + description="Mapping of tool_type_id to profile_id" + ) diff --git a/apps/api/src/schemas/git_repository.py b/apps/api/src/schemas/git_repository.py new file mode 100644 index 0000000..05672af --- /dev/null +++ b/apps/api/src/schemas/git_repository.py @@ -0,0 +1,129 @@ +"""Git repository request/response schemas.""" + +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class GitRepositoryCreate(BaseModel): + name: str + remote_url: str | None = None + force_original_url: bool = False + + +class URLParseRequest(BaseModel): + url: str + + +class URLParseResponse(BaseModel): + original_url: str + base_url: str | None + is_valid_clone_url: bool + needs_parsing: bool + host: str | None + message: str + error_code: str | None + + +class GitRepositoryResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + name: str + path: str + project_id: uuid.UUID + owner_id: uuid.UUID + is_mirror: bool + remote_url: str | None + last_push: datetime | None + created_at: datetime + updated_at: datetime + + +class FileListResponse(BaseModel): + path: str + branch: str + entries: list[dict] + + +class FileContentResponse(BaseModel): + path: str + branch: str + content: str + size: int + encoding: str + language: str | None + is_binary: bool + last_commit: dict | None + + +class BranchesResponse(BaseModel): + branches: list[dict] + default_branch: str + + +class FileUpdateRequest(BaseModel): + path: str + branch: str + content: str + commit_message: str + + +class FileUpdateResponse(BaseModel): + commit_hash: str + message: str + branch: str + + +class StatusResponse(BaseModel): + branch: str + modified: list[str] + added: list[str] + deleted: list[str] + untracked: list[str] + renamed: list[str] + ahead: int + behind: int + + +class BranchCreateRequest(BaseModel): + name: str + base_branch: str = "HEAD" + + +class CheckoutRequest(BaseModel): + branch: str + + +class CommitRequest(BaseModel): + message: str + files: list[str] | None = None + + +class CommitResponse(BaseModel): + commit_hash: str + message: str + + +class FetchResponse(BaseModel): + message: str + + +class PullResponse(BaseModel): + message: str + + +class PushResponse(BaseModel): + message: str + + +class MergeRequest(BaseModel): + source_branch: str + target_branch: str | None = None + message: str | None = None + + +class MergeResponse(BaseModel): + commit_hash: str + message: str diff --git a/apps/api/src/schemas/project.py b/apps/api/src/schemas/project.py new file mode 100644 index 0000000..fb80dbf --- /dev/null +++ b/apps/api/src/schemas/project.py @@ -0,0 +1,25 @@ +"""Project request/response schemas.""" + +from pydantic import BaseModel + + +class ProjectCreate(BaseModel): + name: str + description: str | None = None + + +class ProjectUpdate(BaseModel): + name: str | None = None + description: str | None = None + + +class ProjectResponse(BaseModel): + id: str + name: str + description: str | None + created_at: str + updated_at: str + + +class SetDefaultSSHKeyRequest(BaseModel): + ssh_key_id: str diff --git a/apps/api/src/schemas/ssh_key.py b/apps/api/src/schemas/ssh_key.py new file mode 100644 index 0000000..33e1d41 --- /dev/null +++ b/apps/api/src/schemas/ssh_key.py @@ -0,0 +1,16 @@ +"""SSH key request/response schemas.""" + +from pydantic import BaseModel + + +class SSHKeyCreate(BaseModel): + name: str + public_key: str + + +class SSHKeyResponse(BaseModel): + id: str + name: str + public_key: str + fingerprint: str + created_at: str diff --git a/apps/api/src/schemas/tool_config.py b/apps/api/src/schemas/tool_config.py new file mode 100644 index 0000000..3fc2781 --- /dev/null +++ b/apps/api/src/schemas/tool_config.py @@ -0,0 +1,47 @@ +"""Tool config request/response schemas.""" + +from pydantic import BaseModel, Field + + +class ToolConfigCreate(BaseModel): + tool_type_id: str = Field(description="UUID of the tool type") + key: str = Field(description="Configuration key") + value: str = Field(description="Configuration value") + config_type: str = Field(default="env", description="Config type: env or file") + file_path: str | None = Field(default=None, description="File path for file configs") + port_override: int | None = Field(default=None, description="Port override") + start_command: str | None = Field(default=None, description="Start command override") + working_directory: str | None = Field(default=None, description="Working directory") + environment_variables: dict[str, str] | None = Field( + default=None, description="Additional environment variables" + ) + volumes: list[dict] | None = Field(default=None, description="Volume mounts") + + +class ToolConfigUpdate(BaseModel): + value: str | None = None + config_type: str | None = None + file_path: str | None = None + port_override: int | None = None + start_command: str | None = None + working_directory: str | None = None + environment_variables: dict[str, str] | None = None + volumes: list[dict] | None = None + + +class ToolConfigResponse(BaseModel): + id: str + tool_type_id: str + user_id: str + project_id: str | None + key: str + value: str + config_type: str + file_path: str | None + port_override: int | None + start_command: str | None + working_directory: str | None + environment_variables: dict[str, str] | None + volumes: list[dict] | None + created_at: str + updated_at: str diff --git a/apps/api/src/schemas/tool_instance.py b/apps/api/src/schemas/tool_instance.py new file mode 100644 index 0000000..b460c17 --- /dev/null +++ b/apps/api/src/schemas/tool_instance.py @@ -0,0 +1,17 @@ +"""Tool instance request/response schemas.""" + +from pydantic import BaseModel, Field + + +class CreateInstanceRequest(BaseModel): + """Request body for creating a tool instance.""" + + model_config = {"extra": "ignore"} + + tool_type_id: str = Field(description="UUID of the tool type to instantiate") + display_name: str | None = Field( + default=None, description="Optional display name for the instance" + ) + config_profile_id: str | None = Field( + default=None, description="Optional config profile ID to apply to the instance" + ) diff --git a/apps/api/src/schemas/tool_type.py b/apps/api/src/schemas/tool_type.py new file mode 100644 index 0000000..ff449aa --- /dev/null +++ b/apps/api/src/schemas/tool_type.py @@ -0,0 +1,204 @@ +"""Tool type request/response schemas.""" + +import uuid +from datetime import datetime + +import yaml +from pydantic import BaseModel, ConfigDict, field_validator, model_validator + + +class ToolTypeCreate(BaseModel): + name: str + display_name: str + description: str | None = None + default_port: int + definition_type: str = "compose" + compose_template: str | None = None + dockerfile_template: str | None = None + build_context: dict | None = None + readiness_probe: dict | None = None + required_variables: list[str] = [] + category: str = "other" + interfaces: list[str] = ["web"] + + @field_validator("definition_type") + @classmethod + def validate_definition_type(cls, v: str) -> str: + if v not in ("compose", "dockerfile"): + raise ValueError("definition_type must be 'compose' or 'dockerfile'") + return v + + @field_validator("compose_template") + @classmethod + def validate_compose_template(cls, v: str | None, info) -> str | None: + data = info.data + if data.get("definition_type") != "compose": + return v + if v is None: + raise ValueError("compose_template is required when definition_type is 'compose'") + try: + parsed = yaml.safe_load(v) + except yaml.YAMLError as e: + raise ValueError(f"Invalid YAML: {e}") + if not isinstance(parsed, dict): + raise ValueError("Compose template must be a YAML mapping") + if "services" not in parsed: + raise ValueError("Compose template must contain 'services' key") + if not parsed["services"]: + raise ValueError("Compose template must define at least one service") + return v + + @field_validator("dockerfile_template") + @classmethod + def validate_dockerfile_template(cls, v: str | None, info) -> str | None: + data = info.data + if data.get("definition_type") != "dockerfile": + return v + if v is None: + raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'") + if not v.strip().startswith("FROM"): + raise ValueError("Dockerfile must start with a FROM instruction") + return v + + @field_validator("default_port") + @classmethod + def validate_default_port(cls, v: int, info) -> int: + if v <= 0 or v > 65535: + raise ValueError("Port must be between 1 and 65535") + data = info.data + if data.get("definition_type") != "compose": + return v + template = data.get("compose_template") + if not template: + return v + try: + parsed = yaml.safe_load(template) + except yaml.YAMLError: + return v + port_str = str(v) + port_exposed = False + if isinstance(parsed, dict) and "services" in parsed: + for service_config in parsed["services"].values(): + if isinstance(service_config, dict) and "ports" in service_config: + for port_mapping in service_config["ports"]: + if isinstance(port_mapping, str) and port_str in port_mapping: + port_exposed = True + break + elif isinstance(port_mapping, int) and port_mapping == v: + port_exposed = True + break + if port_exposed: + break + if not port_exposed: + raise ValueError(f"Port {v} is not exposed in the compose template. Add it to the 'ports' section.") + return v + + @field_validator("required_variables") + @classmethod + def validate_required_variables(cls, v: list[str], info) -> list[str]: + if not v: + return v + data = info.data + if data.get("definition_type") != "compose": + return v + template = data.get("compose_template") + if not template: + return v + for var in v: + placeholder = f"{{{{{var}}}}}" + if placeholder not in template: + raise ValueError(f"Required variable '{var}' not found in compose template") + return v + + @model_validator(mode="after") + def validate_templates(self) -> "ToolTypeCreate": + if self.definition_type == "dockerfile" and self.dockerfile_template is None: + raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'") + if self.definition_type == "compose" and self.compose_template is None: + raise ValueError("compose_template is required when definition_type is 'compose'") + return self + + +class ToolTypeUpdate(BaseModel): + display_name: str | None = None + description: str | None = None + default_port: int | None = None + definition_type: str | None = None + compose_template: str | None = None + dockerfile_template: str | None = None + build_context: dict | None = None + readiness_probe: dict | None = None + required_variables: list[str] | None = None + category: str | None = None + interfaces: list[str] | None = None + + @field_validator("definition_type") + @classmethod + def validate_definition_type(cls, v: str | None) -> str | None: + if v is None: + return v + if v not in ("compose", "dockerfile"): + raise ValueError("definition_type must be 'compose' or 'dockerfile'") + return v + + @field_validator("compose_template") + @classmethod + def validate_compose_template(cls, v: str | None, info) -> str | None: + if v is None: + return v + data = info.data + definition_type = data.get("definition_type") + if definition_type and definition_type != "compose": + return v + try: + parsed = yaml.safe_load(v) + except yaml.YAMLError as e: + raise ValueError(f"Invalid YAML: {e}") + if not isinstance(parsed, dict): + raise ValueError("Compose template must be a YAML mapping") + if "services" not in parsed: + raise ValueError("Compose template must contain 'services' key") + if not parsed["services"]: + raise ValueError("Compose template must define at least one service") + return v + + @field_validator("dockerfile_template") + @classmethod + def validate_dockerfile_template(cls, v: str | None, info) -> str | None: + if v is None: + return v + data = info.data + definition_type = data.get("definition_type") + if definition_type and definition_type != "dockerfile": + return v + if not v.strip().startswith("FROM"): + raise ValueError("Dockerfile must start with a FROM instruction") + return v + + +class ToolTypeResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + name: str + display_name: str + description: str | None + category: str + interfaces: list[str] + default_port: int + definition_type: str + compose_template: str | None + dockerfile_template: str | None + build_context: dict | None + readiness_probe: dict | None + required_variables: list[str] + is_builtin: bool + created_by_id: uuid.UUID | None + created_at: datetime + updated_at: datetime + + +class ToolTypeValidateRequest(BaseModel): + definition_type: str + compose_template: str | None = None + dockerfile_template: str | None = None diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 22327e6..c29f1cf 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -5,7 +5,10 @@ import { BrowserRouter } from "react-router-dom"; import { AppRouter } from "./router"; import { AuthProvider } from "./state/auth"; import { SessionsProvider } from "./state/sessions"; -import "./styles.css"; +import "./styles/tokens.css"; +import "./styles/global.css"; +import "./styles/utilities.css"; +import "./styles/syntax-highlight.css"; ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/apps/web/src/styles/global.css b/apps/web/src/styles/global.css new file mode 100644 index 0000000..eeedbc6 --- /dev/null +++ b/apps/web/src/styles/global.css @@ -0,0 +1,127 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--ink); +} + +[data-theme="dark"] body { + background: radial-gradient(circle at top right, #2a2520, var(--bg)); +} + +a { + color: inherit; + text-decoration: none; +} + +.shell { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.shell-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.85rem 1.25rem; + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--panel) 88%, transparent); + backdrop-filter: blur(7px); +} + +[data-theme="dark"] .shell-header { + background: color-mix(in srgb, var(--panel) 88%, transparent); +} + +.brand { + font-weight: 700; + letter-spacing: 0.02em; +} + +.header-actions { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.shell-body { + display: grid; + grid-template-columns: 230px 1fr; + min-height: calc(100vh - 57px); +} + +.shell-nav { + border-right: 1px solid var(--border); + padding: 1rem 0.75rem; + display: flex; + flex-direction: column; + gap: 0.4rem; + background: color-mix(in srgb, var(--panel) 65%, transparent); +} + +.nav-item { + padding: 0.65rem 0.75rem; + border-radius: 10px; + color: var(--muted); +} + +.nav-item:hover { + background: #ece7df; + color: var(--ink); +} + +.nav-item-active { + background: var(--brand); + color: #f7fff7; +} + +.nav-section-title { + margin-top: 0.5rem; + padding: 0.25rem 0.75rem; + font-size: var(--font-size-xs); + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); +} + +.nav-divider { + height: 1px; + background: var(--border); + margin: 0.5rem 0; +} + +.shell-content { +/* Responsive Shell */ +@media (max-width: 767px) { + .shell-body { + grid-template-columns: 1fr; + grid-template-rows: auto 1fr; + } + + .shell-nav { + flex-direction: row; + flex-wrap: wrap; + gap: 0.25rem; + padding: 0.5rem; + border-right: none; + border-bottom: 1px solid var(--border); + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + .nav-item { + padding: 0.5rem 0.75rem; + white-space: nowrap; + font-size: var(--font-size-sm); + } + + .shell-content { + padding: var(--space-4); + } +} + +.stack { diff --git a/apps/web/src/styles/syntax-highlight.css b/apps/web/src/styles/syntax-highlight.css new file mode 100644 index 0000000..6152057 --- /dev/null +++ b/apps/web/src/styles/syntax-highlight.css @@ -0,0 +1,131 @@ +/* Prism.js Theme Integration */ +code[class*="language-"], +pre[class*="language-"] { + color: var(--ink); + text-shadow: none; + font-family: 'Fira Code', 'Monaco', 'Courier New', monospace; + font-size: 14px; + text-align: left; + white-space: pre; + word-spacing: normal; + word-break: normal; + word-wrap: normal; + line-height: 1.5; + tab-size: 2; + hyphens: none; +} + +/* Syntax Highlighting Colors */ +.token.comment, +.token.prolog, +.token.doctype, +.token.cdata { + color: var(--muted); +} + +.token.punctuation { + color: var(--ink); +} + +.token.namespace { + opacity: 0.7; +} + +.token.property, +.token.tag, +.token.boolean, +.token.number, +.token.constant, +.token.symbol, +.token.deleted { + color: #f59e0b; +} + +.token.selector, +.token.attr-name, +.token.string, +.token.char, +.token.builtin, +.token.inserted { + color: #10b981; +} + +.token.operator, +.token.entity, +.token.url, +.language-css .token.string, +.style .token.string { + color: #f43f5e; +} + +.token.atrule, +.token.attr-value, +.token.keyword { + color: #3b82f6; +} + +.token.function, +.token.class-name { + color: #8b5cf6; +} + +.token.regex, +.token.important, +.token.variable { + color: #ec4899; +} + +/* Icon System */ +.icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + vertical-align: middle; +} + +.icon svg { + display: block; +} + +.icon-sm { + width: 16px; + height: 16px; +} + +.icon-md { + width: 20px; + height: 20px; +} + +.icon-lg { + width: 24px; + height: 24px; +} + +.icon-xl { + width: 32px; + height: 32px; +} + +/* Button icons */ +button .icon, +a .icon { + margin-right: 0.35rem; +} + +button .icon:last-child, +a .icon:last-child { + margin-right: 0; +} + +/* Navigation icons */ +.nav-item .icon { + margin-right: 0.5rem; +} + +/* Status badge icons */ +.status-badge .icon { + margin-right: 0.25rem; +} + diff --git a/apps/web/src/styles/tokens.css b/apps/web/src/styles/tokens.css new file mode 100644 index 0000000..979424a --- /dev/null +++ b/apps/web/src/styles/tokens.css @@ -0,0 +1,69 @@ +:root { + color-scheme: light; + font-family: "Inter", "IBM Plex Sans", "Segoe UI", sans-serif; + --bg: #f4f1ea; + --panel: #fffef9; + --ink: #1d1d1b; + --muted: #5f5b55; + --brand: #275d4b; + --brand-strong: #154236; + --border: #d8d0c5; + --primary: #275d4b; + --primary-fg: #fffef9; + --color-primary: #275d4b; + --success: #2f8f62; + --success-light: rgba(47, 143, 98, 0.14); + --warning: #c08a1e; + --warning-light: rgba(192, 138, 30, 0.14); + --danger: #b94a3c; + --danger-light: rgba(185, 74, 60, 0.14); + --info: #4f7fb8; + --info-light: rgba(79, 127, 184, 0.14); + + /* Spacing Scale (4px base) */ + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-5: 1.5rem; + --space-6: 2rem; + --space-8: 3rem; + --space-10: 4rem; + + /* Breakpoints */ + --bp-sm: 480px; + --bp-md: 768px; + --bp-lg: 1024px; + --bp-xl: 1280px; + + /* Fluid Typography */ + --font-size-xs: clamp(0.625rem, 0.6rem + 0.125vw, 0.75rem); + --font-size-sm: clamp(0.75rem, 0.7rem + 0.25vw, 0.875rem); + --font-size-base: clamp(0.875rem, 0.8rem + 0.35vw, 1rem); + --font-size-lg: clamp(1rem, 0.9rem + 0.5vw, 1.25rem); + --font-size-xl: clamp(1.25rem, 1.1rem + 0.75vw, 1.5rem); + --font-size-2xl: clamp(1.5rem, 1.3rem + 1vw, 2rem); +} + +[data-theme="dark"] { + color-scheme: dark; + --bg: #171613; + --panel: #22201d; + --ink: #ece7df; + --muted: #a59d92; + --brand: #5fa889; + --brand-strong: #4d9175; + --border: #39342d; + --primary: #5fa889; + --primary-fg: #171613; + --color-primary: #5fa889; + --success: #22c55e; + --success-light: rgba(34, 197, 94, 0.15); + --warning: #f59e0b; + --warning-light: rgba(245, 158, 11, 0.15); + --danger: #ef4444; + --danger-light: rgba(239, 68, 68, 0.15); + --info: #3b82f6; + --info-light: rgba(59, 130, 246, 0.15); +} + diff --git a/apps/web/src/styles/utilities.css b/apps/web/src/styles/utilities.css new file mode 100644 index 0000000..1052f64 --- /dev/null +++ b/apps/web/src/styles/utilities.css @@ -0,0 +1,718 @@ + flex-direction: column; + gap: 1rem; +} + +.muted { + color: var(--muted); +} + +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); + gap: var(--space-4); +} + +@media (min-width: 768px) { + .card-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (min-width: 1024px) { + .card-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +.card { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 14px; + padding: 1rem; +} + +.card-label { + margin: 0; + color: var(--muted); +} + +.card-value { + margin: 0.45rem 0 0; + font-size: 1.6rem; + font-weight: 700; +} + +.quick-actions { + display: flex; + gap: 0.6rem; +} + +.primary-button, +.secondary-button, +.ghost-button { + border-radius: 10px; + border: 1px solid transparent; + padding: 0.58rem 0.85rem; + cursor: pointer; + font: inherit; +} + +.primary-button { + background: var(--brand); + color: white; +} + +.primary-button:hover { + background: var(--brand-strong); +} + +.secondary-button { + border-color: var(--border); + background: var(--panel); +} + +.ghost-button { + border-color: var(--border); + background: transparent; +} + +.user-chip { + border: 1px solid var(--border); + background: var(--panel); + border-radius: 999px; + padding: 0.35rem 0.7rem; + font-size: 0.9rem; +} + +.center-screen { + min-height: 55vh; + display: grid; + place-content: center; + text-align: center; +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: var(--space-3); +} + +@media (max-width: 767px) { + .page-header { + flex-direction: column; + align-items: flex-start; + } +} + +.project-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.project-card { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; +} + +.project-info h3 { + margin: 0 0 0.35rem; +} + +.project-info p { + margin: 0; +} + +.project-actions { + display: flex; + gap: 0.5rem; + align-items: center; + flex-shrink: 0; +} + +.delete-confirm { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.danger-button { + border-radius: 10px; + border: 1px solid transparent; + padding: 0.58rem 0.85rem; + cursor: pointer; + font: inherit; + background: #b91c1c; + color: white; +} + +.danger-text { + color: #b91c1c; +} + +.error-text { + color: #b91c1c; + margin: 0; +} + +.dialog-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.35); + display: grid; + place-content: center; + z-index: 50; +} + +.dialog { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 14px; + padding: var(--space-5); + width: min(560px, 100vw - 2rem); + max-height: min(800px, 100vh - 2rem); + overflow-y: auto; +} + +.dialog-lg { + width: min(800px, 100vw - 2rem); +} + +@media (max-width: 767px) { + .dialog { + width: 100vw; + height: 100vh; + max-height: 100vh; + border-radius: 0; + padding: var(--space-4); + } + + .dialog-overlay { + padding: 0; + } +} + +.dialog h2 { + margin: 0 0 1rem; +} + +.form-field { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.form-field input, +.form-field textarea, +.form-field select { + padding: 0.55rem 0.7rem; + border: 1px solid var(--border); + border-radius: 10px; + font: inherit; + background: var(--panel); + color: var(--ink); +} + +.dialog-actions { + display: flex; + justify-content: flex-end; + gap: 0.6rem; +} + +.keys-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.key-card { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 14px; + padding: 1rem; +} + +.key-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; +} + +.key-header h3 { + margin: 0; +} + +.key-meta { + margin-bottom: 0.75rem; +} + +.key-public { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem; + background: #f5f3ee; + border-radius: 8px; +} + +.key-public code { + font-size: 0.85rem; + word-break: break-all; + flex: 1; +} + + 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; + background: #fef2f2; + border-radius: 10px; +} + +@media (max-width: 860px) { + .shell-body { + grid-template-columns: 1fr; + } + + .shell-nav { + flex-direction: row; + overflow-x: auto; + border-right: 0; + border-bottom: 1px solid var(--border); + } + + .card-grid { + grid-template-columns: 1fr; + } + + .quick-actions { + flex-direction: column; + } + + .project-card { + flex-direction: column; + } +} + +/* URL Validation Styles */ +.valid-url { + border-color: var(--success, #16a34a) !important; + background-color: var(--success-light, #f0fdf4) !important; +} +/* ============================================ + Responsive Design System + ============================================ */ + +/* Layout Utilities */ +.container { + width: 100%; + max-width: min(1200px, 100vw - 2rem); + margin-inline: auto; + padding-inline: var(--space-4); +} + +.stack { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.stack-sm { + gap: var(--space-2); +} + +.stack-md { + gap: var(--space-4); +} + +.stack-lg { + gap: var(--space-6); +} + +.row { + display: flex; + flex-wrap: wrap; + gap: var(--space-4); + align-items: center; +} + +.grid { + display: grid; + gap: var(--space-4); + grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr)); +} + +/* Text Utilities */ +.truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.truncate-multiline { + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.break-word { + overflow-wrap: break-word; + word-wrap: break-word; + hyphens: auto; +} + +/* Touch Targets */ +.button, +.nav-link, +.icon-button, +button, +[role="button"] { + min-height: 44px; + min-width: 44px; +} + +/* Dialog Responsive */ +.dialog { + width: min(560px, 100vw - 2rem); + max-height: min(800px, 100vh - 2rem); + overflow-y: auto; +} + +.dialog-lg { + width: min(800px, 100vw - 2rem); +} + +/* Table Responsive */ +.table-responsive { + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +.table-responsive table { + min-width: 100%; +} + +/* Mobile-first Media Queries */ +@media (max-width: 767px) { + /* Card layout for tables on mobile */ + .table-responsive thead { + display: none; + } + + .table-responsive tbody tr { + display: block; + margin-bottom: var(--space-4); + border: 1px solid var(--border); + border-radius: 8px; + padding: var(--space-4); + } + + .table-responsive td { + display: flex; + justify-content: space-between; + padding: var(--space-2) 0; + border-bottom: 1px solid var(--border); + } + + .table-responsive td:last-child { + border-bottom: none; + } + + .table-responsive td::before { + content: attr(data-label); + font-weight: 600; + margin-right: var(--space-2); + } + + /* Larger touch targets on mobile */ + .button, + button { + padding: var(--space-3) var(--space-4); + } + + /* Full-width inputs on mobile */ + input, + select, + textarea { + width: 100%; + min-height: 44px; + } +} + +/* Small devices */ +@media (min-width: 480px) { + .container { + padding-inline: var(--space-5); + } +} + +/* Medium devices */ +@media (min-width: 768px) { + .container { + padding-inline: var(--space-6); + } +} + +/* Large devices */ +@media (min-width: 1024px) { + .container { + padding-inline: var(--space-8); + } +} + +/* Extra large devices */ +@media (min-width: 1280px) { + .container { + max-width: 1200px; + } +} + +/* ============================================ + Page-Specific Responsive Styles + ============================================ */ + +/* Forms - Full width on mobile */ +@media (max-width: 767px) { + .form-field input, + .form-field textarea, + .form-field select { + width: 100%; + min-height: 44px; + } + + /* Settings page stack */ + .settings-layout { + flex-direction: column; + } + + .settings-nav { + flex-direction: row; + overflow-x: auto; + padding-bottom: var(--space-2); + } + + /* Project cards full width */ + .project-card { + flex-direction: column; + gap: var(--space-3); + } + + .project-actions { + width: 100%; + justify-content: flex-start; + } + + /* Repository cards */ + .repository-card { + flex-direction: column; + gap: var(--space-3); + align-items: flex-start; + } + + .repository-actions { + width: 100%; + justify-content: flex-start; + } + + /* Git toolbar wrap */ + .git-toolbar { + flex-wrap: wrap; + gap: var(--space-2); + } + + /* Dashboard cards */ + .dashboard-grid { + grid-template-columns: 1fr; + } + + /* Stack form actions */ + .form-actions { + flex-direction: column; + width: 100%; + } + + .form-actions button { + width: 100%; + justify-content: center; + } +} + +/* Ensure all pages have proper padding */ +.stack > h1, +.stack > h2 { + margin-top: 0; +} + +/* Touch target verification override */ +button, +[role="button"], +a.nav-item, +.tree-entry { + min-height: 44px; +} + +/* Table horizontal scroll wrapper */ +.table-wrapper { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + width: 100%; +} + +/* Dialog content spacing */ +.dialog-body { + padding: var(--space-4) 0; +} + +/* Prevent text overflow in cards */ +.card h3, +.card p { + overflow: hidden; + text-overflow: ellipsis; +} + +/* SSH keys table responsive */ +.ssh-key-list { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.ssh-key-item { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding: var(--space-4); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 10px; +} + +@media (min-width: 768px) { + .ssh-key-item { + flex-direction: row; + justify-content: space-between; + align-items: center; + } +} + +/* Session Navigation */ +.session-item { + position: relative; + padding-left: var(--space-6); +} + +.session-status { + position: absolute; + left: var(--space-2); + top: 50%; + transform: translateY(-50%); + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--muted); +} + +.session-status.running { + background: var(--success); +} + +.session-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 140px; +} + +.nav-divider { + height: 1px; + background: var(--border); + margin: var(--space-2) var(--space-3); +} + +.nav-section-title { + padding: var(--space-2) var(--space-4); + font-size: var(--text-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted); +} + +/* Instance List */ +.instance-list { + margin-top: var(--space-4); +} + +.instance-list-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--space-4); +} + +.instance-list-header h3 { + margin: 0; +} + +.instance-grid { + display: grid; + gap: var(--space-3); +} + +.instance-card { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--space-4); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 10px; +} + +.instance-info { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.instance-name { + font-weight: 600; +} + +.instance-meta { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--text-sm); + color: var(--muted); +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; +} + +.instance-actions { + display: flex; + gap: var(--space-2); + align-items: center; +} + +/* ============================================ diff --git a/openspec/changes/repo-restructure/apply-2.1-report.md b/openspec/changes/repo-restructure/apply-2.1-report.md new file mode 100644 index 0000000..fc92b28 --- /dev/null +++ b/openspec/changes/repo-restructure/apply-2.1-report.md @@ -0,0 +1,67 @@ +# Task 2.1 Apply Report: Extract Global Styles and Tokens + +**Status:** Success + +## Files Created (4) + +- `apps/web/src/styles/tokens.css` (69 lines) — CSS custom properties: + - `:root` with all design tokens (colors, spacing, breakpoints, typography) + - `[data-theme="dark"]` with dark mode overrides + +- `apps/web/src/styles/global.css` (127 lines) — Global resets and shell layout: + - `* { box-sizing: border-box; }` + - `body` reset with theme background + - `a` link reset + - `.shell`, `.shell-header`, `.shell-body`, `.shell-nav`, `.shell-content` + - `.nav-item`, `.nav-item-active`, `.nav-section-title`, `.nav-divider` + - `.brand`, `.header-actions` + - `.eyebrow` + - Responsive shell media query (`@media (max-width: 767px)`) + +- `apps/web/src/styles/utilities.css` (718 lines) — Utility classes and generic primitives: + - `.stack`, `.stack-sm`, `.stack-md`, `.stack-lg` + - `.row`, `.grid` + - `.truncate`, `.truncate-multiline`, `.break-word` + - Touch target utilities (`min-height: 44px`) + - `.container` with responsive breakpoints + - `.card-grid`, `.card`, `.card-label`, `.card-value` + - `.primary-button`, `.secondary-button`, `.ghost-button` + - `.user-chip`, `.center-screen`, `.page-header` + - `.dialog-overlay`, `.dialog`, `.dialog-lg` + - `.form-field`, `.form-group`, `.dialog-actions` + - `.error-text`, `.success-text`, `.danger-text`, `.danger-button` + - `.small`, `.muted` + - URL validation styles + - Responsive table/card layout utilities + - Icon system utilities + +- `apps/web/src/styles/syntax-highlight.css` (131 lines) — Prism.js theme: + - `code[class*="language-"]`, `pre[class*="language-"]` base styles + - All `.token.*` color rules (comment, keyword, string, function, etc.) + - `.language-css .token.string` override + +## Files Modified (1) + +- `apps/web/src/main.tsx` — Replaced `import "./styles.css";` with: + ```ts + import "./styles/tokens.css"; + import "./styles/global.css"; + import "./styles/utilities.css"; + import "./styles/syntax-highlight.css"; + ``` + +## Files Preserved + +- `apps/web/src/styles.css` — Kept intact for backward compatibility. Component/page-specific styles remain here and will be extracted into CSS Modules in Tasks 2.2 and 2.3. + +## Quality Gate Results + +- `npm run build` — **PASS** — Build succeeds, output CSS 17.58 kB +- `npm run lint` — **PASS** — Zero warnings +- Visual sanity: Shell layout, navigation, and base styles load correctly via the new imports + +## Notes + +- `utilities.css` is 718 lines because it contains many generic primitives (.card, .button, .dialog, .form-field) that are used across multiple components. These will be further split into CSS Modules in Tasks 2.2–2.3 as components are extracted. +- No CSS rules were modified during extraction — pure copy-paste. +- The `styles.css` file still exists and is functional; it will be deleted in Task 2.3 after all component/page styles are extracted into CSS Modules.