From f6942880a8afb617589ef53e4d6e93a55ff536e3 Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 26 May 2026 00:36:22 +0700 Subject: [PATCH] feat(macos): add OpenWorlds product shell --- docs/OPENWORLDS_DESIGN_ASSET_POLICY.md | 24 ++ .../Services/AppProcessService.swift | 1 + .../Views/Design/OpenWorldsComponents.swift | 204 +++++++++ .../Views/Design/OpenWorldsTheme.swift | 93 ++++ .../Sources/ClawDnDApp/Views/PlayView.swift | 398 +++++++++++++++--- .../Sources/ClawDnDApp/Views/RootView.swift | 141 ++++++- 6 files changed, 782 insertions(+), 79 deletions(-) create mode 100644 docs/OPENWORLDS_DESIGN_ASSET_POLICY.md create mode 100644 macos/ClawDnDApp/Sources/ClawDnDApp/Views/Design/OpenWorldsComponents.swift create mode 100644 macos/ClawDnDApp/Sources/ClawDnDApp/Views/Design/OpenWorldsTheme.swift diff --git a/docs/OPENWORLDS_DESIGN_ASSET_POLICY.md b/docs/OPENWORLDS_DESIGN_ASSET_POLICY.md new file mode 100644 index 00000000..d11ca90e --- /dev/null +++ b/docs/OPENWORLDS_DESIGN_ASSET_POLICY.md @@ -0,0 +1,24 @@ +# OpenWorlds Design Asset Policy + +The OpenWorlds design bundle is a local product-design reference for the native macOS app and dashboard polish. It may guide layout, interaction structure, and abstract visual tokens, but it is not a source of production game assets or canonical ClawDnD content. + +## Allowed + +- Reimplement abstract layout ideas such as a navigation rail, parchment panels, brass controls, campaign launcher, and CRPG-style status surfaces. +- Translate color, spacing, typography, and component intent into ClawDnD-owned code. +- Use generated or original ClawDnD assets in later PRs when their source/license is documented. +- Reference local scratch artifacts under `/Volumes/LEXAR/Codex/openworlds-design-2026-05-25/` in issue and PR bodies. + +## Not Allowed + +- Commit `OpenWorlds.zip`, extracted screenshots, pasted reference images, or binary mockup assets from the design bundle. +- Copy Owlcat, Baldur's Gate, D&D-private, or other proprietary UI art into the repo. +- Promote prototype/test copy from the bundle into canonical campaign, lore, world seed, item, NPC, or companion content. +- Let visual UI code write `play-state`, `qa/state`, engine snapshots, or provider state directly. + +## PR Checklist + +- State whether any assets were copied. The expected answer for OpenWorlds visual PRs is "no third-party/reference assets copied." +- Keep visual/design implementation scoped to `macos/`, `viewer/`, `docs/`, or build scripts unless the issue explicitly needs engine read models. +- Run `python3 scripts/license_check.py` for PRs that touch content, data, licensing docs, or bundled assets. +- Confirm `git status --short` does not include extracted design artifacts, runtime state, `.build/`, `dist/`, transcripts, or private content. diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Services/AppProcessService.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/AppProcessService.swift index 335e8692..5b1a78dd 100644 --- a/macos/ClawDnDApp/Sources/ClawDnDApp/Services/AppProcessService.swift +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/AppProcessService.swift @@ -103,6 +103,7 @@ final class AppProcessService: ObservableObject { endpoint.status = .stopped viewerEndpoint = endpoint } + activeCampaignID = nil } func startProviderSession( diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/Design/OpenWorldsComponents.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/Design/OpenWorldsComponents.swift new file mode 100644 index 00000000..a030e7ff --- /dev/null +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/Design/OpenWorldsComponents.swift @@ -0,0 +1,204 @@ +import SwiftUI + +struct OpenWorldsPanel: View { + let title: String? + let subtitle: String? + let icon: String? + let content: Content + + init( + title: String? = nil, + subtitle: String? = nil, + icon: String? = nil, + @ViewBuilder content: () -> Content + ) { + self.title = title + self.subtitle = subtitle + self.icon = icon + self.content = content() + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + if title != nil || subtitle != nil || icon != nil { + HStack(alignment: .firstTextBaseline, spacing: 10) { + if let icon { + Image(systemName: icon) + .foregroundStyle(OpenWorldsTheme.crimson) + } + VStack(alignment: .leading, spacing: 2) { + if let title { + Text(title) + .font(.title3.weight(.semibold)) + .foregroundStyle(OpenWorldsTheme.ink800) + } + if let subtitle { + Text(subtitle) + .font(.caption) + .foregroundStyle(OpenWorldsTheme.ink600) + } + } + Spacer() + } + OpenWorldsDivider() + } + content + } + .padding(18) + .background(OpenWorldsParchmentBackground()) + .clipShape(RoundedRectangle(cornerRadius: OpenWorldsTheme.panelRadius)) + .overlay { + RoundedRectangle(cornerRadius: OpenWorldsTheme.panelRadius) + .stroke(OpenWorldsTheme.parchmentEdge.opacity(0.62), lineWidth: 1) + } + .overlay(alignment: .topLeading) { + OpenWorldsCornerOrnament() + .frame(width: 28, height: 28) + .padding(7) + } + .overlay(alignment: .topTrailing) { + OpenWorldsCornerOrnament() + .rotationEffect(.degrees(90)) + .frame(width: 28, height: 28) + .padding(7) + } + .shadow(color: .black.opacity(0.24), radius: 16, x: 0, y: 8) + } +} + + +struct OpenWorldsDivider: View { + var body: some View { + HStack(spacing: 8) { + Rectangle() + .fill(OpenWorldsTheme.parchmentEdge.opacity(0.42)) + .frame(height: 1) + Diamond() + .fill(OpenWorldsTheme.brass400) + .frame(width: 7, height: 7) + Rectangle() + .fill(OpenWorldsTheme.parchmentEdge.opacity(0.42)) + .frame(height: 1) + } + } +} + +struct Diamond: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: rect.midX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.midY)) + path.addLine(to: CGPoint(x: rect.midX, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.minX, y: rect.midY)) + path.closeSubpath() + return path + } +} + +struct OpenWorldsCornerOrnament: View { + var body: some View { + GeometryReader { proxy in + Path { path in + let w = proxy.size.width + let h = proxy.size.height + path.move(to: CGPoint(x: 2, y: h * 0.34)) + path.addCurve( + to: CGPoint(x: w * 0.34, y: 2), + control1: CGPoint(x: 2, y: 8), + control2: CGPoint(x: 8, y: 2) + ) + path.move(to: CGPoint(x: 2, y: h * 0.58)) + path.addCurve( + to: CGPoint(x: w * 0.58, y: 2), + control1: CGPoint(x: 2, y: 10), + control2: CGPoint(x: 10, y: 2) + ) + path.move(to: CGPoint(x: w * 0.22, y: h * 0.22)) + path.addCurve( + to: CGPoint(x: w * 0.48, y: h * 0.12), + control1: CGPoint(x: w * 0.34, y: h * 0.24), + control2: CGPoint(x: w * 0.42, y: h * 0.18) + ) + } + .stroke(OpenWorldsTheme.brass600.opacity(0.64), style: StrokeStyle(lineWidth: 1.1, lineCap: .round)) + } + .allowsHitTesting(false) + .accessibilityHidden(true) + } +} + +struct OpenWorldsPill: View { + let text: String + var tone: Tone = .neutral + + enum Tone { + case neutral + case live + case warning + case danger + case royal + } + + var body: some View { + Text(text) + .font(.caption.weight(.semibold)) + .foregroundStyle(foreground) + .padding(.horizontal, 9) + .padding(.vertical, 4) + .background(background) + .clipShape(Capsule()) + } + + private var foreground: Color { + switch tone { + case .neutral: OpenWorldsTheme.ink700 + case .live: .white + case .warning: OpenWorldsTheme.ink800 + case .danger: .white + case .royal: .white + } + } + + private var background: Color { + switch tone { + case .neutral: OpenWorldsTheme.parchment300.opacity(0.9) + case .live: OpenWorldsTheme.emerald + case .warning: OpenWorldsTheme.brass300 + case .danger: OpenWorldsTheme.crimson + case .royal: OpenWorldsTheme.royal + } + } +} + +struct OpenWorldsBrassButtonStyle: ButtonStyle { + var prominent = false + var danger = false + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.callout.weight(.semibold)) + .foregroundStyle(danger || prominent ? Color.white : OpenWorldsTheme.ink800) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(minHeight: 32) + .background(background(configuration: configuration)) + .clipShape(RoundedRectangle(cornerRadius: 5)) + .overlay { + RoundedRectangle(cornerRadius: 5) + .stroke(OpenWorldsTheme.brass600.opacity(0.55), lineWidth: 1) + } + .opacity(configuration.isPressed ? 0.78 : 1) + } + + private func background(configuration: Configuration) -> some View { + let base: [Color] + if danger { + base = [OpenWorldsTheme.crimson, OpenWorldsTheme.crimson.opacity(0.82)] + } else if prominent { + base = [OpenWorldsTheme.royal, OpenWorldsTheme.royal.opacity(0.82)] + } else { + base = [OpenWorldsTheme.brass100, OpenWorldsTheme.brass300] + } + return LinearGradient(colors: base, startPoint: .top, endPoint: .bottom) + } +} diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/Design/OpenWorldsTheme.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/Design/OpenWorldsTheme.swift new file mode 100644 index 00000000..a7b9e8ce --- /dev/null +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/Design/OpenWorldsTheme.swift @@ -0,0 +1,93 @@ +import SwiftUI + +enum OpenWorldsTheme { + static let parchment100 = Color(hex: 0xF6ECD2) + static let parchment200 = Color(hex: 0xF2E7CE) + static let parchment300 = Color(hex: 0xEAD9B5) + static let parchment500 = Color(hex: 0xC8B287) + static let parchmentEdge = Color(hex: 0x8A7146) + + static let walnut100 = Color(hex: 0x5A3E2B) + static let walnut200 = Color(hex: 0x4B3425) + static let walnut300 = Color(hex: 0x3A281B) + static let walnut400 = Color(hex: 0x2A1D14) + + static let brass100 = Color(hex: 0xF0D8A0) + static let brass200 = Color(hex: 0xD4B97A) + static let brass300 = Color(hex: 0xC9A66B) + static let brass400 = Color(hex: 0xB08D57) + static let brass600 = Color(hex: 0x5D4827) + + static let ink900 = Color(hex: 0x1C130A) + static let ink800 = Color(hex: 0x2C1F17) + static let ink700 = Color(hex: 0x4A3325) + static let ink600 = Color(hex: 0x6A4D35) + + static let royal = Color(hex: 0x22305E) + static let crimson = Color(hex: 0x6E1D1D) + static let emerald = Color(hex: 0x2F5A3A) + static let goldGlow = Color(hex: 0xF4D27B) + + static let railWidth: CGFloat = 92 + static let outerRadius: CGFloat = 8 + static let panelRadius: CGFloat = 7 +} + + +extension Color { + init(hex: UInt, opacity: Double = 1) { + self.init( + .sRGB, + red: Double((hex >> 16) & 0xff) / 255, + green: Double((hex >> 8) & 0xff) / 255, + blue: Double(hex & 0xff) / 255, + opacity: opacity + ) + } +} + +struct OpenWorldsWindowBackground: View { + var body: some View { + ZStack { + LinearGradient( + colors: [ + OpenWorldsTheme.walnut200, + OpenWorldsTheme.walnut300, + OpenWorldsTheme.walnut400 + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + OpenWorldsTheme.ink900 + .opacity(0.22) + .blendMode(.multiply) + OpenWorldsTheme.brass400 + .opacity(0.26) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .mask( + RoundedRectangle(cornerRadius: OpenWorldsTheme.outerRadius) + .stroke(lineWidth: 2) + .padding(8) + ) + } + .ignoresSafeArea() + } +} + +struct OpenWorldsParchmentBackground: View { + var body: some View { + ZStack { + LinearGradient( + colors: [ + OpenWorldsTheme.parchment100, + OpenWorldsTheme.parchment200, + OpenWorldsTheme.parchment300.opacity(0.92) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + OpenWorldsTheme.brass100.opacity(0.12) + .blendMode(.softLight) + } + } +} diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/PlayView.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/PlayView.swift index 71378702..d4ceb891 100644 --- a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/PlayView.swift +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/PlayView.swift @@ -2,6 +2,7 @@ import SwiftUI struct PlayView: View { @EnvironmentObject private var processService: AppProcessService + @EnvironmentObject private var campaignStore: CampaignStore @Binding var repoPath: String @Binding var preferredPort: Int @@ -17,15 +18,26 @@ struct PlayView: View { @State private var runID: String = PlayView.newRunID() @State private var companions: String = "" + @State private var selectedCampaignID: CampaignSummary.ID? @State private var alertMessage: String? @State private var webViewErrorMessage: String? var body: some View { VStack(spacing: 0) { - controlBar - Divider() + if webURL != nil { + controlBar + OpenWorldsDivider() + } webSurface } + .background(OpenWorldsParchmentBackground()) + .onAppear { + campaignStore.reload(repoPath: repoPath) + selectFirstCampaignIfNeeded() + } + .onChange(of: campaignStore.campaigns) { _ in + selectFirstCampaignIfNeeded() + } .alert("ClawDnD could not start", isPresented: alertBinding) { Button("OK", role: .cancel) {} } message: { @@ -34,54 +46,45 @@ struct PlayView: View { } private var controlBar: some View { - VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .top, spacing: 12) { - VStack(alignment: .leading, spacing: 8) { - Text("Play") - .font(.title2.weight(.semibold)) - dependencySummary - } - Spacer() - Picker("Provider", selection: $selectedProviderRaw) { - ForEach(ProviderKind.allCases) { provider in - Label(provider.displayName, systemImage: provider.symbolName) - .tag(provider.rawValue) - } - } - .pickerStyle(.segmented) - .frame(width: 320) - } - - HStack(spacing: 10) { - TextField("World", text: $defaultWorld) - .textFieldStyle(.roundedBorder) - .frame(width: 180) - TextField("Run ID", text: $runID) - .textFieldStyle(.roundedBorder) - .frame(width: 240) - TextField("Companions", text: $companions) - .textFieldStyle(.roundedBorder) - Button { - startViewer() - } label: { - Label("Open Dashboard", systemImage: "rectangle.on.rectangle") - } - Button { - startProvider() - } label: { - Label("Start Game", systemImage: "play.fill") - } - .buttonStyle(.borderedProminent) - Button { - processService.stopProvider() - processService.stopViewer() - } label: { - Label("Stop", systemImage: "stop.fill") + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text("The Table") + .font(.title3.weight(.semibold)) + .foregroundStyle(OpenWorldsTheme.ink800) + dependencySummary + } + Spacer() + Picker("Provider", selection: $selectedProviderRaw) { + ForEach(ProviderKind.allCases) { provider in + Label(provider.displayName, systemImage: provider.symbolName) + .tag(provider.rawValue) } } + .pickerStyle(.segmented) + .frame(width: 320) + Button { + startViewer() + } label: { + Label("Dashboard", systemImage: "rectangle.on.rectangle") + } + .buttonStyle(OpenWorldsBrassButtonStyle()) + Button { + startProvider() + } label: { + Label("Start", systemImage: "play.fill") + } + .buttonStyle(OpenWorldsBrassButtonStyle(prominent: true)) + Button { + processService.stopProvider() + processService.stopViewer() + webURL = nil + } label: { + Label("Stop", systemImage: "stop.fill") + } + .buttonStyle(OpenWorldsBrassButtonStyle(danger: true)) } .padding(16) - .background(.thinMaterial) + .background(OpenWorldsTheme.parchment200.opacity(0.96)) } @ViewBuilder @@ -89,13 +92,17 @@ struct PlayView: View { let missing = processService.dependencies.filter { !$0.isInstalled } if missing.isEmpty { Label("Dependencies ready", systemImage: "checkmark.circle.fill") - .foregroundStyle(.green) + .font(.caption) + .foregroundStyle(OpenWorldsTheme.emerald) } else { HStack(spacing: 6) { Label("Missing", systemImage: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) + .font(.caption) + .foregroundStyle(OpenWorldsTheme.crimson) Text(missing.map(\.command).joined(separator: ", ")) - .foregroundStyle(.secondary) + .font(.caption) + .foregroundStyle(OpenWorldsTheme.ink600) + .lineLimit(1) } } } @@ -111,18 +118,34 @@ struct PlayView: View { WebView(url: webURL, navigationError: $webViewErrorMessage) } } else { - VStack(spacing: 18) { - Image(systemName: "gamecontroller") - .font(.system(size: 54, weight: .regular)) - .foregroundStyle(.secondary) - Text("Start a viewer or game session") - .font(.title3.weight(.semibold)) - Text(repoPath) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) + ChroniclesLauncherSurface( + campaigns: campaignStore.campaigns, + selectedCampaignID: $selectedCampaignID, + selectedProviderRaw: $selectedProviderRaw, + defaultWorld: $defaultWorld, + runID: $runID, + companions: $companions, + repoPath: repoPath, + dependencySummary: AnyView(dependencySummary), + openCampaign: openCampaign, + refreshCampaigns: { campaignStore.reload(repoPath: repoPath) }, + startViewer: startViewer, + startProvider: startProvider + ) + } + } + + private func openCampaign(_ campaign: CampaignSummary) { + do { + webViewErrorMessage = nil + webURL = try processService.startViewer( + repoPath: repoPath, + preferredPort: preferredPort, + stateDir: campaign.stateRoot.path, + campaignID: campaign.id + ) + } catch { + alertMessage = error.localizedDescription } } @@ -144,11 +167,13 @@ struct PlayView: View { webViewErrorMessage = nil let provider = ProviderKind(rawValue: selectedProviderRaw) ?? .claude let cleanRunID = runID.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedRunID = cleanRunID.isEmpty ? Self.newRunID() : cleanRunID + runID = resolvedRunID webURL = try processService.startProviderSession( kind: provider, repoPath: repoPath, world: defaultWorld, - runId: cleanRunID.isEmpty ? Self.newRunID() : cleanRunID, + runId: resolvedRunID, preferredPort: preferredPort, companions: companions, stateDir: stateDir, @@ -176,6 +201,13 @@ struct PlayView: View { ) } + private func selectFirstCampaignIfNeeded() { + guard selectedCampaignID == nil || !campaignStore.campaigns.contains(where: { $0.id == selectedCampaignID }) else { + return + } + selectedCampaignID = campaignStore.campaigns.first?.id + } + private static let runIDFormatter: DateFormatter = { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") @@ -188,3 +220,245 @@ struct PlayView: View { return "play-\(runIDFormatter.string(from: Date()))" } } + +struct ChroniclesLauncherSurface: View { + let campaigns: [CampaignSummary] + @Binding var selectedCampaignID: CampaignSummary.ID? + @Binding var selectedProviderRaw: String + @Binding var defaultWorld: String + @Binding var runID: String + @Binding var companions: String + let repoPath: String + let dependencySummary: AnyView + let openCampaign: (CampaignSummary) -> Void + let refreshCampaigns: () -> Void + let startViewer: () -> Void + let startProvider: () -> Void + + private var selectedCampaign: CampaignSummary? { + campaigns.first { $0.id == selectedCampaignID } ?? campaigns.first + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + header + HStack(alignment: .top, spacing: 18) { + campaignList + .frame(minWidth: 310, idealWidth: 360, maxWidth: 430) + campaignPreview + .frame(minWidth: 380, maxWidth: .infinity) + launchPanel + .frame(width: 330) + } + } + .padding(22) + } + } + + private var header: some View { + HStack(alignment: .bottom) { + VStack(alignment: .leading, spacing: 6) { + Text("Chronicles") + .font(.system(size: 34, weight: .semibold, design: .serif)) + .foregroundStyle(OpenWorldsTheme.ink800) + Text("Choose a running world, resume a save, or light a fresh table.") + .foregroundStyle(OpenWorldsTheme.ink600) + } + Spacer() + dependencySummary + Button { + refreshCampaigns() + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .buttonStyle(OpenWorldsBrassButtonStyle()) + } + } + + private var campaignList: some View { + OpenWorldsPanel(title: "I. Chronicles", subtitle: "Local saves and observed runs", icon: "books.vertical") { + if campaigns.isEmpty { + VStack(alignment: .leading, spacing: 10) { + Image(systemName: "book.closed") + .font(.largeTitle) + .foregroundStyle(OpenWorldsTheme.ink600) + Text("No campaigns found") + .font(.headline) + Text(repoPath) + .font(.caption) + .foregroundStyle(OpenWorldsTheme.ink600) + .lineLimit(2) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 20) + } else { + VStack(spacing: 10) { + ForEach(campaigns) { campaign in + ChronicleCard( + campaign: campaign, + selected: selectedCampaign?.id == campaign.id + ) { + selectedCampaignID = campaign.id + } + } + } + } + } + } + + @ViewBuilder + private var campaignPreview: some View { + if let selectedCampaign { + OpenWorldsPanel(title: selectedCampaign.title, subtitle: selectedCampaign.id, icon: "map") { + VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 10) { + OpenWorldsPill(text: selectedCampaign.sourceLabel, tone: .royal) + OpenWorldsPill(text: selectedCampaign.isLive ? "Live" : "Stale", tone: selectedCampaign.isLive ? .live : .neutral) + OpenWorldsPill(text: selectedCampaign.provider, tone: .neutral) + } + detailGrid(selectedCampaign) + OpenWorldsDivider() + VStack(alignment: .leading, spacing: 8) { + Text("The Party") + .font(.headline) + Text(selectedCampaign.partyLabel) + .foregroundStyle(OpenWorldsTheme.ink600) + .textSelection(.enabled) + } + Spacer(minLength: 8) + HStack { + Button { + openCampaign(selectedCampaign) + } label: { + Label("Resume Chronicle", systemImage: "play.fill") + } + .buttonStyle(OpenWorldsBrassButtonStyle(prominent: true)) + Button { + selectedCampaignID = selectedCampaign.id + } label: { + Label("Select", systemImage: "bookmark") + } + .buttonStyle(OpenWorldsBrassButtonStyle()) + } + } + } + } else { + OpenWorldsPanel(title: "Where last we stood", subtitle: "No campaign selected", icon: "scroll") { + Text("Start a new table or refresh the campaign catalogue.") + .foregroundStyle(OpenWorldsTheme.ink600) + } + } + } + + private func detailGrid(_ campaign: CampaignSummary) -> some View { + Grid(alignment: .leading, horizontalSpacing: 14, verticalSpacing: 9) { + detailRow("World", campaign.world) + detailRow("Time", campaign.dayLabel) + detailRow("Location", campaign.location) + detailRow("Run", campaign.runID) + detailRow("Updated", campaign.lastUpdate.formatted(date: .abbreviated, time: .standard)) + detailRow("State root", campaign.stateRoot.path) + } + .font(.callout) + } + + private func detailRow(_ label: String, _ value: String) -> some View { + GridRow { + Text(label) + .foregroundStyle(OpenWorldsTheme.ink600) + Text(value) + .foregroundStyle(OpenWorldsTheme.ink800) + .textSelection(.enabled) + .lineLimit(2) + } + } + + private var launchPanel: some View { + OpenWorldsPanel(title: "II. Light the Lantern", subtitle: "Provider-backed local play", icon: "sparkles") { + VStack(alignment: .leading, spacing: 12) { + Picker("Provider", selection: $selectedProviderRaw) { + ForEach(ProviderKind.allCases) { provider in + Label(provider.displayName, systemImage: provider.symbolName) + .tag(provider.rawValue) + } + } + .pickerStyle(.menu) + + labeledTextField("World", text: $defaultWorld) + labeledTextField("Run ID", text: $runID) + labeledTextField("Companions", text: $companions) + + HStack { + Button { + startViewer() + } label: { + Label("Dashboard", systemImage: "rectangle.on.rectangle") + } + .buttonStyle(OpenWorldsBrassButtonStyle()) + Button { + startProvider() + } label: { + Label("Start Game", systemImage: "play.fill") + } + .buttonStyle(OpenWorldsBrassButtonStyle(prominent: true)) + } + + Text("Ready the table, gather the party, then begin local play.") + .font(.caption) + .foregroundStyle(OpenWorldsTheme.ink600) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + private func labeledTextField(_ label: String, text: Binding) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(label) + .font(.caption.weight(.semibold)) + .foregroundStyle(OpenWorldsTheme.ink600) + TextField(label, text: text) + .textFieldStyle(.roundedBorder) + } + } +} + +struct ChronicleCard: View { + let campaign: CampaignSummary + let selected: Bool + let onSelect: () -> Void + + var body: some View { + Button(action: onSelect) { + VStack(alignment: .leading, spacing: 7) { + HStack { + Text(campaign.title) + .font(.headline) + .foregroundStyle(OpenWorldsTheme.ink800) + .lineLimit(1) + Spacer() + OpenWorldsPill(text: campaign.isLive ? "Live" : campaign.sourceLabel, tone: campaign.isLive ? .live : .neutral) + } + HStack(spacing: 9) { + Label(campaign.world, systemImage: "map") + Label(campaign.dayLabel, systemImage: "clock") + } + .font(.caption) + .foregroundStyle(OpenWorldsTheme.ink600) + Text(campaign.location) + .font(.caption) + .foregroundStyle(OpenWorldsTheme.ink700) + .lineLimit(1) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(selected ? OpenWorldsTheme.brass100.opacity(0.7) : OpenWorldsTheme.parchment100.opacity(0.66)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay { + RoundedRectangle(cornerRadius: 6) + .stroke(selected ? OpenWorldsTheme.brass400 : OpenWorldsTheme.parchmentEdge.opacity(0.35), lineWidth: selected ? 1.5 : 1) + } + } + .buttonStyle(.plain) + } +} diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/RootView.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/RootView.swift index 3f03d033..5a821b99 100644 --- a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/RootView.swift +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/RootView.swift @@ -20,21 +20,37 @@ struct RootView: View { @State private var webURL: URL? var body: some View { - NavigationSplitView { - SidebarView(selection: $selection) - } detail: { + ZStack { + OpenWorldsWindowBackground() VStack(spacing: 0) { - detailView - .frame(maxWidth: .infinity, maxHeight: .infinity) - Divider() - StatusStrip(repoPath: repoPath, stateDir: stateDir) - .environmentObject(processService) + OpenWorldsTitleBar( + campaign: currentCampaign?.title ?? "No Chronicle Selected", + location: (selection ?? .play).title, + day: currentCampaign?.dayLabel ?? "local" + ) + HStack(spacing: 0) { + SidebarView(selection: $selection) + VStack(spacing: 0) { + detailView + .frame(maxWidth: .infinity, maxHeight: .infinity) + StatusStrip(repoPath: repoPath, stateDir: stateDir) + .environmentObject(processService) + } + .clipShape(RoundedRectangle(cornerRadius: OpenWorldsTheme.panelRadius)) + } } + .padding(14) } + .frame(minWidth: 1120, minHeight: 720) .onAppear(perform: refresh) .onChange(of: repoPath) { _ in refresh() } } + private var currentCampaign: CampaignSummary? { + campaignStore.campaigns.first { $0.id == processService.activeCampaignID } + ?? campaignStore.campaigns.first + } + @ViewBuilder private var detailView: some View { switch selection ?? .play { @@ -103,17 +119,102 @@ struct SidebarView: View { @Binding var selection: AppSection? var body: some View { - List(AppSection.allCases, selection: $selection) { section in - Label(section.title, systemImage: section.symbolName) - .tag(section) + VStack(spacing: 12) { + ForEach(AppSection.allCases) { section in + Button { + selection = section + } label: { + VStack(spacing: 6) { + Image(systemName: section.symbolName) + .font(.system(size: 20, weight: .medium)) + Text(section.title) + .font(.caption2.weight(.semibold)) + .lineLimit(1) + } + .frame(width: 58, height: 58) + .foregroundStyle(selection == section ? OpenWorldsTheme.ink800 : OpenWorldsTheme.brass200) + .background(selection == section ? OpenWorldsTheme.brass100.opacity(0.86) : OpenWorldsTheme.walnut100.opacity(0.58)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay { + RoundedRectangle(cornerRadius: 6) + .stroke(selection == section ? OpenWorldsTheme.brass300 : OpenWorldsTheme.brass600.opacity(0.5), lineWidth: 1) + } + } + .buttonStyle(.plain) + .help(section.title) + .accessibilityLabel(section.title) + .accessibilityValue(selection == section ? "Selected" : "Not selected") + } + Spacer(minLength: 12) + } + .padding(.vertical, 12) + .frame(width: OpenWorldsTheme.railWidth) + .background( + LinearGradient( + colors: [OpenWorldsTheme.walnut300, OpenWorldsTheme.walnut400], + startPoint: .top, + endPoint: .bottom + ) + ) + .overlay(alignment: .trailing) { + Rectangle() + .fill(OpenWorldsTheme.brass600.opacity(0.52)) + .frame(width: 1) } - .navigationSplitViewColumnWidth(min: 180, ideal: 210) - .toolbar { - ToolbarItem(placement: .principal) { - Text("ClawDnD") - .font(.headline) + } +} + +struct OpenWorldsTitleBar: View { + let campaign: String + let location: String + let day: String + + var body: some View { + HStack(spacing: 16) { + HStack(spacing: 7) { + statusJewel(OpenWorldsTheme.crimson) + statusJewel(OpenWorldsTheme.brass300) + statusJewel(OpenWorldsTheme.emerald) } + .accessibilityHidden(true) + + Spacer() + Text("CLAWDND") + .font(.caption.weight(.bold)) + .tracking(4) + .foregroundStyle(OpenWorldsTheme.brass100) + Text("·") + .foregroundStyle(OpenWorldsTheme.brass300) + Text(campaign) + .font(.caption.weight(.semibold)) + .tracking(2) + .lineLimit(1) + .foregroundStyle(OpenWorldsTheme.brass200) + Text("·") + .foregroundStyle(OpenWorldsTheme.brass300) + Text(location) + .font(.caption.weight(.semibold)) + .tracking(2) + .lineLimit(1) + .foregroundStyle(OpenWorldsTheme.brass100) + Spacer() + + Text(day) + .font(.caption.monospacedDigit().weight(.semibold)) + .foregroundStyle(OpenWorldsTheme.brass200) + .lineLimit(1) + .frame(width: 130, alignment: .trailing) } + .frame(height: 38) + .padding(.horizontal, 16) + } + + private func statusJewel(_ color: Color) -> some View { + Circle() + .fill(color) + .frame(width: 12, height: 12) + .overlay(Circle().stroke(.black.opacity(0.45), lineWidth: 1)) + .shadow(color: color.opacity(0.35), radius: 2) } } @@ -139,7 +240,13 @@ struct StatusStrip: View { .font(.caption) .padding(.horizontal, 14) .padding(.vertical, 8) - .background(.bar) + .foregroundStyle(OpenWorldsTheme.ink700) + .background(OpenWorldsTheme.parchment200) + .overlay(alignment: .top) { + Rectangle() + .fill(OpenWorldsTheme.parchmentEdge.opacity(0.35)) + .frame(height: 1) + } } private func statusItem(_ label: String, _ value: String) -> some View {