Add sidebar help menu to footer (#958)

* Add sidebar help menu

* Fix help menu test wiring

* Fix help menu accessibility

* Use native popup for help menu

* Use icon button for sidebar help

* Add feedback composer and feedback API

* Allow preview builds without feedback env

* Tighten feedback upload limits

* Adjust sidebar footer padding

* Tighten sidebar footer spacing

* Add link affordances to help menu

* Polish sidebar feedback composer

* Move feedback icon to trailing edge

* Normalize help menu trailing icon sizes

* Enlarge help menu trailing icons

* Reduce help menu link icon size

* Shrink help menu link arrow

* Reduce help menu link arrow again

* Fix feedback message editor focus

* Add send feedback keyboard shortcut

* Polish feedback launch and delivery
This commit is contained in:
Lawrence Chen 2026-03-05 21:00:42 -08:00 committed by GitHub
parent bb052198e5
commit 29054dc709
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 2771 additions and 28 deletions

View file

@ -73,6 +73,7 @@
A5002000 /* THIRD_PARTY_LICENSES.md in Resources */ = {isa = PBXBuildFile; fileRef = A5002001 /* THIRD_PARTY_LICENSES.md */; };
B9000012A1B2C3D4E5F60719 /* AutomationSocketUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */; };
B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */; };
B8F266246A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */; };
C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */; };
B9000014A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000013A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift */; };
B9000015A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */; };
@ -203,6 +204,7 @@
A5001241 /* WindowDecorationsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDecorationsController.swift; sourceTree = "<group>"; };
A5001611 /* SessionPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistence.swift; sourceTree = "<group>"; };
818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = "<group>"; };
B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarHelpMenuUITests.swift; sourceTree = "<group>"; };
C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillUITests.swift; sourceTree = "<group>"; };
A5001101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
B2E7294509CC42FE9191870E /* xterm-ghostty */ = {isa = PBXFileReference; lastKnownFileType = file; path = "ghostty/terminfo/78/xterm-ghostty"; sourceTree = "<group>"; };
@ -433,6 +435,7 @@
B9000019A1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift */,
B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */,
818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */,
B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */,
D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */,
D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */,
C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */,
@ -667,6 +670,7 @@
B9000023A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift in Sources */,
B9000015A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift in Sources */,
B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */,
B8F266246A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift in Sources */,
D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */,
D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */,
C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */,

View file

@ -567,6 +567,584 @@
}
}
},
"debug.devBuildBanner.show": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Show Dev Build Banner"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "開発ビルドバナーを表示"
}
}
}
},
"debug.devBuildBanner.title": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "THIS IS A DEV BUILD"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "これは開発ビルドです"
}
}
}
},
"sidebar.help.button": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Help"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ヘルプ"
}
}
}
},
"sidebar.help.changelog": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Changelog"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "更新履歴"
}
}
}
},
"sidebar.help.githubIssues": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "GitHub Issues"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "GitHub Issues"
}
}
}
},
"sidebar.help.sendFeedback": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Send Feedback"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "フィードバックを送信"
}
}
}
},
"sidebar.help.feedback.attachImages": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Attach Images"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "画像を添付"
}
}
}
},
"sidebar.help.feedback.attachImages.prompt": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Attach"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "添付"
}
}
}
},
"sidebar.help.feedback.attachImages.title": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Attach Images"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "画像を添付"
}
}
}
},
"sidebar.help.feedback.attachmentsHint": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Up to 10 images. Large images will be optimized before sending."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "画像は最大10枚まで添付できます。大きな画像は送信前に最適化されます。"
}
}
}
},
"sidebar.help.feedback.cancel": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Cancel"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "キャンセル"
}
}
}
},
"sidebar.help.feedback.connectionError": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Couldn't send feedback. Check your connection and try again."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "フィードバックを送信できませんでした。接続を確認して、もう一度お試しください。"
}
}
}
},
"sidebar.help.feedback.done": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Done"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "完了"
}
}
}
},
"sidebar.help.feedback.email": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Your Email"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "メールアドレス"
}
}
}
},
"sidebar.help.feedback.emailPlaceholder": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "you@example.com"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "you@example.com"
}
}
}
},
"sidebar.help.feedback.emptyMessage": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Enter a message before sending."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "送信する前にメッセージを入力してください。"
}
}
}
},
"sidebar.help.feedback.endpointError": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Feedback is unavailable right now. Email founders@manaflow.com instead."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "現在フィードバックを送信できません。代わりに founders@manaflow.com までメールしてください。"
}
}
}
},
"sidebar.help.feedback.genericError": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Couldn't send feedback. Please try again."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "フィードバックを送信できませんでした。もう一度お試しください。"
}
}
}
},
"sidebar.help.feedback.imageTooLarge": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Each image must be 4 MB or smaller."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "各画像は 4 MB 以下にしてください。"
}
}
}
},
"sidebar.help.feedback.invalidEmail": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Enter a valid email address."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "有効なメールアドレスを入力してください。"
}
}
}
},
"sidebar.help.feedback.invalidImageSelection": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "One of the selected files could not be attached."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "選択したファイルのうち1つを添付できませんでした。"
}
}
}
},
"sidebar.help.feedback.message": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Message"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "メッセージ"
}
}
}
},
"sidebar.help.feedback.messagePlaceholder": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Share feedback, feature requests, or issues."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "フィードバック、機能要望、不具合をお知らせください。"
}
}
}
},
"sidebar.help.feedback.messageTooLong": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Your message is too long."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "メッセージが長すぎます。"
}
}
}
},
"sidebar.help.feedback.note": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "A human will read this! You can also reach us at founders@manaflow.com."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "人間がこれを読みます。founders@manaflow.com 宛てに直接ご連絡いただくこともできます。"
}
}
}
},
"sidebar.help.feedback.rateLimited": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Too many feedback attempts. Please try again later."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "フィードバックの送信回数が多すぎます。しばらくしてからもう一度お試しください。"
}
}
}
},
"sidebar.help.feedback.removeAttachment": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Remove"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "削除"
}
}
}
},
"sidebar.help.feedback.send": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Send"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "送信"
}
}
}
},
"sidebar.help.feedback.successBody": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "A human will read this! You can also reach us at founders@manaflow.com."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "人間がこれを読みます。founders@manaflow.com 宛てに直接ご連絡いただくこともできます。"
}
}
}
},
"sidebar.help.feedback.successTitle": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Thanks for the feedback."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "フィードバックありがとうございます。"
}
}
}
},
"sidebar.help.feedback.title": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Send Feedback"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "フィードバックを送信"
}
}
}
},
"sidebar.help.feedback.tooManyImages": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "You can attach up to 10 images."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "画像は最大10枚まで添付できます。"
}
}
}
},
"sidebar.help.feedback.totalImagesTooLarge": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "These images are too large to send together. Remove a few and try again."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "これらの画像はまとめて送信するには大きすぎます。いくつか削除してもう一度お試しください。"
}
}
}
},
"sidebar.help.feedback.validationError": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Check your message and attachments, then try again."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "メッセージと添付ファイルを確認して、もう一度お試しください。"
}
}
}
},
"about.github": {
"extractionState": "manual",
"localizations": {

View file

@ -4858,8 +4858,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
@MainActor
static func presentPreferencesWindow(
showFallbackSettingsWindow: @MainActor () -> Void = {
SettingsWindowController.shared.show()
navigationTarget: SettingsNavigationTarget? = nil,
showFallbackSettingsWindow: @MainActor (SettingsNavigationTarget?) -> Void = { target in
SettingsWindowController.shared.show(navigationTarget: target)
},
activateApplication: @MainActor () -> Void = {
NSApp.activate(ignoringOtherApps: true)
@ -4868,7 +4869,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
#if DEBUG
dlog("settings.open.present path=customWindowDirect")
#endif
showFallbackSettingsWindow()
showFallbackSettingsWindow(navigationTarget)
activateApplication()
#if DEBUG
dlog("settings.open.present activate=1")
@ -4876,11 +4877,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}
@MainActor
func openPreferencesWindow(debugSource: String) {
func openPreferencesWindow(debugSource: String, navigationTarget: SettingsNavigationTarget? = nil) {
#if DEBUG
dlog("settings.open.request source=\(debugSource)")
#endif
Self.presentPreferencesWindow()
Self.presentPreferencesWindow(navigationTarget: navigationTarget)
}
@objc func openPreferencesWindow() {
@ -6610,6 +6611,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return true
}
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .sendFeedback)) {
guard let targetContext = preferredMainWindowContextForShortcuts(event: event),
let targetWindow = targetContext.window ?? windowForMainWindowId(targetContext.windowId) else {
return false
}
setActiveMainWindow(targetWindow)
bringToFront(targetWindow)
NotificationCenter.default.post(name: .feedbackComposerRequested, object: targetWindow)
return true
}
// Check Jump to Unread shortcut
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .jumpToUnread)) {
#if DEBUG

File diff suppressed because it is too large Load diff

View file

@ -10,6 +10,7 @@ enum KeyboardShortcutSettings {
case newWindow
case closeWindow
case openFolder
case sendFeedback
case showNotifications
case jumpToUnread
case triggerFlash
@ -50,6 +51,7 @@ enum KeyboardShortcutSettings {
case .newWindow: return String(localized: "shortcut.newWindow.label", defaultValue: "New Window")
case .closeWindow: return String(localized: "shortcut.closeWindow.label", defaultValue: "Close Window")
case .openFolder: return String(localized: "shortcut.openFolder.label", defaultValue: "Open Folder")
case .sendFeedback: return String(localized: "sidebar.help.sendFeedback", defaultValue: "Send Feedback")
case .showNotifications: return String(localized: "shortcut.showNotifications.label", defaultValue: "Show Notifications")
case .jumpToUnread: return String(localized: "shortcut.jumpToUnread.label", defaultValue: "Jump to Latest Unread")
case .triggerFlash: return String(localized: "shortcut.flashFocusedPanel.label", defaultValue: "Flash Focused Panel")
@ -84,6 +86,7 @@ enum KeyboardShortcutSettings {
case .newWindow: return "shortcut.newWindow"
case .closeWindow: return "shortcut.closeWindow"
case .openFolder: return "shortcut.openFolder"
case .sendFeedback: return "shortcut.sendFeedback"
case .showNotifications: return "shortcut.showNotifications"
case .jumpToUnread: return "shortcut.jumpToUnread"
case .triggerFlash: return "shortcut.triggerFlash"
@ -123,6 +126,8 @@ enum KeyboardShortcutSettings {
return StoredShortcut(key: "w", command: true, shift: false, option: false, control: true)
case .openFolder:
return StoredShortcut(key: "o", command: true, shift: false, option: false, control: false)
case .sendFeedback:
return StoredShortcut(key: "f", command: true, shift: false, option: true, control: false)
case .showNotifications:
return StoredShortcut(key: "i", command: true, shift: false, option: false, control: false)
case .jumpToUnread:

View file

@ -3676,6 +3676,7 @@ extension Notification.Name {
static let commandPaletteMoveSelection = Notification.Name("cmux.commandPaletteMoveSelection")
static let commandPaletteRenameInputInteractionRequested = Notification.Name("cmux.commandPaletteRenameInputInteractionRequested")
static let commandPaletteRenameInputDeleteBackwardRequested = Notification.Name("cmux.commandPaletteRenameInputDeleteBackwardRequested")
static let feedbackComposerRequested = Notification.Name("cmux.feedbackComposerRequested")
static let ghosttyDidSetTitle = Notification.Name("ghosttyDidSetTitle")
static let ghosttyDidFocusTab = Notification.Name("ghosttyDidFocusTab")
static let ghosttyDidFocusSurface = Notification.Name("ghosttyDidFocusSurface")

View file

@ -14,6 +14,8 @@ struct cmuxApp: App {
@AppStorage(AppearanceSettings.appearanceModeKey) private var appearanceMode = AppearanceSettings.defaultMode.rawValue
@AppStorage("titlebarControlsStyle") private var titlebarControlsStyle = TitlebarControlsStyle.classic.rawValue
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
@AppStorage(DevBuildBannerDebugSettings.sidebarBannerVisibleKey)
private var showSidebarDevBuildBanner = DevBuildBannerDebugSettings.defaultShowSidebarBanner
@AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue
@AppStorage(KeyboardShortcutSettings.Action.toggleSidebar.defaultsKey) private var toggleSidebarShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.newTab.defaultsKey) private var newWorkspaceShortcutData = Data()
@ -360,6 +362,10 @@ struct cmuxApp: App {
}
Toggle("Always Show Shortcut Hints", isOn: $alwaysShowShortcutHints)
Toggle(
String(localized: "debug.devBuildBanner.show", defaultValue: "Show Dev Build Banner"),
isOn: $showSidebarDevBuildBanner
)
Divider()
@ -1321,6 +1327,7 @@ private enum DebugWindowConfigSnapshot {
sidebarCornerRadius=\(String(format: "%.1f", doubleValue(defaults, key: "sidebarCornerRadius", fallback: 0.0)))
sidebarBranchVerticalLayout=\(boolValue(defaults, key: SidebarBranchLayoutSettings.key, fallback: SidebarBranchLayoutSettings.defaultVerticalLayout))
sidebarActiveTabIndicatorStyle=\(stringValue(defaults, key: SidebarActiveTabIndicatorSettings.styleKey, fallback: SidebarActiveTabIndicatorSettings.defaultStyle.rawValue))
sidebarDevBuildBannerVisible=\(boolValue(defaults, key: DevBuildBannerDebugSettings.sidebarBannerVisibleKey, fallback: DevBuildBannerDebugSettings.defaultShowSidebarBanner))
shortcutHintSidebarXOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.sidebarHintXKey, fallback: ShortcutHintDebugSettings.defaultSidebarHintX)))
shortcutHintSidebarYOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.sidebarHintYKey, fallback: ShortcutHintDebugSettings.defaultSidebarHintY)))
shortcutHintTitlebarXOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.titlebarHintXKey, fallback: ShortcutHintDebugSettings.defaultTitlebarHintX)))
@ -1775,7 +1782,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
fatalError("init(coder:) has not been implemented")
}
func show() {
func show(navigationTarget: SettingsNavigationTarget? = nil) {
guard let window else { return }
#if DEBUG
dlog("settings.window.show requested isVisible=\(window.isVisible ? 1 : 0) isKey=\(window.isKeyWindow ? 1 : 0)")
@ -1785,12 +1792,39 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
window.center()
}
window.makeKeyAndOrderFront(nil)
if let navigationTarget {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
SettingsNavigationRequest.post(navigationTarget)
}
}
#if DEBUG
dlog("settings.window.show completed isVisible=\(window.isVisible ? 1 : 0) isKey=\(window.isKeyWindow ? 1 : 0)")
#endif
}
}
enum SettingsNavigationTarget: String {
case keyboardShortcuts
}
enum SettingsNavigationRequest {
static let notificationName = Notification.Name("cmux.settings.navigate")
private static let targetKey = "target"
static func post(_ target: SettingsNavigationTarget) {
NotificationCenter.default.post(
name: notificationName,
object: nil,
userInfo: [targetKey: target.rawValue]
)
}
static func target(from notification: Notification) -> SettingsNavigationTarget? {
guard let rawValue = notification.userInfo?[targetKey] as? String else { return nil }
return SettingsNavigationTarget(rawValue: rawValue)
}
}
private final class SidebarDebugWindowController: NSWindowController, NSWindowDelegate {
static let shared = SidebarDebugWindowController()
@ -1931,6 +1965,8 @@ private struct SidebarDebugView: View {
@AppStorage(ShortcutHintDebugSettings.paneHintXKey) private var paneShortcutHintXOffset = ShortcutHintDebugSettings.defaultPaneHintX
@AppStorage(ShortcutHintDebugSettings.paneHintYKey) private var paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
@AppStorage(DevBuildBannerDebugSettings.sidebarBannerVisibleKey)
private var showSidebarDevBuildBanner = DevBuildBannerDebugSettings.defaultShowSidebarBanner
@AppStorage(SidebarActiveTabIndicatorSettings.styleKey)
private var sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue
@ -2154,6 +2190,7 @@ private struct SidebarDebugView: View {
sidebarCornerRadius=\(String(format: "%.1f", sidebarCornerRadius))
sidebarBranchVerticalLayout=\(sidebarBranchVerticalLayout)
sidebarActiveTabIndicatorStyle=\(sidebarActiveTabIndicatorStyle)
sidebarDevBuildBannerVisible=\(showSidebarDevBuildBanner)
shortcutHintSidebarXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset)))
shortcutHintSidebarYOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset)))
shortcutHintTitlebarXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(titlebarShortcutHintXOffset)))
@ -3168,7 +3205,8 @@ struct SettingsView: View {
}
var body: some View {
ZStack(alignment: .top) {
ScrollViewReader { proxy in
ZStack(alignment: .top) {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
SettingsSectionHeader(title: String(localized: "settings.section.app", defaultValue: "App"))
@ -3864,6 +3902,8 @@ struct SettingsView: View {
}
SettingsSectionHeader(title: String(localized: "settings.section.keyboardShortcuts", defaultValue: "Keyboard Shortcuts"))
.id(SettingsNavigationTarget.keyboardShortcuts)
.accessibilityIdentifier("SettingsKeyboardShortcutsSection")
SettingsCard {
SettingsCardRow(
String(localized: "settings.shortcuts.showHints", defaultValue: "Show Cmd/Ctrl-Hold Shortcut Hints"),
@ -3894,6 +3934,7 @@ struct SettingsView: View {
.font(.caption)
.foregroundColor(.secondary)
.padding(.leading, 2)
.accessibilityIdentifier("ShortcutRecordingHint")
SettingsSectionHeader(title: String(localized: "settings.section.reset", defaultValue: "Reset"))
SettingsCard {
@ -4013,6 +4054,14 @@ struct SettingsView: View {
.onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in
reloadWorkspaceTabColorSettings()
}
.onReceive(NotificationCenter.default.publisher(for: SettingsNavigationRequest.notificationName)) { notification in
guard let target = SettingsNavigationRequest.target(from: notification) else { return }
DispatchQueue.main.async {
withAnimation(.easeInOut(duration: 0.2)) {
proxy.scrollTo(target, anchor: .top)
}
}
}
.confirmationDialog(
String(localized: "settings.browser.history.clearDialog.title", defaultValue: "Clear browser history?"),
isPresented: $showClearBrowserHistoryConfirmation,
@ -4061,6 +4110,7 @@ struct SettingsView: View {
} message: {
Text(notificationCustomSoundErrorAlertMessage)
}
}
}
private func relaunchApp() {

View file

@ -2071,9 +2071,11 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
func testPresentPreferencesWindowShowsCustomSettingsWindowAndActivates() {
var showFallbackSettingsWindowCallCount = 0
var activateApplicationCallCount = 0
var receivedNavigationTargets: [SettingsNavigationTarget?] = []
AppDelegate.presentPreferencesWindow(
showFallbackSettingsWindow: {
showFallbackSettingsWindow: { navigationTarget in
receivedNavigationTargets.append(navigationTarget)
showFallbackSettingsWindowCallCount += 1
},
activateApplication: {
@ -2083,14 +2085,17 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
XCTAssertEqual(showFallbackSettingsWindowCallCount, 1)
XCTAssertEqual(activateApplicationCallCount, 1)
XCTAssertEqual(receivedNavigationTargets, [nil])
}
func testPresentPreferencesWindowSupportsRepeatedCalls() {
var showFallbackSettingsWindowCallCount = 0
var activateApplicationCallCount = 0
var receivedNavigationTargets: [SettingsNavigationTarget?] = []
AppDelegate.presentPreferencesWindow(
showFallbackSettingsWindow: {
showFallbackSettingsWindow: { navigationTarget in
receivedNavigationTargets.append(navigationTarget)
showFallbackSettingsWindowCallCount += 1
},
activateApplication: {
@ -2099,7 +2104,8 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
)
AppDelegate.presentPreferencesWindow(
showFallbackSettingsWindow: {
showFallbackSettingsWindow: { navigationTarget in
receivedNavigationTargets.append(navigationTarget)
showFallbackSettingsWindowCallCount += 1
},
activateApplication: {
@ -2109,6 +2115,25 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
XCTAssertEqual(showFallbackSettingsWindowCallCount, 2)
XCTAssertEqual(activateApplicationCallCount, 2)
XCTAssertEqual(receivedNavigationTargets, [nil, nil])
}
func testPresentPreferencesWindowForwardsNavigationTarget() {
var receivedNavigationTarget: SettingsNavigationTarget?
var activateApplicationCallCount = 0
AppDelegate.presentPreferencesWindow(
navigationTarget: .keyboardShortcuts,
showFallbackSettingsWindow: { navigationTarget in
receivedNavigationTarget = navigationTarget
},
activateApplication: {
activateApplicationCallCount += 1
}
)
XCTAssertEqual(receivedNavigationTarget, .keyboardShortcuts)
XCTAssertEqual(activateApplicationCallCount, 1)
}
private func makeKeyDownEvent(

View file

@ -3590,6 +3590,35 @@ final class ShortcutHintDebugSettingsTests: XCTestCase {
}
}
final class DevBuildBannerDebugSettingsTests: XCTestCase {
func testShowSidebarBannerDefaultsToVisible() {
let suiteName = "DevBuildBannerDebugSettingsTests.Default.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.removeObject(forKey: DevBuildBannerDebugSettings.sidebarBannerVisibleKey)
XCTAssertTrue(DevBuildBannerDebugSettings.showSidebarBanner(defaults: defaults))
}
func testShowSidebarBannerRespectsStoredValue() {
let suiteName = "DevBuildBannerDebugSettingsTests.Stored.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.set(false, forKey: DevBuildBannerDebugSettings.sidebarBannerVisibleKey)
XCTAssertFalse(DevBuildBannerDebugSettings.showSidebarBanner(defaults: defaults))
defaults.set(true, forKey: DevBuildBannerDebugSettings.sidebarBannerVisibleKey)
XCTAssertTrue(DevBuildBannerDebugSettings.showSidebarBanner(defaults: defaults))
}
}
final class ShortcutHintLanePlannerTests: XCTestCase {
func testAssignLanesKeepsSeparatedIntervalsOnSingleLane() {
let intervals: [ClosedRange<CGFloat>] = [0...20, 28...40, 48...64]

View file

@ -0,0 +1,296 @@
import XCTest
private func sidebarHelpPollUntil(
timeout: TimeInterval,
pollInterval: TimeInterval = 0.05,
condition: () -> Bool
) -> Bool {
let start = ProcessInfo.processInfo.systemUptime
while true {
if condition() {
return true
}
if (ProcessInfo.processInfo.systemUptime - start) >= timeout {
return false
}
RunLoop.current.run(until: Date().addingTimeInterval(pollInterval))
}
}
final class SidebarHelpMenuUITests: XCTestCase {
override func setUp() {
super.setUp()
continueAfterFailure = false
}
func testHelpMenuOpensKeyboardShortcutsSection() {
let app = XCUIApplication()
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
launchAndActivate(app)
XCTAssertTrue(waitForWindowCount(atLeast: 1, app: app, timeout: 6.0))
let helpButton = requireElement(
candidates: helpButtonCandidates(in: app),
timeout: 6.0,
description: "sidebar help button"
)
helpButton.click()
let keyboardShortcutsItem = requireElement(
candidates: helpMenuItemCandidates(in: app, identifier: "SidebarHelpMenuOptionKeyboardShortcuts", title: "Keyboard Shortcuts"),
timeout: 3.0,
description: "Keyboard Shortcuts help menu item"
)
keyboardShortcutsItem.click()
XCTAssertTrue(app.staticTexts["ShortcutRecordingHint"].waitForExistence(timeout: 6.0))
}
func testHelpMenuCheckForUpdatesTriggersSidebarUpdatePill() {
let app = XCUIApplication()
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
app.launchEnvironment["CMUX_UI_TEST_FEED_URL"] = "https://cmux.test/appcast.xml"
app.launchEnvironment["CMUX_UI_TEST_FEED_MODE"] = "available"
app.launchEnvironment["CMUX_UI_TEST_UPDATE_VERSION"] = "9.9.9"
app.launchEnvironment["CMUX_UI_TEST_AUTO_ALLOW_PERMISSION"] = "1"
launchAndActivate(app)
XCTAssertTrue(waitForWindowCount(atLeast: 1, app: app, timeout: 6.0))
let helpButton = requireElement(
candidates: helpButtonCandidates(in: app),
timeout: 6.0,
description: "sidebar help button"
)
helpButton.click()
let checkForUpdatesItem = requireElement(
candidates: helpMenuItemCandidates(in: app, identifier: "SidebarHelpMenuOptionCheckForUpdates", title: "Check for Updates"),
timeout: 3.0,
description: "Check for Updates help menu item"
)
checkForUpdatesItem.click()
let updatePill = app.buttons["UpdatePill"]
XCTAssertTrue(updatePill.waitForExistence(timeout: 6.0))
XCTAssertEqual(updatePill.label, "Update Available: 9.9.9")
}
func testHelpMenuSendFeedbackOpensComposerSheet() {
let app = XCUIApplication()
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
launchAndActivate(app)
XCTAssertTrue(waitForWindowCount(atLeast: 1, app: app, timeout: 6.0))
let helpButton = requireElement(
candidates: helpButtonCandidates(in: app),
timeout: 6.0,
description: "sidebar help button"
)
helpButton.click()
let sendFeedbackItem = requireElement(
candidates: helpMenuItemCandidates(in: app, identifier: "SidebarHelpMenuOptionSendFeedback", title: "Send Feedback"),
timeout: 3.0,
description: "Send Feedback help menu item"
)
sendFeedbackItem.click()
XCTAssertTrue(app.staticTexts["Send Feedback"].waitForExistence(timeout: 3.0))
XCTAssertTrue(
firstExistingElement(
candidates: [
app.textFields["SidebarFeedbackEmailField"],
app.textFields["Your Email"],
],
timeout: 2.0
) != nil
)
XCTAssertTrue(
firstExistingElement(
candidates: [
app.buttons["SidebarFeedbackAttachButton"],
app.buttons["Attach Images"],
],
timeout: 2.0
) != nil
)
XCTAssertTrue(
firstExistingElement(
candidates: [
app.buttons["SidebarFeedbackSendButton"],
app.buttons["Send"],
],
timeout: 2.0
) != nil
)
XCTAssertTrue(
app.staticTexts[
"A human will read this! You can also reach us at founders@manaflow.com."
].waitForExistence(timeout: 2.0)
)
let messageEditor = requireElement(
candidates: [
app.textViews["SidebarFeedbackMessageEditor"],
app.scrollViews["SidebarFeedbackMessageEditor"],
app.otherElements["SidebarFeedbackMessageEditor"],
app.textViews["Message"],
],
timeout: 2.0,
description: "feedback message editor"
)
messageEditor.click()
app.typeText("hello")
XCTAssertTrue(app.staticTexts["5/4000"].waitForExistence(timeout: 2.0))
}
private func waitForWindowCount(atLeast count: Int, app: XCUIApplication, timeout: TimeInterval) -> Bool {
sidebarHelpPollUntil(timeout: timeout) {
app.windows.count >= count
}
}
private func helpButtonCandidates(in app: XCUIApplication) -> [XCUIElement] {
let sidebar = app.otherElements["Sidebar"]
return [
app.buttons["SidebarHelpMenuButton"],
app.buttons["Help"],
sidebar.buttons["SidebarHelpMenuButton"],
sidebar.buttons["Help"],
]
}
private func helpMenuItemCandidates(
in app: XCUIApplication,
identifier: String,
title: String
) -> [XCUIElement] {
[
app.buttons[identifier],
app.buttons[title],
]
}
private func firstExistingElement(
candidates: [XCUIElement],
timeout: TimeInterval
) -> XCUIElement? {
var match: XCUIElement?
let found = sidebarHelpPollUntil(timeout: timeout) {
for candidate in candidates where candidate.exists {
match = candidate
return true
}
return false
}
return found ? match : nil
}
private func requireElement(
candidates: [XCUIElement],
timeout: TimeInterval,
description: String
) -> XCUIElement {
guard let element = firstExistingElement(candidates: candidates, timeout: timeout) else {
XCTFail("Expected \(description) to exist")
return candidates[0]
}
return element
}
private func launchAndActivate(_ app: XCUIApplication, activateTimeout: TimeInterval = 2.0) {
app.launch()
let activated = sidebarHelpPollUntil(timeout: activateTimeout) {
guard app.state != .runningForeground else {
return true
}
app.activate()
return app.state == .runningForeground
}
if !activated {
app.activate()
}
XCTAssertTrue(
sidebarHelpPollUntil(timeout: 2.0) { app.state == .runningForeground },
"App did not reach runningForeground before UI interactions"
)
}
}
final class FeedbackComposerShortcutUITests: XCTestCase {
override func setUp() {
super.setUp()
continueAfterFailure = false
}
func testCmdOptionFOpensFeedbackComposer() {
let app = XCUIApplication()
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
app.launch()
app.activate()
XCTAssertTrue(
sidebarHelpPollUntil(timeout: 6.0) {
app.windows.count >= 1
}
)
app.typeKey("f", modifierFlags: [.command, .option])
XCTAssertTrue(app.staticTexts["Send Feedback"].waitForExistence(timeout: 3.0))
XCTAssertTrue(
app.textFields["SidebarFeedbackEmailField"].waitForExistence(timeout: 2.0)
|| app.textFields["Your Email"].waitForExistence(timeout: 2.0)
)
}
func testCmdOptionFWorksWithHiddenSidebar() {
let app = XCUIApplication()
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
app.launch()
app.activate()
XCTAssertTrue(
sidebarHelpPollUntil(timeout: 6.0) {
app.windows.count >= 1
}
)
app.typeKey("b", modifierFlags: [.command])
XCTAssertTrue(
sidebarHelpPollUntil(timeout: 3.0) {
!app.buttons["SidebarHelpMenuButton"].exists && !app.buttons["Help"].exists
}
)
app.typeKey("f", modifierFlags: [.command, .option])
XCTAssertTrue(app.staticTexts["Send Feedback"].waitForExistence(timeout: 3.0))
}
func testCmdOptionFWorksFromSettingsWindow() {
let app = XCUIApplication()
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
app.launchEnvironment["CMUX_UI_TEST_SHOW_SETTINGS"] = "1"
app.launch()
app.activate()
XCTAssertTrue(
sidebarHelpPollUntil(timeout: 6.0) {
app.windows.count >= 2
}
)
app.typeKey("f", modifierFlags: [.command, .option])
XCTAssertTrue(app.staticTexts["Send Feedback"].waitForExistence(timeout: 3.0))
XCTAssertTrue(
app.textFields["SidebarFeedbackEmailField"].waitForExistence(timeout: 2.0)
|| app.textFields["Your Email"].waitForExistence(timeout: 2.0)
)
}
}

3
web/.env.example Normal file
View file

@ -0,0 +1,3 @@
RESEND_API_KEY=
CMUX_FEEDBACK_FROM_EMAIL=
CMUX_FEEDBACK_RATE_LIMIT_ID=

View file

@ -0,0 +1,340 @@
import { checkRateLimit } from "@vercel/firewall";
import { NextResponse } from "next/server";
import { Resend } from "resend";
import { z } from "zod";
import { env } from "@/app/env";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const feedbackRecipient = "founders@manaflow.com";
const maxAttachmentCount = 10;
const maxAttachmentBytes = 4 * 1024 * 1024;
// Keep multipart requests below Vercel Functions' 4.5 MB request-body limit.
const maxTotalAttachmentBytes = 4 * 1024 * 1024;
const allowedImageTypes = new Set([
"image/gif",
"image/heic",
"image/heif",
"image/jpeg",
"image/png",
"image/tiff",
"image/webp",
]);
const feedbackSchema = z.object({
email: z.string().trim().email().max(320),
message: z.string().trim().min(1).max(4000),
appVersion: z.string().trim().max(120).optional().default(""),
appBuild: z.string().trim().max(120).optional().default(""),
appCommit: z.string().trim().max(120).optional().default(""),
bundleIdentifier: z.string().trim().max(200).optional().default(""),
osVersion: z.string().trim().max(200).optional().default(""),
locale: z.string().trim().max(120).optional().default(""),
});
type PreparedAttachment = {
content: Buffer;
contentType: string;
filename: string;
size: number;
};
export async function POST(request: Request) {
const feedbackConfig = resolveFeedbackConfig();
if (!feedbackConfig) {
return jsonError("Feedback endpoint is not configured", 503);
}
if (process.env.VERCEL === "1") {
const { error, rateLimited } = await checkRateLimit(
feedbackConfig.rateLimitId,
{ request },
);
if (rateLimited || error === "blocked") {
return jsonError("Rate limit exceeded", 429);
}
if (error === "not-found") {
console.error(
"feedback.route.rate_limit_not_found",
feedbackConfig.rateLimitId,
);
} else if (error) {
console.error("feedback.route.rate_limit_error", error);
}
}
let formData: FormData;
try {
formData = await request.formData();
} catch {
return jsonError("Invalid multipart payload", 400);
}
const parsed = feedbackSchema.safeParse({
email: getString(formData, "email"),
message: getString(formData, "message"),
appVersion: getString(formData, "appVersion"),
appBuild: getString(formData, "appBuild"),
appCommit: getString(formData, "appCommit"),
bundleIdentifier: getString(formData, "bundleIdentifier"),
osVersion: getString(formData, "osVersion"),
locale: getString(formData, "locale"),
});
if (!parsed.success) {
return jsonError("Invalid feedback payload", 400);
}
const attachmentsResult = await prepareAttachments(
formData.getAll("attachments"),
);
if ("errorResponse" in attachmentsResult) {
return attachmentsResult.errorResponse;
}
const { appBuild, appCommit, appVersion, bundleIdentifier, email, locale, message, osVersion } =
parsed.data;
const subject = buildSubject(email, message, appVersion);
const attachments = attachmentsResult.attachments;
const resend = new Resend(feedbackConfig.resendApiKey);
const { error } = await resend.emails.send({
from: `cmux feedback <${feedbackConfig.fromEmail}>`,
to: [feedbackRecipient],
replyTo: email,
subject,
text: buildTextBody({
email,
message,
appVersion,
appBuild,
appCommit,
bundleIdentifier,
osVersion,
locale,
attachments,
}),
html: buildHtmlBody({
email,
message,
appVersion,
appBuild,
appCommit,
bundleIdentifier,
osVersion,
locale,
attachments,
}),
attachments: attachments.map((attachment) => ({
content: attachment.content,
contentType: attachment.contentType,
filename: attachment.filename,
})),
});
if (error) {
console.error("feedback.route.resend_failed", error);
return jsonError("Failed to send feedback", 502);
}
return NextResponse.json(
{ ok: true },
{
headers: {
"Cache-Control": "no-store",
},
},
);
}
function resolveFeedbackConfig() {
const resendApiKey = env.RESEND_API_KEY;
const fromEmail = env.CMUX_FEEDBACK_FROM_EMAIL;
const rateLimitId = env.CMUX_FEEDBACK_RATE_LIMIT_ID;
if (!resendApiKey || !fromEmail || !rateLimitId) {
return null;
}
return {
resendApiKey,
fromEmail,
rateLimitId,
};
}
function getString(formData: FormData, key: string) {
const value = formData.get(key);
return typeof value === "string" ? value.trim() : "";
}
async function prepareAttachments(values: FormDataEntryValue[]) {
const files = values.filter(
(value): value is File => value instanceof File && value.name.length > 0,
);
if (files.length > maxAttachmentCount) {
return {
errorResponse: jsonError("Too many images attached", 400),
};
}
let totalSize = 0;
const attachments: PreparedAttachment[] = [];
for (const file of files) {
if (!allowedImageTypes.has(file.type)) {
return {
errorResponse: jsonError("Unsupported image attachment type", 415),
};
}
if (file.size > maxAttachmentBytes) {
return {
errorResponse: jsonError("Image attachment is too large", 413),
};
}
totalSize += file.size;
if (totalSize > maxTotalAttachmentBytes) {
return {
errorResponse: jsonError("Total image attachment size is too large", 413),
};
}
attachments.push({
content: Buffer.from(await file.arrayBuffer()),
contentType: file.type,
filename: sanitizeFilename(file.name),
size: file.size,
});
}
return { attachments };
}
function buildSubject(email: string, message: string, appVersion: string) {
const firstNonEmptyLine =
message
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? "Feedback";
const summary =
firstNonEmptyLine.length > 72
? `${firstNonEmptyLine.slice(0, 69)}...`
: firstNonEmptyLine;
const versionSuffix = appVersion ? ` (v${appVersion})` : "";
return `cmux feedback from ${email}${versionSuffix}: ${summary}`;
}
function buildTextBody(input: {
email: string;
message: string;
appVersion: string;
appBuild: string;
appCommit: string;
bundleIdentifier: string;
osVersion: string;
locale: string;
attachments: PreparedAttachment[];
}) {
const attachmentLines =
input.attachments.length === 0
? "Attachments: none"
: [
"Attachments:",
...input.attachments.map(
(attachment) =>
`- ${attachment.filename} (${attachment.contentType}, ${attachment.size} bytes)`,
),
].join("\n");
return [
`From: ${input.email}`,
`App version: ${input.appVersion || "unknown"}`,
`App build: ${input.appBuild || "unknown"}`,
`App commit: ${input.appCommit || "unknown"}`,
`Bundle identifier: ${input.bundleIdentifier || "unknown"}`,
`macOS: ${input.osVersion || "unknown"}`,
`Locale: ${input.locale || "unknown"}`,
attachmentLines,
"",
"Message:",
input.message,
].join("\n");
}
function buildHtmlBody(input: {
email: string;
message: string;
appVersion: string;
appBuild: string;
appCommit: string;
bundleIdentifier: string;
osVersion: string;
locale: string;
attachments: PreparedAttachment[];
}) {
const attachmentMarkup =
input.attachments.length === 0
? "<p><strong>Attachments:</strong> none</p>"
: `<p><strong>Attachments:</strong></p><ul>${input.attachments
.map(
(attachment) =>
`<li>${escapeHtml(attachment.filename)} (${escapeHtml(
attachment.contentType,
)}, ${attachment.size} bytes)</li>`,
)
.join("")}</ul>`;
return `
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#111827;line-height:1.5">
<h1 style="font-size:18px;margin:0 0 16px">cmux feedback</h1>
<p><strong>From:</strong> ${escapeHtml(input.email)}</p>
<p><strong>App version:</strong> ${escapeHtml(input.appVersion || "unknown")}</p>
<p><strong>App build:</strong> ${escapeHtml(input.appBuild || "unknown")}</p>
<p><strong>App commit:</strong> ${escapeHtml(input.appCommit || "unknown")}</p>
<p><strong>Bundle identifier:</strong> ${escapeHtml(
input.bundleIdentifier || "unknown",
)}</p>
<p><strong>macOS:</strong> ${escapeHtml(input.osVersion || "unknown")}</p>
<p><strong>Locale:</strong> ${escapeHtml(input.locale || "unknown")}</p>
${attachmentMarkup}
<h2 style="font-size:15px;margin:24px 0 8px">Message</h2>
<pre style="white-space:pre-wrap;font:13px/1.6 SFMono-Regular,Menlo,monospace;background:#f3f4f6;border-radius:10px;padding:12px">${escapeHtml(
input.message,
)}</pre>
</div>
`.trim();
}
function sanitizeFilename(fileName: string) {
const cleaned = fileName.replace(/[\r\n"]/g, "").trim();
return cleaned.length > 0 ? cleaned : "attachment";
}
function escapeHtml(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function jsonError(message: string, status: number) {
return NextResponse.json(
{ error: message },
{
status,
headers: {
"Cache-Control": "no-store",
},
},
);
}

18
web/app/env.ts Normal file
View file

@ -0,0 +1,18 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
server: {
RESEND_API_KEY: z.string().min(1),
CMUX_FEEDBACK_FROM_EMAIL: z.string().email(),
CMUX_FEEDBACK_RATE_LIMIT_ID: z.string().min(1),
},
runtimeEnv: {
RESEND_API_KEY: process.env.RESEND_API_KEY,
CMUX_FEEDBACK_FROM_EMAIL: process.env.CMUX_FEEDBACK_FROM_EMAIL,
CMUX_FEEDBACK_RATE_LIMIT_ID: process.env.CMUX_FEEDBACK_RATE_LIMIT_ID,
},
skipValidation:
process.env.SKIP_ENV_VALIDATION === "1" ||
process.env.VERCEL_ENV === "preview",
});

View file

@ -5,6 +5,8 @@
"": {
"name": "web",
"dependencies": {
"@t3-oss/env-nextjs": "^0.13.10",
"@vercel/firewall": "^1.1.2",
"next": "16.1.6",
"next-themes": "^0.4.6",
"posthog-js": "^1.350.0",
@ -12,7 +14,9 @@
"react-dom": "19.2.3",
"react-tweet": "^3.3.0",
"react-wrap-balancer": "^1.1.1",
"resend": "^6.9.3",
"shiki": "^3.22.0",
"zod": "^4.3.6",
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@ -249,8 +253,14 @@
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
"@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="],
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
"@t3-oss/env-core": ["@t3-oss/env-core@0.13.10", "", { "peerDependencies": { "arktype": "^2.1.0", "typescript": ">=5.0.0", "valibot": "^1.0.0-beta.7 || ^1.0.0", "zod": "^3.24.0 || ^4.0.0" }, "optionalPeers": ["arktype", "typescript", "valibot", "zod"] }, "sha512-NNFfdlJ+HmPHkLi2HKy7nwuat9SIYOxei9K10lO2YlcSObDILY7mHZNSHsieIM3A0/5OOzw/P/b+yLvPdaG52g=="],
"@t3-oss/env-nextjs": ["@t3-oss/env-nextjs@0.13.10", "", { "dependencies": { "@t3-oss/env-core": "0.13.10" }, "peerDependencies": { "arktype": "^2.1.0", "typescript": ">=5.0.0", "valibot": "^1.0.0-beta.7 || ^1.0.0", "zod": "^3.24.0 || ^4.0.0" }, "optionalPeers": ["arktype", "typescript", "valibot", "zod"] }, "sha512-JfSA2WXOnvcc/uMdp31paMsfbYhhdvLLRxlwvrnlPE9bwM/n0Z+Qb9xRv48nPpvfMhOrkrTYw1I5Yc06WIKBJQ=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="],
@ -363,6 +373,8 @@
"@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="],
"@vercel/firewall": ["@vercel/firewall@1.1.2", "", {}, "sha512-h0sdBVrloWx8TitvWla/rGj3AnJ5JEYfL5LaGHNNOWkyMuzNqfCcGTvJgnjL2A5eSpAAzoN7Xt609YQ0L7xZdw=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
@ -543,6 +555,8 @@
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="],
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
@ -819,6 +833,8 @@
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
"postal-mime": ["postal-mime@2.7.3", "", {}, "sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"posthog-js": ["posthog-js@1.350.0", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.208.0", "@opentelemetry/exporter-logs-otlp-http": "^0.208.0", "@opentelemetry/resources": "^2.2.0", "@opentelemetry/sdk-logs": "^0.208.0", "@posthog/core": "1.23.0", "@posthog/types": "1.350.0", "core-js": "^3.38.1", "dompurify": "^3.3.1", "fflate": "^0.4.8", "preact": "^10.28.2", "query-selector-shadow-dom": "^1.0.1", "web-vitals": "^5.1.0" } }, "sha512-Ab+dyQdlKUTrfUZ12+fvcBo75S4jw/3o2gMleDga21B1v9c15yybiX4S3JrX66uh5L1DYG1H8sxtd4BXIIodjQ=="],
@ -859,6 +875,8 @@
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
"resend": ["resend@6.9.3", "", { "dependencies": { "postal-mime": "2.7.3", "svix": "1.84.1" }, "peerDependencies": { "@react-email/render": "*" }, "optionalPeers": ["@react-email/render"] }, "sha512-GRXjH9XZBJA+daH7bBVDuTShr22iWCxXA8P7t495G4dM/RC+d+3gHBK/6bz9K6Vpcq11zRQKmD+B+jECwQlyGQ=="],
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
@ -907,6 +925,8 @@
"stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="],
"standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="],
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
"string.prototype.includes": ["string.prototype.includes@2.0.1", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="],
@ -933,6 +953,8 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"svix": ["svix@1.84.1", "", { "dependencies": { "standardwebhooks": "1.0.0", "uuid": "^10.0.0" } }, "sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ=="],
"swr": ["swr@2.4.0", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-sUlC20T8EOt1pHmDiqueUWMmRRX03W7w5YxovWX7VR2KHEPCTMly85x05vpkP5i6Bu4h44ePSMD9Tc+G2MItFw=="],
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
@ -987,6 +1009,8 @@
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],

View file

@ -1,3 +1,4 @@
import "./app/env";
import type { NextConfig } from "next";
const nextConfig: NextConfig = {

192
web/package-lock.json generated
View file

@ -9,13 +9,18 @@
"version": "0.1.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@t3-oss/env-nextjs": "^0.13.10",
"@vercel/firewall": "^1.1.2",
"next": "16.1.6",
"next-themes": "^0.4.6",
"posthog-js": "^1.350.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-tweet": "^3.3.0",
"react-wrap-balancer": "^1.1.1",
"shiki": "^3.22.0"
"resend": "^6.9.3",
"shiki": "^3.22.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@ -1630,6 +1635,12 @@
"integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
"license": "MIT"
},
"node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@ -1639,6 +1650,61 @@
"tslib": "^2.8.0"
}
},
"node_modules/@t3-oss/env-core": {
"version": "0.13.10",
"resolved": "https://registry.npmjs.org/@t3-oss/env-core/-/env-core-0.13.10.tgz",
"integrity": "sha512-NNFfdlJ+HmPHkLi2HKy7nwuat9SIYOxei9K10lO2YlcSObDILY7mHZNSHsieIM3A0/5OOzw/P/b+yLvPdaG52g==",
"license": "MIT",
"peerDependencies": {
"arktype": "^2.1.0",
"typescript": ">=5.0.0",
"valibot": "^1.0.0-beta.7 || ^1.0.0",
"zod": "^3.24.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"arktype": {
"optional": true
},
"typescript": {
"optional": true
},
"valibot": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/@t3-oss/env-nextjs": {
"version": "0.13.10",
"resolved": "https://registry.npmjs.org/@t3-oss/env-nextjs/-/env-nextjs-0.13.10.tgz",
"integrity": "sha512-JfSA2WXOnvcc/uMdp31paMsfbYhhdvLLRxlwvrnlPE9bwM/n0Z+Qb9xRv48nPpvfMhOrkrTYw1I5Yc06WIKBJQ==",
"license": "MIT",
"dependencies": {
"@t3-oss/env-core": "0.13.10"
},
"peerDependencies": {
"arktype": "^2.1.0",
"typescript": ">=5.0.0",
"valibot": "^1.0.0-beta.7 || ^1.0.0",
"zod": "^3.24.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"arktype": {
"optional": true
},
"typescript": {
"optional": true
},
"valibot": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/@tailwindcss/node": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.0.tgz",
@ -2559,6 +2625,15 @@
"win32"
]
},
"node_modules/@vercel/firewall": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@vercel/firewall/-/firewall-1.1.2.tgz",
"integrity": "sha512-h0sdBVrloWx8TitvWla/rGj3AnJ5JEYfL5LaGHNNOWkyMuzNqfCcGTvJgnjL2A5eSpAAzoN7Xt609YQ0L7xZdw==",
"license": "Apache-2.0",
"engines": {
"node": ">= 20"
}
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@ -3055,6 +3130,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -4031,6 +4115,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
@ -6044,6 +6134,12 @@
"node": ">= 0.4"
}
},
"node_modules/postal-mime": {
"version": "2.7.3",
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.3.tgz",
"integrity": "sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==",
"license": "MIT-0"
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@ -6225,6 +6321,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/react-tweet": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/react-tweet/-/react-tweet-3.3.0.tgz",
"integrity": "sha512-gSIG2169ZK7UH6rBzuU+j1xnQbH3IlOTLEkuGrRiJJTMgETik+h+26yHyyVKrLkzwrOaYPk4K3OtEKycqKgNLw==",
"license": "MIT",
"dependencies": {
"@swc/helpers": "^0.5.3",
"clsx": "^2.0.0",
"swr": "^2.2.4"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}
},
"node_modules/react-wrap-balancer": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/react-wrap-balancer/-/react-wrap-balancer-1.1.1.tgz",
@ -6302,6 +6413,27 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/resend": {
"version": "6.9.3",
"resolved": "https://registry.npmjs.org/resend/-/resend-6.9.3.tgz",
"integrity": "sha512-GRXjH9XZBJA+daH7bBVDuTShr22iWCxXA8P7t495G4dM/RC+d+3gHBK/6bz9K6Vpcq11zRQKmD+B+jECwQlyGQ==",
"license": "MIT",
"dependencies": {
"postal-mime": "2.7.3",
"svix": "1.84.1"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@react-email/render": "*"
},
"peerDependenciesMeta": {
"@react-email/render": {
"optional": true
}
}
},
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@ -6695,6 +6827,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/standardwebhooks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
"license": "MIT",
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
}
},
"node_modules/stop-iteration-iterator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@ -6908,6 +7050,29 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/svix": {
"version": "1.84.1",
"resolved": "https://registry.npmjs.org/svix/-/svix-1.84.1.tgz",
"integrity": "sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==",
"license": "MIT",
"dependencies": {
"standardwebhooks": "1.0.0",
"uuid": "^10.0.0"
}
},
"node_modules/swr": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/swr/-/swr-2.4.1.tgz",
"integrity": "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==",
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/tailwindcss": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.0.tgz",
@ -7140,7 +7305,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@ -7343,6 +7508,28 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/vfile": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
@ -7515,7 +7702,6 @@
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"

View file

@ -10,6 +10,8 @@
"lint": "eslint"
},
"dependencies": {
"@t3-oss/env-nextjs": "^0.13.10",
"@vercel/firewall": "^1.1.2",
"next": "16.1.6",
"next-themes": "^0.4.6",
"posthog-js": "^1.350.0",
@ -17,7 +19,9 @@
"react-dom": "19.2.3",
"react-tweet": "^3.3.0",
"react-wrap-balancer": "^1.1.1",
"shiki": "^3.22.0"
"resend": "^6.9.3",
"shiki": "^3.22.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",