diff --git a/Packages/EaselChat/Sources/EaselChat/EaselAgentInstructions.swift b/Packages/EaselChat/Sources/EaselChat/EaselAgentInstructions.swift index d2907f8..53b9295 100644 --- a/Packages/EaselChat/Sources/EaselChat/EaselAgentInstructions.swift +++ b/Packages/EaselChat/Sources/EaselChat/EaselAgentInstructions.swift @@ -26,7 +26,7 @@ enum EaselAgentInstructions { - Do not open external browser apps or use shell commands such as `open`, `open -a`, `xdg-open`, or `start` to preview project UI. - Write or copy every generated project asset into the project's resources/ folder before referencing it from app UI. - Codebases listed in `resources/codebase-references/` are external user repositories attached as read-only reference context. You may inspect them, but never modify files there, create files there, delete files there, format files there, run package installs/builds/generators there, or run git commands that change their state. Make all implementation changes inside the current Easel project directory. - - When the project ships a design system, it is the source of truth. Before writing any UI, read its spec at `resources/design-system/DESIGN.md`, then build every screen or slide directly from that system: reuse its exact colors, typography, spacing, radii, effects, and component families instead of inventing an ad-hoc palette, type scale, or component style. If you need a token the system does not define, extend it consistently rather than departing from it. + - When the project ships a design system, it is the source of truth. Before writing any UI, read its spec at `resources/design-system/DESIGN.md`, then build every screen or slide directly from that system: reuse its exact colors, typography, spacing, radii, effects, and component families instead of inventing an ad-hoc palette, type scale, or component style. Reusable design-system assets are bundled under `resources/design-system/resources/`; inspect and reference them when they fit the prototype. If you need a token the system does not define, extend it consistently rather than departing from it. - For slide deck projects, \(SlideDeckContract.authoringSummary) - For slide deck creation, \(slideDeckCreationGuidance) """ @@ -267,7 +267,7 @@ enum EaselAgentInstructions { } if let designSystem, designSystem.kind == .custom { - lines.append("Active design system: \(designSystem.displayName). Its spec is in this project at resources/design-system/DESIGN.md. Read it before writing UI and build directly from its colors, typography, spacing, radii, effects, and component families — do not improvise a different palette or type scale when the design system already defines one.") + lines.append("Active design system: \(designSystem.displayName). Its spec is in this project at resources/design-system/DESIGN.md, with reusable assets under resources/design-system/resources/. Read the spec before writing UI, inspect those assets when relevant, and build directly from its colors, typography, spacing, radii, effects, and component families — do not improvise a different palette or type scale when the design system already defines one.") } if !resourcePaths.isEmpty { diff --git a/Packages/EaselChat/Sources/EaselChat/Projects/EaselProjectManager.swift b/Packages/EaselChat/Sources/EaselChat/Projects/EaselProjectManager.swift index fc93492..b427543 100644 --- a/Packages/EaselChat/Sources/EaselChat/Projects/EaselProjectManager.swift +++ b/Packages/EaselChat/Sources/EaselChat/Projects/EaselProjectManager.swift @@ -30,6 +30,11 @@ public actor LocalEaselProjectManager: EaselProjectManaging { private let encoder: JSONEncoder private let decoder: JSONDecoder + private static let skippedReusableResourceDirectoryNames: Set = [ + ".git", ".svn", ".build", "DerivedData", "node_modules", + ".next", ".nuxt", "dist", "build", "coverage", ".cache", ".easel", + ] + public init( rootDirectory: URL? = nil, fileManager: FileManager = .default @@ -185,33 +190,48 @@ public actor LocalEaselProjectManager: EaselProjectManaging { ) } - writeDesignSystemBrief(for: project, at: directoryURL) + writeDesignSystemBundle(for: project, at: directoryURL) } - /// Provides the custom design system's spec to the project so the agent can - /// reuse its tokens/components. Prefers the canonical, spec-compliant - /// `DESIGN.md` authored by the design system; falls back to a derived brief - /// only for legacy systems that predate it. - private func writeDesignSystemBrief(for project: EaselDesignProject, at directoryURL: URL) { + /// Provides the custom design system's spec and reusable assets to the + /// project. The bundle is copied into project-local resources so prototypes + /// can reference it without depending on the design-system source folder. + private func writeDesignSystemBundle(for project: EaselDesignProject, at directoryURL: URL) { let choice = project.designSystem guard choice.kind == .custom, let designSystemDirectory = choice.workingDirectory else { return } - let briefDirectory = directoryURL + let designSystemURL = URL(fileURLWithPath: designSystemDirectory, isDirectory: true) + let bundleDirectory = directoryURL .appendingPathComponent(ProjectResource.resourcesDirectoryName, isDirectory: true) .appendingPathComponent("design-system", isDirectory: true) - try? fileManager.createDirectory(at: briefDirectory, withIntermediateDirectories: true) - let destination = briefDirectory.appendingPathComponent("DESIGN.md") + try? fileManager.createDirectory(at: bundleDirectory, withIntermediateDirectories: true) + + writeDesignSystemMarkdown( + from: designSystemURL, + choice: choice, + to: bundleDirectory + ) + copyReusableDesignSystemResources( + from: designSystemURL, + into: bundleDirectory.appendingPathComponent("resources", isDirectory: true) + ) + } - let canonicalURL = URL(fileURLWithPath: designSystemDirectory, isDirectory: true) - .appendingPathComponent("DESIGN.md") + private func writeDesignSystemMarkdown( + from designSystemURL: URL, + choice: EaselDesignSystemChoice, + to bundleDirectory: URL + ) { + let destination = bundleDirectory.appendingPathComponent("DESIGN.md") + let canonicalURL = designSystemURL.appendingPathComponent("DESIGN.md") if let canonical = try? Data(contentsOf: canonicalURL), !canonical.isEmpty { try? canonical.write(to: destination, options: .atomic) return } - let catalog = loadDesignSystemCatalog(atDesignSystemDirectory: designSystemDirectory) + let catalog = loadDesignSystemCatalog(atDesignSystemDirectory: designSystemURL.path) let markdown = DesignSystemBriefBuilder.markdown( displayName: choice.displayName, detail: choice.detail, @@ -221,6 +241,84 @@ public actor LocalEaselProjectManager: EaselProjectManaging { try? Data(markdown.utf8).write(to: destination, options: .atomic) } + private func copyReusableDesignSystemResources(from designSystemURL: URL, into resourcesDirectory: URL) { + try? fileManager.createDirectory(at: resourcesDirectory, withIntermediateDirectories: true) + + let groups: [(source: URL, destinationName: String)] = [ + ( + designSystemURL + .appendingPathComponent(".easel", isDirectory: true) + .appendingPathComponent("assets", isDirectory: true), + "figma-extracted" + ), + ( + designSystemURL + .appendingPathComponent(ProjectResource.resourcesDirectoryName, isDirectory: true) + .appendingPathComponent("assets", isDirectory: true), + "imported-assets" + ), + ] + + for group in groups { + let destination = resourcesDirectory.appendingPathComponent(group.destinationName, isDirectory: true) + try? copyReusableResourceFiles(from: group.source, to: destination) + } + } + + private func copyReusableResourceFiles(from sourceDirectory: URL, to destinationDirectory: URL) throws { + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: sourceDirectory.path, isDirectory: &isDirectory), + isDirectory.boolValue else { + return + } + + try fileManager.createDirectory(at: destinationDirectory, withIntermediateDirectories: true) + guard let enumerator = fileManager.enumerator( + at: sourceDirectory, + includingPropertiesForKeys: [.isDirectoryKey, .isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { + return + } + + while let itemURL = enumerator.nextObject() as? URL { + let values = try itemURL.resourceValues(forKeys: [.isDirectoryKey, .isRegularFileKey]) + if values.isDirectory == true { + if Self.skippedReusableResourceDirectoryNames.contains(itemURL.lastPathComponent) { + enumerator.skipDescendants() + } + continue + } + + guard values.isRegularFile == true, + let relativePath = relativePath(for: itemURL, relativeTo: sourceDirectory) else { + continue + } + + let destinationURL = destinationDirectory.appendingPathComponent(relativePath) + try fileManager.createDirectory( + at: destinationURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + if fileManager.fileExists(atPath: destinationURL.path) { + try fileManager.removeItem(at: destinationURL) + } + try fileManager.copyItem(at: itemURL, to: destinationURL) + } + } + + private func relativePath(for fileURL: URL, relativeTo rootURL: URL) -> String? { + let filePath = fileURL.standardizedFileURL.resolvingSymlinksInPath().path + let rootPath = rootURL.standardizedFileURL.resolvingSymlinksInPath().path + let rootPrefix = rootPath + "/" + + guard filePath.hasPrefix(rootPrefix) else { + return nil + } + + return String(filePath.dropFirst(rootPrefix.count)) + } + private func loadDesignSystemCatalog(atDesignSystemDirectory directory: String) -> EaselDesignSystemCatalog? { let catalogURL = URL(fileURLWithPath: directory, isDirectory: true) .appendingPathComponent(".easel", isDirectory: true) @@ -325,7 +423,7 @@ public actor LocalEaselProjectManager: EaselProjectManaging { metadataLines.append("- Design system: \(project.designSystem.displayName)") let designSystemGuidance = project.designSystem.kind == .custom - ? "\nThis project uses the **\(project.designSystem.displayName)** design system. Follow `resources/design-system/DESIGN.md` for its colors, type, spacing, and component families, and reuse them throughout the UI.\n" + ? "\nThis project uses the **\(project.designSystem.displayName)** design system. Follow `resources/design-system/DESIGN.md` for its colors, type, spacing, and component families. Reusable design-system assets are bundled under `resources/design-system/resources/`.\n" : "" let slideDeckGuidance = project.kind == .slideDeck diff --git a/Packages/EaselChat/Tests/EaselChatTests/EaselAgentInstructionsTests.swift b/Packages/EaselChat/Tests/EaselChatTests/EaselAgentInstructionsTests.swift index c097bf1..6e42c74 100644 --- a/Packages/EaselChat/Tests/EaselChatTests/EaselAgentInstructionsTests.swift +++ b/Packages/EaselChat/Tests/EaselChatTests/EaselAgentInstructionsTests.swift @@ -24,6 +24,7 @@ struct EaselAgentInstructionsTests { #expect(prefix.contains("Easel")) // The agent must treat a bundled design system as the source of truth. #expect(prefix.contains("resources/design-system/DESIGN.md")) + #expect(prefix.contains("resources/design-system/resources/")) #expect(prefix.contains("source of truth")) #expect(prefix.contains("component families")) // Slide deck projects get presentation-design guidance in addition to the layout contract. @@ -58,6 +59,7 @@ struct EaselAgentInstructionsTests { #expect(context.contains("Active design system: PlusPlus")) #expect(context.contains("resources/design-system/DESIGN.md")) + #expect(context.contains("resources/design-system/resources/")) #expect(context.contains("do not improvise a different palette")) } diff --git a/Packages/EaselChat/Tests/EaselChatTests/EaselProjectManagerTests.swift b/Packages/EaselChat/Tests/EaselChatTests/EaselProjectManagerTests.swift index 409ead5..851c792 100644 --- a/Packages/EaselChat/Tests/EaselChatTests/EaselProjectManagerTests.swift +++ b/Packages/EaselChat/Tests/EaselChatTests/EaselProjectManagerTests.swift @@ -295,10 +295,87 @@ struct EaselProjectManagerTests { let readme = try String(contentsOf: projectURL.appendingPathComponent("README.md"), encoding: .utf8) #expect(readme.contains("resources/design-system/DESIGN.md")) + #expect(readme.contains("resources/design-system/resources/")) + } + + @Test + func createProjectCopiesCustomDesignSystemReusableAssetsIntoGroupedResources() async throws { + let rootDirectory = temporaryRoot() + let designSystemDirectory = temporaryRoot() + defer { + try? FileManager.default.removeItem(at: rootDirectory) + try? FileManager.default.removeItem(at: designSystemDirectory) + } + + try write("# Plus UI Canonical\n\nUse the system.", to: designSystemDirectory.appendingPathComponent("DESIGN.md")) + try writeData(Data([1, 2, 3]), to: designSystemDirectory.appendingPathComponent(".easel/assets/thumbnail.png")) + try writeData(Data([4, 5, 6]), to: designSystemDirectory.appendingPathComponent(".easel/assets/images/logo.png")) + try writeData(Data([7, 8, 9]), to: designSystemDirectory.appendingPathComponent("resources/assets/fonts/Brand.woff2")) + try write("", to: designSystemDirectory.appendingPathComponent("resources/assets/icons/logo.svg")) + try write("ignored dependency", to: designSystemDirectory.appendingPathComponent("resources/assets/node_modules/pkg/index.js")) + try writeData(Data([10, 11, 12]), to: designSystemDirectory.appendingPathComponent("resources/figma/System.fig")) + try write("struct Button {}", to: designSystemDirectory.appendingPathComponent("resources/code/Button.swift")) + + let profile = EaselDesignSystemProfile( + id: UUID(), + name: "Plus UI", + blurb: "Plus UI kit", + notes: "", + sourceLinks: [], + workingDirectory: designSystemDirectory.path, + createdAt: Date(), + updatedAt: Date() + ) + + let manager = LocalEaselProjectManager(rootDirectory: rootDirectory) + let project = try await manager.createProject(from: EaselProjectCreateRequest( + name: "Asset Landing", + kind: .prototype, + designSystem: .custom(profile), + fidelity: .highFidelity + )) + + let projectURL = URL(fileURLWithPath: project.workingDirectory) + let designMarkdownURL = projectURL.appendingPathComponent("resources/design-system/DESIGN.md") + let designMarkdown = try String(contentsOf: designMarkdownURL, encoding: .utf8) + #expect(designMarkdown.contains("# Plus UI Canonical")) + + let copiedRoot = projectURL.appendingPathComponent("resources/design-system/resources", isDirectory: true) + #expect(FileManager.default.fileExists(atPath: copiedRoot.appendingPathComponent("figma-extracted/thumbnail.png").path)) + #expect(FileManager.default.fileExists(atPath: copiedRoot.appendingPathComponent("figma-extracted/images/logo.png").path)) + #expect(FileManager.default.fileExists(atPath: copiedRoot.appendingPathComponent("imported-assets/fonts/Brand.woff2").path)) + #expect(FileManager.default.fileExists(atPath: copiedRoot.appendingPathComponent("imported-assets/icons/logo.svg").path)) + #expect(FileManager.default.fileExists(atPath: copiedRoot.appendingPathComponent("imported-assets/node_modules/pkg/index.js").path) == false) + #expect(FileManager.default.fileExists(atPath: copiedRoot.appendingPathComponent("figma/System.fig").path) == false) + #expect(FileManager.default.fileExists(atPath: copiedRoot.appendingPathComponent("code/Button.swift").path) == false) + + let resources = try await LocalProjectResourceManager().loadResources(forProjectAt: project.workingDirectory) + let resourcePaths = Set(resources.map(\.relativePath)) + #expect(resourcePaths.contains("resources/design-system/DESIGN.md")) + #expect(resourcePaths.contains("resources/design-system/resources/figma-extracted/thumbnail.png")) + #expect(resourcePaths.contains("resources/design-system/resources/figma-extracted/images/logo.png")) + #expect(resourcePaths.contains("resources/design-system/resources/imported-assets/fonts/Brand.woff2")) + #expect(resourcePaths.contains("resources/design-system/resources/imported-assets/icons/logo.svg")) } private func temporaryRoot() -> URL { FileManager.default.temporaryDirectory .appendingPathComponent("EaselProjectManagerTests-\(UUID().uuidString)", isDirectory: true) } + + private func write(_ string: String, to url: URL) throws { + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Data(string.utf8).write(to: url) + } + + private func writeData(_ data: Data, to url: URL) throws { + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try data.write(to: url) + } }