feat: iOS appearance, iPad logo fix, demo seed, and landing hero polish

Add System/Light/Dark preference for iPhone and iPad, restore the iPad
sidebar brand mark, ship a DEBUG seed-demo URL for rich placeholder data,
and refresh GitHub Pages with the device composite, full-bleed pale-green
hero wash, and transparent Mac screenshot chrome.
This commit is contained in:
Rocky
2026-07-12 16:24:59 +08:00
parent a57bba9ea0
commit c598ba77e6
18 changed files with 424 additions and 220 deletions
+6
View File
@@ -9,11 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- **Voluntary support tip**: Settings (top of the page) includes an optional ¥28 Consumable in-app tip (StoreKit 2). All features stay free — no paywall or unlock. / **自愿打赏**:设置页顶部新增可选 ¥28 消耗型应用内打赏(StoreKit 2)。全功能仍免费,无付费墙或功能解锁。 - **Voluntary support tip**: Settings (top of the page) includes an optional ¥28 Consumable in-app tip (StoreKit 2). All features stay free — no paywall or unlock. / **自愿打赏**:设置页顶部新增可选 ¥28 消耗型应用内打赏(StoreKit 2)。全功能仍免费,无付费墙或功能解锁。
- **iOS appearance preference**: Settings → Preferences adds System / Light / Dark (iPhone + iPad), matching the Mac control. / **iOS 外观偏好**:设置 → 偏好设置新增跟随系统 / 浅色 / 深色(iPhone 与 iPad),与 Mac 一致。
- **DEBUG demo seed URL**: `osgkeyboard://seed-demo` fills Home stats, History, and Dictionary with placeholder data and turns iCloud sync off (script: `scripts/seed_demo_data.py`). / **DEBUG 演示数据**`osgkeyboard://seed-demo` 填充首页统计、历史与词库占位数据并关闭 iCloud 同步(脚本:`scripts/seed_demo_data.py`)。
### Changed ### Changed
- **Unified welcome slogan**: iPad Home now reuses the iOS onboarding brand line, and macOS onboarding shows the same “Speak it. Its typed.” welcome slogan. / **统一欢迎口号**:iPad 首页复用 iOS 引导页品牌句,macOS 引导页也显示同一句「开口即文字。」欢迎口号。 - **Unified welcome slogan**: iPad Home now reuses the iOS onboarding brand line, and macOS onboarding shows the same “Speak it. Its typed.” welcome slogan. / **统一欢迎口号**:iPad 首页复用 iOS 引导页品牌句,macOS 引导页也显示同一句「开口即文字。」欢迎口号。
- **GitHub Pages landing**: redesign as a commercial product page with zh/en, light/dark, brand mark, scroll motion, and App Store screenshots; emphasizes free, cross-platform, open source, privacy, and BYOK. / **GitHub Pages 落地页**:改版为商业产品页,支持中英与日夜模式、品牌标、滚动动效与 App Store 截图;突出免费、跨端、开源、隐私与 BYOK。 - **GitHub Pages landing**: redesign as a commercial product page with zh/en, light/dark, brand mark, scroll motion, and App Store screenshots; emphasizes free, cross-platform, open source, privacy, and BYOK. / **GitHub Pages 落地页**:改版为商业产品页,支持中英与日夜模式、品牌标、滚动动效与 App Store 截图;突出免费、跨端、开源、隐私与 BYOK。
- **Landing hero device family**: Mac + iPad + iPhone nested in one mockup cluster (no outer card stroke); screens swap with language/theme. / **落地页 Hero 设备组**Mac、iPad、iPhone 叠放在同一组设备框内(无外卡片描边);截图随语言/主题切换。 - **Landing hero device family**: Mac + iPad + iPhone nested in one mockup cluster (no outer card stroke); screens swap with language/theme. / **落地页 Hero 设备组**Mac、iPad、iPhone 叠放在同一组设备框内(无外卡片描边);截图随语言/主题切换。
- **Landing hero polish**: replace CSS device frames with the marketing composite; full-bleed pale-green hero wash (no side gaps / no radial gradient); Mac story shots sit on transparent chrome. / **落地页 Hero 抛光**:设备框改为营销合成图;首屏淡绿单色通栏(无两侧留白 / 无径向渐变);Mac 故事截图去卡片底。
### Fixed
- **iPad sidebar brand mark**: use the template `OSGLogoWide` mark with accent tint so the logo stays visible in the split-view sidebar. / **iPad 侧栏品牌标**:改用可着色的 `OSGLogoWide`,保证分栏侧栏始终显示 logo。
## [0.5.3] - 2026-07-11 ## [0.5.3] - 2026-07-11
+10
View File
@@ -8,6 +8,14 @@ import OSGKeyboardShared
struct OSGKeyboardApp: App { struct OSGKeyboardApp: App {
@UIApplicationDelegateAdaptor(AppURLHandler.self) private var appURLHandler @UIApplicationDelegateAdaptor(AppURLHandler.self) private var appURLHandler
/// App-local light / dark preference (Settings Preferences Appearance).
@AppStorage(AppearancePreference.storageKey)
private var appearanceRaw = AppearancePreference.system.rawValue
private var appearance: AppearancePreference {
AppearancePreference.fromStored(appearanceRaw)
}
init() { init() {
MaterialIconsFont.registerIfNeeded() MaterialIconsFont.registerIfNeeded()
if AppGroup.isAvailable { if AppGroup.isAvailable {
@@ -21,10 +29,12 @@ struct OSGKeyboardApp: App {
ThemedRoot { ThemedRoot {
MainAppRoot() MainAppRoot()
} }
.preferredColorScheme(appearance.colorScheme)
} else { } else {
ThemedRoot { ThemedRoot {
AppGroupErrorView() AppGroupErrorView()
} }
.preferredColorScheme(appearance.colorScheme)
} }
} }
} }
+5
View File
@@ -80,6 +80,11 @@ struct MainAppRoot: View {
switch url.host { switch url.host {
case "startflow": case "startflow":
flowManager.startSession(coldStart: true) flowManager.startSession(coldStart: true)
#if DEBUG
case "seed-demo":
DemoDataSeeder.seedRichPlaceholderData()
config.reloadFromPersistedStorage()
#endif
default: default:
break break
} }
+8 -2
View File
@@ -54,13 +54,19 @@ struct MainSplitView: View {
private var brandHeader: some View { private var brandHeader: some View {
HStack { HStack {
Image("osglogo") // Match macOS sidebar: template wide mark tinted with accent so
// light / dark both stay readable (PNG `osglogo` can fail to
// show under NavigationSplitView chrome on some iPad sizes).
Image("OSGLogoWide")
.renderingMode(.template)
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.frame(height: 28) .frame(height: 28)
.foregroundStyle(palette.accent)
.accessibilityLabel("OSGKeyboard") .accessibilityLabel("OSGKeyboard")
Spacer() Spacer(minLength: 0)
} }
.frame(maxWidth: .infinity, minHeight: 28, alignment: .leading)
.padding(.leading, WideLayoutMetrics.sidebarContentInset) .padding(.leading, WideLayoutMetrics.sidebarContentInset)
.padding(.trailing, WideLayoutMetrics.sidebarInset) .padding(.trailing, WideLayoutMetrics.sidebarInset)
.padding(.top, Spacing.lg) .padding(.top, Spacing.lg)
+25
View File
@@ -156,6 +156,10 @@ struct SettingsView: View {
Divider().background(palette.divider) Divider().background(palette.divider)
AppearancePickerRow()
Divider().background(palette.divider)
LocalePickerRow( LocalePickerRow(
locales: effectiveLocales, locales: effectiveLocales,
selection: Binding( selection: Binding(
@@ -495,6 +499,27 @@ private struct AppLanguagePickerRow: View {
} }
} }
// MARK: - Appearance picker row
private struct AppearancePickerRow: View {
@AppStorage(AppearancePreference.storageKey)
private var appearanceRaw = AppearancePreference.system.rawValue
private var options: [(id: String, label: String)] {
AppearancePreference.allCases.map { preference in
(preference.rawValue, AppL10n.string(preference.labelKey))
}
}
var body: some View {
PickerRow(
title: AppL10n.string("settings.appearance.title"),
options: options,
selection: $appearanceRaw
)
}
}
// MARK: - Flow inactivity picker row // MARK: - Flow inactivity picker row
private struct FlowInactivityPickerRow: View { private struct FlowInactivityPickerRow: View {
+4
View File
@@ -91,6 +91,10 @@
"settings.appLanguage.auto" = "Auto"; "settings.appLanguage.auto" = "Auto";
"settings.appLanguage.english" = "English"; "settings.appLanguage.english" = "English";
"settings.appLanguage.chinese" = "Chinese"; "settings.appLanguage.chinese" = "Chinese";
"settings.appearance.title" = "Appearance";
"settings.appearance.system" = "System";
"settings.appearance.light" = "Light";
"settings.appearance.dark" = "Dark";
"settings.reset.title" = "Reset all settings?"; "settings.reset.title" = "Reset all settings?";
"settings.reset.message" = "API key, model, base URL, voice history, and cloud consent will be cleared."; "settings.reset.message" = "API key, model, base URL, voice history, and cloud consent will be cleared.";
"settings.reset.confirm" = "Reset all settings"; "settings.reset.confirm" = "Reset all settings";
@@ -91,6 +91,10 @@
"settings.appLanguage.auto" = "自动"; "settings.appLanguage.auto" = "自动";
"settings.appLanguage.english" = "英文"; "settings.appLanguage.english" = "英文";
"settings.appLanguage.chinese" = "中文"; "settings.appLanguage.chinese" = "中文";
"settings.appearance.title" = "外观";
"settings.appearance.system" = "跟随系统";
"settings.appearance.light" = "浅色";
"settings.appearance.dark" = "深色";
"settings.reset.title" = "重置所有设置?"; "settings.reset.title" = "重置所有设置?";
"settings.reset.message" = "将清空 API Key、模型、接口地址、语音历史及云端确认状态。"; "settings.reset.message" = "将清空 API Key、模型、接口地址、语音历史及云端确认状态。";
"settings.reset.confirm" = "重置所有设置"; "settings.reset.confirm" = "重置所有设置";
+11
View File
@@ -23,6 +23,17 @@
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string> <string>$(MARKETING_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.osgkeyboard.mac.seed</string>
<key>CFBundleURLSchemes</key>
<array>
<string>osgkeyboard</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string> <string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>
+14
View File
@@ -52,6 +52,20 @@ struct OSGKeyboardMacApp: App {
.onReceive(NotificationCenter.default.publisher(for: .speechHistoryDidSyncFromCloud)) { _ in .onReceive(NotificationCenter.default.publisher(for: .speechHistoryDidSyncFromCloud)) { _ in
viewModel.speechHistory.reloadFromDisk() viewModel.speechHistory.reloadFromDisk()
} }
.onOpenURL { url in
#if DEBUG
guard url.scheme == "osgkeyboard", url.host == "seed-demo" else { return }
DemoDataSeeder.seedRichPlaceholderData(
defaults: .standard,
historyDefaults: .standard
)
viewModel.reloadConfigFromCloud()
viewModel.refreshDictionaryFromCloud()
viewModel.usageStatistics.reloadFromDisk()
viewModel.speechHistory.reloadFromDisk()
hasCompletedMacOnboarding = true
#endif
}
} }
// Borderless titlebar content (sidebar + traffic lights) runs to the // Borderless titlebar content (sidebar + traffic lights) runs to the
// very top, matching macOS System Settings. // very top, matching macOS System Settings.
@@ -0,0 +1,41 @@
// AppearancePreference.swift
// OSGKeyboard · Shared
//
// In-app light / dark preference for the iOS main app (iPhone + iPad).
// Mirrors macOS `MacAppearancePreference` but uses iOS Settings copy keys.
// Stored in standard UserDefaults (app-local); not synced via iCloud.
import SwiftUI
/// How the iOS host app resolves its colour scheme.
public enum AppearancePreference: String, CaseIterable, Identifiable, Sendable, Codable {
case system
case light
case dark
public var id: String { rawValue }
/// `nil` means follow the system SwiftUI's `preferredColorScheme(nil)`.
public var colorScheme: ColorScheme? {
switch self {
case .system: return nil
case .light: return .light
case .dark: return .dark
}
}
public var labelKey: String {
switch self {
case .system: return "settings.appearance.system"
case .light: return "settings.appearance.light"
case .dark: return "settings.appearance.dark"
}
}
public static let storageKey = "config.appearancePreference"
public static func fromStored(_ raw: String?) -> AppearancePreference {
guard let raw, let value = AppearancePreference(rawValue: raw) else { return .system }
return value
}
}
@@ -0,0 +1,143 @@
// DemoDataSeeder.swift
// OSGKeyboard · Shared
//
// DEBUG / screenshot helper: fills Home stats, History, and Dictionary with
// rich placeholder content, and forces iCloud sync OFF so a remote pull cannot
// wipe the seed. Trigger via `osgkeyboard://seed-demo`.
import Foundation
@MainActor
public enum DemoDataSeeder {
/// Disable sync (local + KVS), write placeholder payloads, reload stores.
public static func seedRichPlaceholderData(
defaults: UserDefaults? = nil,
historyDefaults: UserDefaults = .standard
) {
let store = defaults.map { AppGroupStore(defaults: $0) } ?? AppGroupStore()
let groupDefaults = store.defaults
// 1. Sync must be OFF before any pull can empty the dictionary.
store.setSettingsICloudSyncEnabled(false)
store.setPersonalDictionaryICloudSyncEnabled(false)
ICloudSyncPreferences.pushSettingsEnabled(false, kvs: NSUbiquitousKeyValueStore.default)
ICloudSyncPreferences.pushDictionaryEnabled(false, kvs: NSUbiquitousKeyValueStore.default)
groupDefaults.set(true, forKey: "usageStatistics.dirtyReset.v1")
groupDefaults.set(true, forKey: "home.keyboardHintDismissed")
groupDefaults.set(true, forKey: AppGroupConfiguration.Keys.hasCompletedOnboarding)
// Mac onboarding flag (harmless on iOS).
groupDefaults.set(true, forKey: "mac.hasCompletedOnboarding")
UserDefaults.standard.set(true, forKey: "mac.hasCompletedOnboarding")
let now = Date()
seedUsage(defaults: groupDefaults, now: now)
seedHistory(defaults: historyDefaults, now: now)
seedDictionary(store: store, now: now)
UsageStatisticsStore(defaults: groupDefaults).reloadFromDisk()
UsageStatisticsStore.shared.reloadFromDisk()
SpeechHistoryStore(defaults: historyDefaults).reloadFromDisk()
SpeechHistoryStore.shared.reloadFromDisk()
NotificationCenter.default.post(name: .personalDictionaryDidSyncFromCloud, object: nil)
NotificationCenter.default.post(name: .usageStatisticsDidSyncFromCloud, object: nil)
}
// MARK: - Payloads
private static func seedUsage(defaults: UserDefaults, now: Date) {
let calendar = Calendar.current
let today = calendar.startOfDay(for: now)
let dailyValues = [2100, 3400, 1800, 5200, 2900, 6100, 4300]
var daily: [String: Int] = [:]
for (offset, value) in dailyValues.enumerated() {
guard let day = calendar.date(byAdding: .day, value: offset - 6, to: today) else { continue }
daily[UsageStatisticsDayKey.key(for: day)] = value
}
let deviceID = SyncDeviceID.current(defaults: defaults)
let slice = UsageStatisticsDeviceSlice(
updatedAt: now,
dictationDurationSeconds: 6120,
dictationCharacterCount: 31_458,
translationCharacterCount: 3_200,
dailyDictationCharacters: daily
)
SyncedUsageStatisticsStorage.upsertCurrentDeviceSlice(
slice,
defaults: defaults,
deviceID: deviceID
)
}
private static func seedHistory(defaults: UserDefaults, now: Date) {
let samples: [(String, String)] = [
("local", "请帮我把这段会议纪要整理成三条行动项,并标出负责人。"),
("local", "下周产品评审改到周三下午两点,地点还是三楼会议室。"),
("cloud", "帮我写一封礼貌的跟进邮件,询问合同进度。"),
("local", "今天听写了三十分钟,词库命中率比昨天更好。"),
("local", "把「开口即文字」加到品牌口号里,首页和引导页保持一致。"),
("cloud", "Draft a short release note for the appearance preference on iOS and iPad."),
("local", "提醒我晚上九点前提交 App Store 截图和落地页更新。"),
("local", "语音输入在任意 App 可用,点按键盘麦克风即可开始听写。"),
("cloud", "Summarize yesterday's dictation stats for the weekly report."),
("local", "词库里加上 Cursor、DeepSeek、Qwen3-ASR,方便识别专有名词。"),
("local", "跨设备同步先关掉,演示数据用本地占位,避免被 iCloud 覆盖。"),
("local", "把首页近七天柱状图补齐,看起来更有真实使用痕迹。"),
]
var entries: [SpeechHistoryEntry] = []
for (index, pair) in samples.enumerated() {
let created = now.addingTimeInterval(-Double(index * 5 * 3600 + index * 7 * 60))
entries.append(
SpeechHistoryEntry(
id: UUID(),
text: pair.1,
createdAt: created,
engineMode: pair.0
)
)
}
var history = SyncedSpeechHistory(updatedAt: now)
history.entries = entries
history.deletedEntryIDs = [:]
SpeechHistoryStorage.save(history, to: defaults)
}
private static func seedDictionary(store: AppGroupStore, now: Date) {
let terms: [(String, [String], PersonalDictionary.Entry.Category, Int)] = [
("OSGKeyboard", ["OSG Keyboard", "开口即文字"], .productName, 48),
("Cursor", ["cursor"], .productName, 36),
("DeepSeek", ["deep seek", "深度求索"], .productName, 29),
("Qwen3-ASR", ["千问 ASR", "Qwen ASR"], .technical, 22),
("BYOK", ["自带密钥"], .acronym, 18),
("Sherpa", ["sherpa onnx"], .technical, 15),
("Typeless", [], .productName, 11),
("iCloud", ["云同步"], .productName, 9),
("SpeechAnalyzer", ["语音分析器"], .technical, 7),
("Live Activity", ["灵动岛"], .custom, 5),
("StoreKit", ["内购"], .technical, 4),
("Rocky", ["rocky"], .properNoun, 3),
]
var dictionary = PersonalDictionary()
for (index, item) in terms.enumerated() {
let created = now.addingTimeInterval(-Double((20 - index) * 86_400))
dictionary.entries.append(
PersonalDictionary.Entry(
id: UUID(),
term: item.0,
aliases: item.1,
category: item.2,
source: .manual,
createdAt: created,
updatedAt: created.addingTimeInterval(3600),
usageCount: item.3
)
)
}
store.setPersonalDictionary(dictionary)
}
}
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Seed rich demo data into OSGKeyboard via in-app DEBUG URL.
Uses `osgkeyboard://seed-demo` so the app itself disables iCloud sync and
writes Home / History / Dictionary placeholders with the real Swift models
(avoids KVS wiping plist-only seeds).
Usage:
python3 scripts/seed_demo_data.py --mac
python3 scripts/seed_demo_data.py --sim <UDID>
python3 scripts/seed_demo_data.py --all
"""
from __future__ import annotations
import argparse
import subprocess
import sys
import time
from pathlib import Path
BUNDLE_IOS = "com.osgkeyboard.ios"
SEED_URL = "osgkeyboard://seed-demo"
MAC_APP = Path(
"/Users/rocky/Library/Developer/Xcode/DerivedData/OSGKeyboard-dgaosbtwferhcpclfyzxmfuwaikn"
"/Build/Products/Debug/OSGKeyboard.app"
)
# Fallback if DerivedData folder hash changes.
if not MAC_APP.exists():
alt = Path("/Users/rocky/Documents/OSGKeyboard/build/mac/Build/Products/Debug/OSGKeyboard.app")
if alt.exists():
MAC_APP = alt
else:
found = sorted(Path.home().glob(
"Library/Developer/Xcode/DerivedData/OSGKeyboard-*/Build/Products/Debug/OSGKeyboard.app"
))
if found:
MAC_APP = found[-1]
def run(cmd: list[str], check: bool = True) -> subprocess.CompletedProcess[str]:
return subprocess.run(cmd, check=check, capture_output=True, text=True)
def seed_mac() -> None:
print("=== macOS ===")
run(["osascript", "-e", 'tell application "OSGKeyboard" to quit'], check=False)
run(["pkill", "-x", "OSGKeyboard"], check=False)
time.sleep(0.5)
if MAC_APP.exists():
run(["open", str(MAC_APP)], check=False)
else:
run(["open", "-a", "OSGKeyboard"], check=False)
time.sleep(2.0)
# openURL while app is running
result = run(["open", SEED_URL], check=False)
if result.returncode != 0:
print("open URL failed:", result.stderr, file=sys.stderr)
else:
print(f"opened {SEED_URL}")
time.sleep(1.5)
def seed_sim(udid: str) -> None:
print(f"=== simulator {udid} ===")
run(["xcrun", "simctl", "boot", udid], check=False)
run(["xcrun", "simctl", "bootstatus", udid, "-b"], check=False)
# Ensure app is frontmost
run(["xcrun", "simctl", "terminate", udid, BUNDLE_IOS], check=False)
time.sleep(0.3)
launch = run(["xcrun", "simctl", "launch", udid, BUNDLE_IOS], check=False)
if launch.returncode != 0:
raise RuntimeError(f"launch failed: {launch.stderr.strip()}")
time.sleep(2.0)
opened = run(["xcrun", "simctl", "openurl", udid, SEED_URL], check=False)
if opened.returncode != 0:
raise RuntimeError(f"openurl failed: {opened.stderr.strip()}")
print(f"opened {SEED_URL}")
time.sleep(1.5)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--mac", action="store_true")
parser.add_argument("--sim", action="append", default=[])
parser.add_argument("--all", action="store_true")
args = parser.parse_args()
sims = list(args.sim)
do_mac = args.mac
if args.all:
do_mac = True
for udid in [
"D6D8DA15-704E-4A4E-8A47-7AAB5A9DE4C4", # iPad A16
"B3EB5C4D-6802-42A7-92C1-56530523F8E3", # iPhone 17 Pro
]:
if udid not in sims:
sims.append(udid)
if not do_mac and not sims:
parser.error("Pass --mac, --sim UDID, or --all")
if do_mac:
seed_mac()
for udid in sims:
try:
seed_sim(udid)
except Exception as exc: # noqa: BLE001
print(f"skip {udid}: {exc}", file=sys.stderr)
print("done — check Home / History / Dictionary in each running app")
if __name__ == "__main__":
main()
Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 176 KiB

+35 -218
View File
@@ -41,6 +41,7 @@
--bg: #f7f7f5; --bg: #f7f7f5;
--bg-elevated: #ffffff; --bg-elevated: #ffffff;
--bg-soft: #efefec; --bg-soft: #efefec;
--hero-wash: #e7f3eb;
--text: #121214; --text: #121214;
--text-2: #5a5a62; --text-2: #5a5a62;
--text-3: #8e8e96; --text-3: #8e8e96;
@@ -60,6 +61,7 @@
--bg: #0a0a0b; --bg: #0a0a0b;
--bg-elevated: #131316; --bg-elevated: #131316;
--bg-soft: #18181b; --bg-soft: #18181b;
--hero-wash: #102018;
--text: #f4f4f2; --text: #f4f4f2;
--text-2: #a8a8b0; --text-2: #a8a8b0;
--text-3: #6f6f78; --text-3: #6f6f78;
@@ -77,6 +79,7 @@
--bg: #0a0a0b; --bg: #0a0a0b;
--bg-elevated: #131316; --bg-elevated: #131316;
--bg-soft: #18181b; --bg-soft: #18181b;
--hero-wash: #102018;
--text: #f4f4f2; --text: #f4f4f2;
--text-2: #a8a8b0; --text-2: #a8a8b0;
--text-3: #6f6f78; --text-3: #6f6f78;
@@ -219,24 +222,15 @@
/* —— Hero —— */ /* —— Hero —— */
.hero { .hero {
position: relative; position: relative;
padding: 4.5rem 0 2rem; /* Full-bleed solid wash (no side gaps from inset gradients). */
background: var(--hero-wash);
padding: 4.5rem 0 2.5rem;
overflow: hidden; overflow: hidden;
} }
.hero::before {
content: "";
position: absolute;
inset: -20% 10% auto;
height: 70%;
background:
radial-gradient(ellipse at 30% 40%, var(--accent-soft), transparent 55%),
radial-gradient(ellipse at 75% 20%, rgba(47, 154, 82, 0.08), transparent 50%);
pointer-events: none;
z-index: 0;
}
.hero .wrap { position: relative; z-index: 1; } .hero .wrap { position: relative; z-index: 1; }
.hero-copy { .hero-copy {
max-width: 42rem; max-width: 42rem;
margin: 0 auto 3rem; margin: 0 auto 2.5rem;
text-align: center; text-align: center;
} }
.eyebrow { .eyebrow {
@@ -286,176 +280,19 @@
} }
.hero-meta .material-symbols-outlined { font-size: 16px; color: var(--accent); } .hero-meta .material-symbols-outlined { font-size: 16px; color: var(--accent); }
/* —— Hero device family (Mac + iPad + iPhone) —— */ /* Marketing device-family composite (replaces CSS mockup frames). */
.hero-stage { .hero-stage {
position: relative; position: relative;
margin-top: 2.75rem; margin: 2.25rem auto 0;
max-width: 1080px; max-width: 1080px;
margin-left: auto;
margin-right: auto;
}
.device-cluster {
position: relative;
width: 100%;
aspect-ratio: 16 / 10;
min-height: 280px;
transform: translateY(16px); transform: translateY(16px);
opacity: 0; opacity: 0;
animation: rise-in 1s cubic-bezier(0.22, 1, 0.36, 1) 0.12s forwards; animation: rise-in 1s cubic-bezier(0.22, 1, 0.36, 1) 0.12s forwards;
--device-shell: #1c1c1e;
--device-shell-2: #2c2c2e;
--device-edge: rgba(255, 255, 255, 0.14);
--device-shadow: 0 28px 60px rgba(0, 0, 0, 0.28);
} }
html[data-theme="light"] .device-cluster { .hero-stage img {
--device-shell: #d8d8dc;
--device-shell-2: #c4c4c8;
--device-edge: rgba(0, 0, 0, 0.12);
--device-shadow: 0 24px 50px rgba(18, 18, 20, 0.16);
}
@media (prefers-color-scheme: light) {
html[data-theme="system"] .device-cluster {
--device-shell: #d8d8dc;
--device-shell-2: #c4c4c8;
--device-edge: rgba(0, 0, 0, 0.12);
--device-shadow: 0 24px 50px rgba(18, 18, 20, 0.16);
}
}
.device {
position: absolute;
box-sizing: border-box;
}
.device img {
display: block;
width: 100%; width: 100%;
height: 100%; height: auto;
object-fit: cover; display: block;
object-position: top center;
}
/* MacBook — back right */
.device-mac {
right: 0;
top: 4%;
width: 72%;
z-index: 1;
filter: drop-shadow(var(--device-shadow));
}
.mac-lid {
border-radius: 10px 10px 0 0;
background: linear-gradient(180deg, var(--device-shell-2), var(--device-shell));
padding: 1.4% 1.4% 0.9%;
border: 1px solid var(--device-edge);
border-bottom: none;
}
.mac-bezel {
position: relative;
border-radius: 6px 6px 2px 2px;
overflow: hidden;
background: #000;
aspect-ratio: 16 / 10;
}
.mac-notch {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 18%;
height: 3.2%;
max-height: 12px;
background: #0a0a0b;
border-radius: 0 0 7px 7px;
z-index: 2;
}
.mac-screen {
position: absolute;
inset: 0;
overflow: hidden;
}
.mac-base {
height: 10px;
margin: 0 -1.5% 0;
border-radius: 0 0 10px 10px;
background: linear-gradient(180deg, var(--device-shell), var(--device-shell-2));
border: 1px solid var(--device-edge);
border-top: none;
position: relative;
}
.mac-base::after {
content: "";
position: absolute;
left: 50%;
top: 2px;
transform: translateX(-50%);
width: 16%;
height: 4px;
border-radius: 0 0 4px 4px;
background: rgba(0, 0, 0, 0.22);
}
/* iPad — center */
.device-ipad {
left: 22%;
top: 18%;
width: 36%;
z-index: 2;
filter: drop-shadow(0 22px 40px rgba(0, 0, 0, 0.22));
}
.ipad-bezel {
border-radius: 18px;
background: linear-gradient(160deg, var(--device-shell-2), var(--device-shell));
padding: 2.2%;
border: 1px solid var(--device-edge);
}
.ipad-screen {
border-radius: 10px;
overflow: hidden;
background: #000;
aspect-ratio: 3 / 4;
}
/* iPhone — front left */
.device-phone {
left: 4%;
bottom: 2%;
width: 20%;
z-index: 3;
filter: drop-shadow(0 18px 32px rgba(0, 0, 0, 0.26));
}
.phone-bezel {
position: relative;
border-radius: 22px;
background: linear-gradient(160deg, var(--device-shell-2), var(--device-shell));
padding: 3.2%;
border: 1px solid var(--device-edge);
}
.phone-island {
position: absolute;
top: 5.5%;
left: 50%;
transform: translateX(-50%);
width: 32%;
height: 3.8%;
border-radius: 20px;
background: #0a0a0b;
z-index: 2;
}
.phone-screen {
border-radius: 16px;
overflow: hidden;
background: #000;
aspect-ratio: 9 / 19.5;
}
@media (max-width: 720px) {
.device-cluster { aspect-ratio: 5 / 4; }
.device-mac { width: 78%; right: -2%; top: 0; }
.device-ipad { width: 42%; left: 14%; top: 22%; }
.device-phone { width: 26%; left: 2%; bottom: 0; }
.phone-bezel { border-radius: 18px; }
.phone-screen { border-radius: 12px; }
.ipad-bezel { border-radius: 14px; }
} }
@keyframes rise-in { @keyframes rise-in {
@@ -588,12 +425,13 @@
} }
.shot { .shot {
margin: 0; margin: 0;
border-radius: 16px;
overflow: hidden;
border: 1px solid var(--line);
background: var(--bg-elevated);
box-shadow: var(--shadow);
line-height: 0; line-height: 0;
/* Desktop/Mac window PNGs already include chrome — no card fill behind them. */
border: none;
background: transparent;
box-shadow: none;
border-radius: 0;
overflow: visible;
} }
.shot img { .shot img {
width: 100%; width: 100%;
@@ -605,6 +443,10 @@
max-width: 300px; max-width: 300px;
width: 100%; width: 100%;
margin-inline: auto; margin-inline: auto;
overflow: hidden;
border: 1px solid var(--line);
background: var(--bg-elevated);
box-shadow: var(--shadow);
} }
/* —— Device strip —— */ /* —— Device strip —— */
@@ -617,12 +459,12 @@
} }
.device-card { .device-card {
margin: 0; margin: 0;
border-radius: 16px;
overflow: hidden;
border: 1px solid var(--line);
background: var(--bg-elevated);
box-shadow: var(--shadow);
line-height: 0; line-height: 0;
border: none;
background: transparent;
box-shadow: none;
border-radius: 0;
overflow: visible;
} }
.device-card img { .device-card img {
width: 100%; width: 100%;
@@ -637,6 +479,10 @@
flex: 0 0 auto; flex: 0 0 auto;
width: min(260px, 46vw); width: min(260px, 46vw);
border-radius: 26px; border-radius: 26px;
overflow: hidden;
border: 1px solid var(--line);
background: var(--bg-elevated);
box-shadow: var(--shadow);
} }
.device-caption { .device-caption {
margin-top: 0.65rem; margin-top: 0.65rem;
@@ -661,9 +507,7 @@
.compare { .compare {
border-radius: calc(var(--radius) + 4px); border-radius: calc(var(--radius) + 4px);
border: 1px solid var(--line); border: 1px solid var(--line);
background: background: var(--hero-wash);
linear-gradient(180deg, var(--accent-soft), transparent 42%),
var(--bg-elevated);
padding: 2rem 1.5rem; padding: 2rem 1.5rem;
overflow: hidden; overflow: hidden;
} }
@@ -793,7 +637,7 @@
transition: none !important; transition: none !important;
} }
.reveal { opacity: 1; transform: none; } .reveal { opacity: 1; transform: none; }
.device-cluster { opacity: 1; transform: none; } .hero-stage { opacity: 1; transform: none; }
} }
</style> </style>
</head> </head>
@@ -857,34 +701,7 @@
</div> </div>
<div class="hero-stage" aria-label="OSGKeyboard on Mac, iPad, and iPhone"> <div class="hero-stage" aria-label="OSGKeyboard on Mac, iPad, and iPhone">
<div class="device-cluster"> <img src="assets/hero-devices.png" width="1024" height="458" alt="OSGKeyboard on iPad, Mac, and iPhone" loading="eager">
<div class="device device-mac">
<div class="mac-lid">
<div class="mac-bezel">
<div class="mac-notch" aria-hidden="true"></div>
<div class="mac-screen">
<img data-shot="mac-dashboard" src="assets/screenshots/zh/dark/mac-dashboard.png" width="1440" height="900" alt="OSGKeyboard on Mac" loading="eager">
</div>
</div>
</div>
<div class="mac-base" aria-hidden="true"></div>
</div>
<div class="device device-ipad">
<div class="ipad-bezel">
<div class="ipad-screen">
<img data-shot="ipad-home" src="assets/screenshots/zh/light/ipad-home.png" width="800" height="1066" alt="OSGKeyboard on iPad" loading="eager">
</div>
</div>
</div>
<div class="device device-phone">
<div class="phone-bezel">
<div class="phone-island" aria-hidden="true"></div>
<div class="phone-screen">
<img data-shot="iphone-home" src="assets/screenshots/zh/light/iphone-home.png" width="552" height="1200" alt="OSGKeyboard on iPhone" loading="eager">
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</section> </section>
@@ -1357,7 +1174,7 @@
} }
// Soft parallax after hero entrance animation settles // Soft parallax after hero entrance animation settles
const stage = document.querySelector(".device-cluster"); const stage = document.querySelector(".hero-stage");
if (stage && !window.matchMedia("(prefers-reduced-motion: reduce)").matches) { if (stage && !window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
let ready = false; let ready = false;
setTimeout(() => { ready = true; }, 1100); setTimeout(() => { ready = true; }, 1100);
+4
View File
@@ -469,6 +469,10 @@ targets:
NSSpeechRecognitionUsageDescription: "OSGKeyboard uses on-device speech recognition for local dictation mode." NSSpeechRecognitionUsageDescription: "OSGKeyboard uses on-device speech recognition for local dictation mode."
NSHumanReadableCopyright: "OSGKeyboard" NSHumanReadableCopyright: "OSGKeyboard"
ITSAppUsesNonExemptEncryption: false ITSAppUsesNonExemptEncryption: false
CFBundleURLTypes:
- CFBundleURLName: com.osgkeyboard.mac.seed
CFBundleURLSchemes:
- osgkeyboard
settings: settings:
base: base:
PRODUCT_NAME: OSGKeyboard PRODUCT_NAME: OSGKeyboard