Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
"""
Expand Down Expand Up @@ -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 {
Expand Down
124 changes: 111 additions & 13 deletions Packages/EaselChat/Sources/EaselChat/Projects/EaselProjectManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ public actor LocalEaselProjectManager: EaselProjectManaging {
private let encoder: JSONEncoder
private let decoder: JSONDecoder

private static let skippedReusableResourceDirectoryNames: Set<String> = [
".git", ".svn", ".build", "DerivedData", "node_modules",
".next", ".nuxt", "dist", "build", "coverage", ".cache", ".easel",
]

public init(
rootDirectory: URL? = nil,
fileManager: FileManager = .default
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"))
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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("<svg></svg>", 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)
}
}
Loading