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.
@@ -9,11 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### 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)。全功能仍免费,无付费墙或功能解锁。
|
||||
- **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
|
||||
- **Unified welcome slogan**: iPad Home now reuses the iOS onboarding brand line, and macOS onboarding shows the same “Speak it. It’s 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。
|
||||
- **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
|
||||
|
||||
|
||||
@@ -8,6 +8,14 @@ import OSGKeyboardShared
|
||||
struct OSGKeyboardApp: App {
|
||||
@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() {
|
||||
MaterialIconsFont.registerIfNeeded()
|
||||
if AppGroup.isAvailable {
|
||||
@@ -21,10 +29,12 @@ struct OSGKeyboardApp: App {
|
||||
ThemedRoot {
|
||||
MainAppRoot()
|
||||
}
|
||||
.preferredColorScheme(appearance.colorScheme)
|
||||
} else {
|
||||
ThemedRoot {
|
||||
AppGroupErrorView()
|
||||
}
|
||||
.preferredColorScheme(appearance.colorScheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,11 @@ struct MainAppRoot: View {
|
||||
switch url.host {
|
||||
case "startflow":
|
||||
flowManager.startSession(coldStart: true)
|
||||
#if DEBUG
|
||||
case "seed-demo":
|
||||
DemoDataSeeder.seedRichPlaceholderData()
|
||||
config.reloadFromPersistedStorage()
|
||||
#endif
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
@@ -54,13 +54,19 @@ struct MainSplitView: View {
|
||||
|
||||
private var brandHeader: some View {
|
||||
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()
|
||||
.scaledToFit()
|
||||
.frame(height: 28)
|
||||
.foregroundStyle(palette.accent)
|
||||
.accessibilityLabel("OSGKeyboard")
|
||||
Spacer()
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 28, alignment: .leading)
|
||||
.padding(.leading, WideLayoutMetrics.sidebarContentInset)
|
||||
.padding(.trailing, WideLayoutMetrics.sidebarInset)
|
||||
.padding(.top, Spacing.lg)
|
||||
|
||||
@@ -156,6 +156,10 @@ struct SettingsView: View {
|
||||
|
||||
Divider().background(palette.divider)
|
||||
|
||||
AppearancePickerRow()
|
||||
|
||||
Divider().background(palette.divider)
|
||||
|
||||
LocalePickerRow(
|
||||
locales: effectiveLocales,
|
||||
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
|
||||
|
||||
private struct FlowInactivityPickerRow: View {
|
||||
|
||||
@@ -91,6 +91,10 @@
|
||||
"settings.appLanguage.auto" = "Auto";
|
||||
"settings.appLanguage.english" = "English";
|
||||
"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.message" = "API key, model, base URL, voice history, and cloud consent will be cleared.";
|
||||
"settings.reset.confirm" = "Reset all settings";
|
||||
|
||||
@@ -91,6 +91,10 @@
|
||||
"settings.appLanguage.auto" = "自动";
|
||||
"settings.appLanguage.english" = "英文";
|
||||
"settings.appLanguage.chinese" = "中文";
|
||||
"settings.appearance.title" = "外观";
|
||||
"settings.appearance.system" = "跟随系统";
|
||||
"settings.appearance.light" = "浅色";
|
||||
"settings.appearance.dark" = "深色";
|
||||
"settings.reset.title" = "重置所有设置?";
|
||||
"settings.reset.message" = "将清空 API Key、模型、接口地址、语音历史及云端确认状态。";
|
||||
"settings.reset.confirm" = "重置所有设置";
|
||||
|
||||
@@ -23,6 +23,17 @@
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<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>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
|
||||
@@ -52,6 +52,20 @@ struct OSGKeyboardMacApp: App {
|
||||
.onReceive(NotificationCenter.default.publisher(for: .speechHistoryDidSyncFromCloud)) { _ in
|
||||
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
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 208 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 176 KiB |
@@ -41,6 +41,7 @@
|
||||
--bg: #f7f7f5;
|
||||
--bg-elevated: #ffffff;
|
||||
--bg-soft: #efefec;
|
||||
--hero-wash: #e7f3eb;
|
||||
--text: #121214;
|
||||
--text-2: #5a5a62;
|
||||
--text-3: #8e8e96;
|
||||
@@ -60,6 +61,7 @@
|
||||
--bg: #0a0a0b;
|
||||
--bg-elevated: #131316;
|
||||
--bg-soft: #18181b;
|
||||
--hero-wash: #102018;
|
||||
--text: #f4f4f2;
|
||||
--text-2: #a8a8b0;
|
||||
--text-3: #6f6f78;
|
||||
@@ -77,6 +79,7 @@
|
||||
--bg: #0a0a0b;
|
||||
--bg-elevated: #131316;
|
||||
--bg-soft: #18181b;
|
||||
--hero-wash: #102018;
|
||||
--text: #f4f4f2;
|
||||
--text-2: #a8a8b0;
|
||||
--text-3: #6f6f78;
|
||||
@@ -219,24 +222,15 @@
|
||||
/* —— Hero —— */
|
||||
.hero {
|
||||
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;
|
||||
}
|
||||
.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-copy {
|
||||
max-width: 42rem;
|
||||
margin: 0 auto 3rem;
|
||||
margin: 0 auto 2.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
.eyebrow {
|
||||
@@ -286,176 +280,19 @@
|
||||
}
|
||||
.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 {
|
||||
position: relative;
|
||||
margin-top: 2.75rem;
|
||||
margin: 2.25rem auto 0;
|
||||
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);
|
||||
opacity: 0;
|
||||
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 {
|
||||
--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;
|
||||
.hero-stage img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
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; }
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@keyframes rise-in {
|
||||
@@ -588,12 +425,13 @@
|
||||
}
|
||||
.shot {
|
||||
margin: 0;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-elevated);
|
||||
box-shadow: var(--shadow);
|
||||
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 {
|
||||
width: 100%;
|
||||
@@ -605,6 +443,10 @@
|
||||
max-width: 300px;
|
||||
width: 100%;
|
||||
margin-inline: auto;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-elevated);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
/* —— Device strip —— */
|
||||
@@ -617,12 +459,12 @@
|
||||
}
|
||||
.device-card {
|
||||
margin: 0;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-elevated);
|
||||
box-shadow: var(--shadow);
|
||||
line-height: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
.device-card img {
|
||||
width: 100%;
|
||||
@@ -637,6 +479,10 @@
|
||||
flex: 0 0 auto;
|
||||
width: min(260px, 46vw);
|
||||
border-radius: 26px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-elevated);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.device-caption {
|
||||
margin-top: 0.65rem;
|
||||
@@ -661,9 +507,7 @@
|
||||
.compare {
|
||||
border-radius: calc(var(--radius) + 4px);
|
||||
border: 1px solid var(--line);
|
||||
background:
|
||||
linear-gradient(180deg, var(--accent-soft), transparent 42%),
|
||||
var(--bg-elevated);
|
||||
background: var(--hero-wash);
|
||||
padding: 2rem 1.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -793,7 +637,7 @@
|
||||
transition: none !important;
|
||||
}
|
||||
.reveal { opacity: 1; transform: none; }
|
||||
.device-cluster { opacity: 1; transform: none; }
|
||||
.hero-stage { opacity: 1; transform: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -857,34 +701,7 @@
|
||||
</div>
|
||||
|
||||
<div class="hero-stage" aria-label="OSGKeyboard on Mac, iPad, and iPhone">
|
||||
<div class="device-cluster">
|
||||
<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>
|
||||
<img src="assets/hero-devices.png" width="1024" height="458" alt="OSGKeyboard on iPad, Mac, and iPhone" loading="eager">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1357,7 +1174,7 @@
|
||||
}
|
||||
|
||||
// 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) {
|
||||
let ready = false;
|
||||
setTimeout(() => { ready = true; }, 1100);
|
||||
|
||||
@@ -469,6 +469,10 @@ targets:
|
||||
NSSpeechRecognitionUsageDescription: "OSGKeyboard uses on-device speech recognition for local dictation mode."
|
||||
NSHumanReadableCopyright: "OSGKeyboard"
|
||||
ITSAppUsesNonExemptEncryption: false
|
||||
CFBundleURLTypes:
|
||||
- CFBundleURLName: com.osgkeyboard.mac.seed
|
||||
CFBundleURLSchemes:
|
||||
- osgkeyboard
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_NAME: OSGKeyboard
|
||||
|
||||