@sigx/lynx-appearance 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,87 @@
1
+ import { callAsync, isModuleAvailable } from '@sigx/lynx-core';
2
+ const MODULE = 'Appearance';
3
+ const UNSUPPORTED = { ok: false, reason: 'unsupported' };
4
+ /**
5
+ * Set the status-bar *content* tint (clock + icons).
6
+ *
7
+ * - `'light'` = light content (white-ish icons) — use when behind a dark theme.
8
+ * - `'dark'` = dark content — use when behind a light theme.
9
+ *
10
+ * On iOS the host VC must forward `preferredStatusBarStyle` to
11
+ * `AppearanceModule.preferredStatusBarStyle` for this to take effect — the
12
+ * lynx-cli iOS template wires that automatically.
13
+ *
14
+ * Resolves `{ ok: false, reason: 'unsupported' }` (never rejects) when the
15
+ * native module isn't registered — typical in web preview, SSR, tests, or
16
+ * apps that don't link the module. Fire-and-forget callers can therefore
17
+ * `void setStatusBarStyle(...)` without risking unhandled rejections.
18
+ */
19
+ export function setStatusBarStyle(style) {
20
+ if (!isAvailable())
21
+ return Promise.resolve(UNSUPPORTED);
22
+ return callAsync(MODULE, 'setStatusBarStyle', { style });
23
+ }
24
+ /**
25
+ * Android only — set the status-bar background color. Pass `null` (or
26
+ * omit / pass `'transparent'`) to clear. iOS resolves `{ ok: false,
27
+ * reason: 'unsupported' }` since iOS has no separate status-bar background.
28
+ *
29
+ * On Android 15+ (API 35) edge-to-edge is enforced and this call is a no-op
30
+ * at the system level — callers should overlay their own background view
31
+ * inside the safe-area top padding.
32
+ *
33
+ * Resolves `{ ok: false, reason: 'unsupported' }` (never rejects) when the
34
+ * native module isn't registered. See `setStatusBarStyle` for the rationale.
35
+ */
36
+ export function setStatusBarBackgroundColor(color) {
37
+ if (!isAvailable())
38
+ return Promise.resolve(UNSUPPORTED);
39
+ return callAsync(MODULE, 'setStatusBarBackgroundColor', { color });
40
+ }
41
+ /**
42
+ * Android only — set the navigation-bar content tint + optional background.
43
+ * iOS resolves `{ ok: false, reason: 'unsupported' }` since there's no
44
+ * separate navigation bar.
45
+ *
46
+ * Resolves `{ ok: false, reason: 'unsupported' }` (never rejects) when the
47
+ * native module isn't registered. See `setStatusBarStyle` for the rationale.
48
+ */
49
+ export function setNavigationBarStyle(opts) {
50
+ if (!isAvailable())
51
+ return Promise.resolve(UNSUPPORTED);
52
+ return callAsync(MODULE, 'setNavigationBarStyle', opts);
53
+ }
54
+ /**
55
+ * Convenience: apply status-bar tint + (optionally) status-bar background +
56
+ * nav-bar tint in one call. Resolves to the aggregate result — `ok: false`
57
+ * if any leg returned a non-`unsupported` failure, with the first such
58
+ * failure's reason. `unsupported` legs (e.g. status-bar background on iOS)
59
+ * are intentionally ignored so a partially-supported platform can still
60
+ * report success for the legs that do apply.
61
+ *
62
+ * Fields are optional; omitting any leaves that surface untouched. Order is
63
+ * deterministic (statusBar → statusBarBackground → navigationBar) so the
64
+ * resolved reason on partial failure is unambiguous.
65
+ */
66
+ export async function setSystemBarsStyle(opts) {
67
+ if (!isAvailable())
68
+ return UNSUPPORTED;
69
+ let firstFailure;
70
+ const record = (r) => {
71
+ if (!r.ok && !firstFailure && r.reason !== 'unsupported')
72
+ firstFailure = r;
73
+ };
74
+ if (opts.statusBar)
75
+ record(await setStatusBarStyle(opts.statusBar));
76
+ if (opts.statusBarBackground !== undefined) {
77
+ record(await setStatusBarBackgroundColor(opts.statusBarBackground));
78
+ }
79
+ if (opts.navigationBar)
80
+ record(await setNavigationBarStyle(opts.navigationBar));
81
+ return firstFailure ?? { ok: true };
82
+ }
83
+ /** Quick check whether the native Appearance module is registered. */
84
+ export function isAvailable() {
85
+ return isModuleAvailable(MODULE);
86
+ }
87
+ //# sourceMappingURL=setters.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"setters.js","sourceRoot":"","sources":["../src/setters.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAG/D,MAAM,MAAM,GAAG,YAAY,CAAC;AAE5B,MAAM,WAAW,GAAiB,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,aAAa,EAAE,CAAC;AAEvE;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,iBAAiB,CAAC,KAAqB;IACrD,IAAI,CAAC,WAAW,EAAE;QAAE,OAAO,OAAO,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC;IACxD,OAAO,SAAS,CAAe,MAAM,EAAE,mBAAmB,EAAE,EAAE,KAAK,EAAE,CAAC,CAAC;AACzE,CAAC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,2BAA2B,CAAC,KAAoB;IAC9D,IAAI,CAAC,WAAW,EAAE;QAAE,OAAO,OAAO,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC;IACxD,OAAO,SAAS,CAAe,MAAM,EAAE,6BAA6B,EAAE,EAAE,KAAK,EAAE,CAAC,CAAC;AACnF,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,qBAAqB,CAAC,IAA+C;IACnF,IAAI,CAAC,WAAW,EAAE;QAAE,OAAO,OAAO,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC;IACxD,OAAO,SAAS,CAAe,MAAM,EAAE,uBAAuB,EAAE,IAAI,CAAC,CAAC;AACxE,CAAC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,KAAK,UAAU,kBAAkB,CAAC,IAA0B;IACjE,IAAI,CAAC,WAAW,EAAE;QAAE,OAAO,WAAW,CAAC;IACvC,IAAI,YAAsC,CAAC;IAC3C,MAAM,MAAM,GAAG,CAAC,CAAe,EAAQ,EAAE;QACvC,IAAI,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,YAAY,IAAI,CAAC,CAAC,MAAM,KAAK,aAAa;YAAE,YAAY,GAAG,CAAC,CAAC;IAC7E,CAAC,CAAC;IACF,IAAI,IAAI,CAAC,SAAS;QAAE,MAAM,CAAC,MAAM,iBAAiB,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC;IACpE,IAAI,IAAI,CAAC,mBAAmB,KAAK,SAAS,EAAE,CAAC;QAC3C,MAAM,CAAC,MAAM,2BAA2B,CAAC,IAAI,CAAC,mBAAmB,CAAC,CAAC,CAAC;IACtE,CAAC;IACD,IAAI,IAAI,CAAC,aAAa;QAAE,MAAM,CAAC,MAAM,qBAAqB,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC,CAAC;IAChF,OAAO,YAAY,IAAI,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC;AACtC,CAAC;AAED,sEAAsE;AACtE,MAAM,UAAU,WAAW;IACzB,OAAO,iBAAiB,CAAC,MAAM,CAAC,CAAC;AACnC,CAAC"}
@@ -0,0 +1,26 @@
1
+ /** The two color schemes the platform reports. iOS `unspecified` and
2
+ * Android `UI_MODE_NIGHT_UNDEFINED` both collapse to `'light'` at the
3
+ * native publisher boundary so JS only ever sees these two values. */
4
+ export type ColorScheme = 'light' | 'dark';
5
+ /** Tint of system-bar *content* (clock, icons), not its background. */
6
+ export type SystemBarStyle = 'light' | 'dark';
7
+ /** Result envelope returned by every native setter. */
8
+ export interface SetterResult {
9
+ ok: boolean;
10
+ /** Present when `ok === false` — `'unsupported'` on iOS for nav-bar and
11
+ * status-bar-background calls, or an Android failure message. */
12
+ reason?: string;
13
+ }
14
+ /** Argument to `setSystemBarsStyle` — partial; omitted fields are left alone. */
15
+ export interface SystemBarsStyleInput {
16
+ /** Status-bar content tint. `'light'` = light icons (legible on dark bg). */
17
+ statusBar?: SystemBarStyle;
18
+ /** Android only — status-bar background color. `null` clears (transparent). */
19
+ statusBarBackground?: string | null;
20
+ /** Android only — navigation-bar tint + optional background. */
21
+ navigationBar?: {
22
+ style: SystemBarStyle;
23
+ color?: string;
24
+ };
25
+ }
26
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;uEAEuE;AACvE,MAAM,MAAM,WAAW,GAAG,OAAO,GAAG,MAAM,CAAC;AAE3C,uEAAuE;AACvE,MAAM,MAAM,cAAc,GAAG,OAAO,GAAG,MAAM,CAAC;AAE9C,uDAAuD;AACvD,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,OAAO,CAAC;IACZ;sEACkE;IAClE,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,iFAAiF;AACjF,MAAM,WAAW,oBAAoB;IACnC,6EAA6E;IAC7E,SAAS,CAAC,EAAE,cAAc,CAAC;IAC3B,+EAA+E;IAC/E,mBAAmB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACpC,gEAAgE;IAChE,aAAa,CAAC,EAAE;QAAE,KAAK,EAAE,cAAc,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CAC3D"}
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
@@ -0,0 +1,130 @@
1
+ import UIKit
2
+ import Lynx
3
+
4
+ /// System-bar appearance setters + a sync getter for the current color scheme.
5
+ /// JS usage:
6
+ /// NativeModules.Appearance.setStatusBarStyle({ "style": "light" }, callback)
7
+ /// NativeModules.Appearance.getColorScheme(callback)
8
+ ///
9
+ /// iOS exposes two routes to control status-bar style; we try both so apps
10
+ /// don't have to wire boilerplate:
11
+ ///
12
+ /// 1. **VC-controlled (modern, default)** — iOS reads
13
+ /// `preferredStatusBarStyle` from the root view controller. The host app
14
+ /// overrides that getter to return `AppearanceModule.preferredStatusBarStyle`
15
+ /// (the static we update). We call `setNeedsStatusBarAppearanceUpdate()` to
16
+ /// force a re-evaluation.
17
+ ///
18
+ /// 2. **UIApplication-controlled (legacy)** — if the host's Info.plist has
19
+ /// `UIViewControllerBasedStatusBarAppearance = NO`, iOS reads style from
20
+ /// `UIApplication.shared` directly. We invoke the deprecated setter via
21
+ /// `perform(_:with:with:)` so SwiftUI hosts (which can't easily override
22
+ /// their hidden UIHostingController) get a working path with one plist
23
+ /// change. Apps that leave the plist default (YES) get the silent no-op
24
+ /// for this leg — Path 1 covers them.
25
+ ///
26
+ /// `setStatusBarBackgroundColor` and `setNavigationBarStyle` are Android-only
27
+ /// in practice. iOS resolves the system-bar background from the underlying
28
+ /// view's color and has no separate navigation bar. We return
29
+ /// `{ ok: false, reason: "unsupported" }` so JS callers can decide whether to
30
+ /// treat that as an error.
31
+ class AppearanceModule: NSObject, LynxModule {
32
+
33
+ /// Last requested status-bar style; the host VC's
34
+ /// `preferredStatusBarStyle` should return this.
35
+ @objc public static var preferredStatusBarStyle: UIStatusBarStyle = .default
36
+
37
+ @objc static var name: String { "Appearance" }
38
+
39
+ @objc static var methodLookup: [String: String] {
40
+ [
41
+ "setStatusBarStyle": NSStringFromSelector(#selector(setStatusBarStyle(_:callback:))),
42
+ "setStatusBarBackgroundColor": NSStringFromSelector(#selector(setStatusBarBackgroundColor(_:callback:))),
43
+ "setNavigationBarStyle": NSStringFromSelector(#selector(setNavigationBarStyle(_:callback:))),
44
+ "getColorScheme": NSStringFromSelector(#selector(getColorScheme(_:))),
45
+ ]
46
+ }
47
+
48
+ required override init() { super.init() }
49
+ required init(param: Any) { super.init() }
50
+
51
+ @objc func setStatusBarStyle(_ params: [String: Any]?, callback: LynxCallbackBlock?) {
52
+ let style = (params?["style"] as? String) ?? "default"
53
+ // 'light' style means "light content" — i.e. white icons on a dark
54
+ // background. 'dark' means dark icons on a light background.
55
+ // iOS uses lightContent / darkContent to express the *content* color.
56
+ let resolved: UIStatusBarStyle
57
+ switch style {
58
+ case "light":
59
+ resolved = .lightContent
60
+ case "dark":
61
+ resolved = .darkContent
62
+ default:
63
+ resolved = .default
64
+ }
65
+
66
+ DispatchQueue.main.async {
67
+ AppearanceModule.preferredStatusBarStyle = resolved
68
+
69
+ // Path 1 — VC-controlled (the modern, recommended path):
70
+ // re-evaluate `preferredStatusBarStyle` on every key window's
71
+ // root view controller. The host app's VC must override
72
+ // `preferredStatusBarStyle` to return
73
+ // `AppearanceModule.preferredStatusBarStyle` for this to take
74
+ // effect.
75
+ for scene in UIApplication.shared.connectedScenes {
76
+ guard let windowScene = scene as? UIWindowScene else { continue }
77
+ for window in windowScene.windows {
78
+ window.rootViewController?.setNeedsStatusBarAppearanceUpdate()
79
+ }
80
+ }
81
+
82
+ // Path 2 — UIApplication-controlled (legacy, deprecated but
83
+ // still functional when `UIViewControllerBasedStatusBarAppearance`
84
+ // is `false` in Info.plist). For SwiftUI hosts where overriding
85
+ // a UIHostingController's preferredStatusBarStyle is awkward,
86
+ // setting that plist key to NO and letting this path do the work
87
+ // is the path of least resistance. No-op on apps that leave the
88
+ // plist key default (true) — iOS silently ignores the call.
89
+ //
90
+ // Invoked via `perform(_:with:with:)` so the deprecation warning
91
+ // doesn't fire at this single intentional call site.
92
+ let plist = Bundle.main.infoDictionary
93
+ let vcBased = (plist?["UIViewControllerBasedStatusBarAppearance"] as? Bool) ?? true
94
+ if !vcBased {
95
+ let app = UIApplication.shared
96
+ let sel = NSSelectorFromString("setStatusBarStyle:animated:")
97
+ if app.responds(to: sel) {
98
+ app.perform(sel, with: resolved.rawValue, with: true)
99
+ }
100
+ }
101
+
102
+ callback?(["ok": true])
103
+ }
104
+ }
105
+
106
+ @objc func setStatusBarBackgroundColor(_ params: [String: Any]?, callback: LynxCallbackBlock?) {
107
+ // No-op on iOS — the status bar background is the underlying view's
108
+ // color, not a system property we can set.
109
+ _ = params
110
+ callback?(["ok": false, "reason": "unsupported"])
111
+ }
112
+
113
+ @objc func setNavigationBarStyle(_ params: [String: Any]?, callback: LynxCallbackBlock?) {
114
+ // No-op on iOS — there's no separate navigation bar to style.
115
+ _ = params
116
+ callback?(["ok": false, "reason": "unsupported"])
117
+ }
118
+
119
+ @objc func getColorScheme(_ callback: LynxCallbackBlock?) {
120
+ // Read from the active key window so we pick up the user's preference
121
+ // even if no LynxView has fired its publisher yet.
122
+ let style = UIApplication.shared.connectedScenes
123
+ .compactMap { $0 as? UIWindowScene }
124
+ .first(where: { $0.activationState == .foregroundActive })?
125
+ .windows.first?.traitCollection.userInterfaceStyle
126
+ ?? UITraitCollection.current.userInterfaceStyle
127
+ let scheme = style == .dark ? "dark" : "light"
128
+ callback?(["colorScheme": scheme])
129
+ }
130
+ }
@@ -0,0 +1,86 @@
1
+ import UIKit
2
+ import Lynx
3
+
4
+ /// Publishes the host's current system color scheme (light / dark) to JS via:
5
+ ///
6
+ /// 1. **`lynx.__globalProps.appearance`** — populated synchronously via
7
+ /// `LynxView.updateGlobalProps(withDictionary:)` before MT first paint, so
8
+ /// apps can render the correct theme on cold start without a flash.
9
+ ///
10
+ /// 2. **`appearanceChanged` global event** — fired via
11
+ /// `LynxView.sendGlobalEvent(_:withParams:)` whenever the trait collection
12
+ /// flips light/dark. JS-side `<AppearanceProvider>` subscribes via
13
+ /// `lynx.getJSModule("GlobalEventEmitter").addListener` and updates its
14
+ /// reactive signal so consumers (e.g. `<ThemeProvider>` with `followSystem`)
15
+ /// swap themes live.
16
+ ///
17
+ /// One publisher per `LynxView`. The autolinker wires
18
+ /// `GeneratedLifecyclePublishers.attachAll(to: lynxView)` to instantiate this
19
+ /// after each LynxView is built; the host coordinator retains the instance for
20
+ /// the LynxView's lifetime.
21
+ final class AppearancePublisher {
22
+ private weak var lynxView: LynxView?
23
+ private var lastScheme: String = ""
24
+
25
+ init(lynxView: LynxView) {
26
+ self.lynxView = lynxView
27
+
28
+ // Observe trait-collection changes via the window — handles the
29
+ // Settings → Display & Brightness → Appearance flip in real time.
30
+ // UIScreen.traitCollectionDidChange isn't reliable here (only fires
31
+ // when the screen *itself* changes), so we listen for the
32
+ // app-foreground notification (catches changes made while the app was
33
+ // backgrounded) and rely on the LynxContainerView's trait observer to
34
+ // call back into us via `republish(scheme:)` on every real-time flip.
35
+ NotificationCenter.default.addObserver(
36
+ self,
37
+ selector: #selector(handleAppearanceMayHaveChanged),
38
+ name: UIApplication.didBecomeActiveNotification,
39
+ object: nil
40
+ )
41
+
42
+ // Seed on the next runloop tick — the LynxView's window may not be
43
+ // attached yet at construction time, so reading `traitCollection`
44
+ // immediately can yield the unspecified style.
45
+ DispatchQueue.main.async { [weak self] in
46
+ self?.publish()
47
+ }
48
+ }
49
+
50
+ deinit {
51
+ NotificationCenter.default.removeObserver(self)
52
+ }
53
+
54
+ @objc private func handleAppearanceMayHaveChanged() {
55
+ publish()
56
+ }
57
+
58
+ /// Force a republish. Called from the seed tick, the app-foreground
59
+ /// observer, and (optionally) from a host-side trait-collection observer
60
+ /// that calls into `republish(scheme:)` when the real-time flip happens.
61
+ @discardableResult
62
+ @objc func publish() -> Bool {
63
+ guard let view = lynxView else { return false }
64
+ let style = view.traitCollection.userInterfaceStyle
65
+ let scheme = style == .dark ? "dark" : "light"
66
+ return republish(scheme: scheme)
67
+ }
68
+
69
+ @discardableResult
70
+ func republish(scheme: String) -> Bool {
71
+ guard let view = lynxView else { return false }
72
+ if scheme == lastScheme { return false }
73
+ lastScheme = scheme
74
+
75
+ let map: [String: Any] = ["colorScheme": scheme]
76
+
77
+ // Channel 1: __globalProps — sync MT read at first paint.
78
+ view.updateGlobalProps(with: ["appearance": map])
79
+
80
+ // Channel 2: appearanceChanged event — live update for the BG-side
81
+ // <AppearanceProvider> listener. First param is the map itself;
82
+ // GlobalEventEmitter delivers it as the listener's first argument.
83
+ view.sendGlobalEvent("appearanceChanged", withParams: [map])
84
+ return true
85
+ }
86
+ }
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@sigx/lynx-appearance",
3
+ "version": "0.4.1",
4
+ "description": "System appearance (color scheme observation + status/navigation bar tint setters) for sigx-lynx",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ },
13
+ "./signalx-module.json": "./signalx-module.json"
14
+ },
15
+ "files": [
16
+ "src",
17
+ "dist",
18
+ "ios",
19
+ "android",
20
+ "signalx-module.json"
21
+ ],
22
+ "keywords": [
23
+ "sigx",
24
+ "lynx",
25
+ "appearance",
26
+ "color-scheme",
27
+ "dark-mode",
28
+ "status-bar"
29
+ ],
30
+ "author": "Andreas Ekdahl",
31
+ "license": "MIT",
32
+ "dependencies": {
33
+ "@sigx/reactivity": "^0.4.8",
34
+ "@sigx/lynx": "^0.4.1",
35
+ "@sigx/lynx-core": "^0.4.1"
36
+ },
37
+ "devDependencies": {
38
+ "@typescript/native-preview": "7.0.0-dev.20260521.1",
39
+ "typescript": "^6.0.3",
40
+ "@sigx/lynx-plugin": "^0.4.1",
41
+ "@sigx/lynx-testing": "^0.4.1",
42
+ "@sigx/lynx-runtime-main": "^0.4.1"
43
+ },
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "git+https://github.com/signalxjs/lynx.git",
47
+ "directory": "packages/lynx-appearance"
48
+ },
49
+ "homepage": "https://github.com/signalxjs/lynx/tree/main/packages/lynx-appearance",
50
+ "bugs": {
51
+ "url": "https://github.com/signalxjs/lynx/issues"
52
+ },
53
+ "publishConfig": {
54
+ "access": "public"
55
+ },
56
+ "scripts": {
57
+ "build": "node ../../scripts/clean.mjs dist && tsgo",
58
+ "dev": "tsgo --watch",
59
+ "clean": "node ../../scripts/clean.mjs dist .turbo"
60
+ }
61
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "Appearance",
3
+ "package": "@sigx/lynx-appearance",
4
+ "description": "System color-scheme observer + status-bar / navigation-bar tint setters",
5
+ "platforms": ["android", "ios"],
6
+ "ios": {
7
+ "moduleClass": "AppearanceModule",
8
+ "publisherClass": "AppearancePublisher",
9
+ "sourceDir": "ios",
10
+ "methods": [
11
+ "setStatusBarStyle",
12
+ "setStatusBarBackgroundColor",
13
+ "setNavigationBarStyle",
14
+ "getColorScheme"
15
+ ]
16
+ },
17
+ "android": {
18
+ "moduleClass": "com.sigx.appearance.AppearanceModule",
19
+ "publisherClass": "com.sigx.appearance.AppearancePublisher",
20
+ "sourceDir": "android"
21
+ }
22
+ }
package/src/globals.ts ADDED
@@ -0,0 +1,40 @@
1
+ import type { ColorScheme } from './types.js';
2
+
3
+ /**
4
+ * Key under `lynx.__globalProps` where the native publisher writes the
5
+ * appearance map. Single string shared by iOS / Android publishers, the JS
6
+ * reader, and tests.
7
+ */
8
+ export const GLOBAL_PROPS_KEY = 'appearance';
9
+
10
+ export interface RawAppearanceProps {
11
+ colorScheme?: ColorScheme;
12
+ }
13
+
14
+ interface LynxGlobalLike {
15
+ __globalProps?: { [k: string]: unknown };
16
+ }
17
+
18
+ // Closure-injected identifier from
19
+ // `@lynx-js/runtime-wrapper-webpack-plugin`'s `__init_card_bundle__` wrapper;
20
+ // declared locally so the package doesn't need to depend on lynx-runtime-internal
21
+ // just for the ambient (same pattern as lynx-safe-area).
22
+ declare const lynx: unknown | undefined;
23
+
24
+ /**
25
+ * Synchronously read the current system color scheme from
26
+ * `lynx.__globalProps`. Returns `null` when the publisher hasn't populated
27
+ * yet (early cold start) or when running outside a Lynx host (web preview,
28
+ * SSR, tests). Callers should treat `null` as "unknown — fall back to your
29
+ * default theme".
30
+ *
31
+ * Safe on both BG and MT threads — `__globalProps` is mirrored across both.
32
+ */
33
+ export function readGlobalColorScheme(): ColorScheme | null {
34
+ const lynxObj: LynxGlobalLike | undefined = typeof lynx !== 'undefined'
35
+ ? (lynx as unknown as LynxGlobalLike)
36
+ : undefined;
37
+ const raw = lynxObj?.__globalProps?.[GLOBAL_PROPS_KEY] as RawAppearanceProps | undefined;
38
+ if (!raw || typeof raw !== 'object') return null;
39
+ return raw.colorScheme === 'dark' ? 'dark' : raw.colorScheme === 'light' ? 'light' : null;
40
+ }
package/src/hooks.ts ADDED
@@ -0,0 +1,46 @@
1
+ import { signal, type Computed, type PrimitiveSignal } from '@sigx/reactivity';
2
+ import { useAppearanceContext } from './injectable.js';
3
+ import { readGlobalColorScheme } from './globals.js';
4
+ import type { ColorScheme } from './types.js';
5
+
6
+ type ColorSchemeRead = PrimitiveSignal<ColorScheme> | Computed<ColorScheme>;
7
+
8
+ /**
9
+ * BG-side reactive read of the current system color scheme. Returns the
10
+ * live signal — components calling this re-render when the user flips dark
11
+ * mode in system settings.
12
+ *
13
+ * If no `<AppearanceProvider>` is in scope, returns a fallback signal seeded
14
+ * from `lynx.__globalProps` once at first call (still gives the correct
15
+ * value on cold start; just doesn't update live). This lets ThemeProvider
16
+ * use the hook even when its consumer hasn't mounted `<AppearanceProvider>`.
17
+ */
18
+ export function useSystemColorScheme(): ColorSchemeRead {
19
+ const ctx = useAppearanceContext();
20
+ if (ctx) return ctx.colorScheme;
21
+ return fallbackSignal();
22
+ }
23
+
24
+ /**
25
+ * MT-thread synchronous read of the current system color scheme. For use
26
+ * inside `'main thread'`-marked worklet bodies. Reads `lynx.__globalProps`
27
+ * directly — no subscription, callers re-evaluate per worklet invocation.
28
+ *
29
+ * Returns `'light'` when the publisher hasn't populated yet (cold start before
30
+ * first publish, or non-Lynx hosts).
31
+ */
32
+ export function useSystemColorSchemeMT(): ColorScheme {
33
+ return readGlobalColorScheme() ?? 'light';
34
+ }
35
+
36
+ let _fallback: PrimitiveSignal<ColorScheme> | undefined;
37
+ function fallbackSignal(): PrimitiveSignal<ColorScheme> {
38
+ if (!_fallback) {
39
+ _fallback = signal<ColorScheme>(readGlobalColorScheme() ?? 'light');
40
+ }
41
+ return _fallback;
42
+ }
43
+
44
+ // Re-export a typed PrimitiveSignal/Computed pair so consumers can write
45
+ // `useSystemColorScheme().value` without importing from @sigx/reactivity.
46
+ export type { PrimitiveSignal, Computed } from '@sigx/reactivity';
package/src/index.ts ADDED
@@ -0,0 +1,33 @@
1
+ // Public API for @sigx/lynx-appearance.
2
+
3
+ export { AppearanceProvider, APPEARANCE_EVENT } from './provider.js';
4
+ export type { AppearanceProviderProps } from './provider.js';
5
+
6
+ export { useAppearanceContext } from './injectable.js';
7
+ export type { AppearanceContextValue } from './injectable.js';
8
+
9
+ export {
10
+ useSystemColorScheme,
11
+ useSystemColorSchemeMT,
12
+ } from './hooks.js';
13
+
14
+ export {
15
+ setStatusBarStyle,
16
+ setStatusBarBackgroundColor,
17
+ setNavigationBarStyle,
18
+ setSystemBarsStyle,
19
+ isAvailable,
20
+ } from './setters.js';
21
+
22
+ export {
23
+ readGlobalColorScheme,
24
+ GLOBAL_PROPS_KEY,
25
+ } from './globals.js';
26
+ export type { RawAppearanceProps } from './globals.js';
27
+
28
+ export type {
29
+ ColorScheme,
30
+ SystemBarStyle,
31
+ SystemBarsStyleInput,
32
+ SetterResult,
33
+ } from './types.js';
@@ -0,0 +1,20 @@
1
+ import { defineInjectable } from '@sigx/lynx';
2
+ import type { PrimitiveSignal } from '@sigx/reactivity';
3
+ import type { ColorScheme } from './types.js';
4
+
5
+ /** DI shape exposed by `<AppearanceProvider>`. */
6
+ export interface AppearanceContextValue {
7
+ /** BG-side reactive color scheme. Re-renders consumers on system flip. */
8
+ readonly colorScheme: PrimitiveSignal<ColorScheme>;
9
+ }
10
+
11
+ /**
12
+ * The DI handle for the appearance context.
13
+ *
14
+ * Factory returns `null` so consumers outside a provider get a clear signal
15
+ * (vs. a phantom `'light'` signal that silently never updates). Hooks in
16
+ * `./hooks.ts` wrap this with the null-check + fallback signal.
17
+ */
18
+ export const useAppearanceContext = defineInjectable<AppearanceContextValue | null>(
19
+ () => null,
20
+ );
@@ -0,0 +1,89 @@
1
+ import {
2
+ component,
3
+ defineProvide,
4
+ signal,
5
+ onMounted,
6
+ onUnmounted,
7
+ type Define,
8
+ } from '@sigx/lynx';
9
+ import { useAppearanceContext, type AppearanceContextValue } from './injectable.js';
10
+ import { readGlobalColorScheme } from './globals.js';
11
+ import type { ColorScheme } from './types.js';
12
+
13
+ /**
14
+ * Event name fired by the native publisher (iOS `AppearancePublisher.swift`,
15
+ * Android `AppearancePublisher.kt`) via `GlobalEventEmitter` every time the
16
+ * host's system color scheme flips. Payload mirrors the same map stored under
17
+ * `lynx.__globalProps.appearance`.
18
+ *
19
+ * Kept as a constant so iOS/Android publishers and the JS listener agree on
20
+ * a single string.
21
+ */
22
+ export const APPEARANCE_EVENT = 'appearanceChanged';
23
+
24
+ interface GlobalEventEmitterLike {
25
+ addListener: (name: string, fn: (...a: unknown[]) => void) => void;
26
+ removeListener: (name: string, fn: (...a: unknown[]) => void) => void;
27
+ }
28
+
29
+ interface LynxLike {
30
+ getJSModule?: (name: string) => GlobalEventEmitterLike | undefined;
31
+ }
32
+
33
+ declare const lynx: unknown | undefined;
34
+
35
+ export type AppearanceProviderProps =
36
+ & Define.Prop<'class', string, false>
37
+ & Define.Prop<'style', Record<string, string | number>, false>
38
+ & Define.Slot<'default'>;
39
+
40
+ /**
41
+ * Mount near the root of an app (above any consumer of `useSystemColorScheme`).
42
+ * Cheap — just one BG signal + one GlobalEventEmitter subscription. The
43
+ * native publisher writes `lynx.__globalProps.appearance` before MT first
44
+ * paint, so the initial value is correct on cold start with no flash.
45
+ *
46
+ * On platforms where the publisher isn't wired (web preview, tests),
47
+ * `readGlobalColorScheme()` returns `null` and we seed `'light'` as a safe
48
+ * default. Consumers can detect the unwired case via the live-update
49
+ * subscription never firing.
50
+ */
51
+ export const AppearanceProvider = component<AppearanceProviderProps>(({ props, slots }) => {
52
+ const initial: ColorScheme = readGlobalColorScheme() ?? 'light';
53
+ const colorScheme = signal<ColorScheme>(initial);
54
+
55
+ const ctx: AppearanceContextValue = { colorScheme };
56
+ defineProvide(useAppearanceContext, () => ctx);
57
+
58
+ let listener: ((...a: unknown[]) => void) | undefined;
59
+ let emitter: GlobalEventEmitterLike | undefined;
60
+
61
+ onMounted(() => {
62
+ const lynxObj: LynxLike | undefined = typeof lynx !== 'undefined'
63
+ ? (lynx as unknown as LynxLike)
64
+ : undefined;
65
+ emitter = lynxObj?.getJSModule?.('GlobalEventEmitter');
66
+ if (!emitter) return;
67
+ listener = (raw: unknown) => {
68
+ const next = normaliseScheme(raw);
69
+ if (next && next !== colorScheme.value) colorScheme.value = next;
70
+ };
71
+ emitter.addListener(APPEARANCE_EVENT, listener);
72
+ });
73
+
74
+ onUnmounted(() => {
75
+ if (emitter && listener) emitter.removeListener(APPEARANCE_EVENT, listener);
76
+ });
77
+
78
+ return () => (
79
+ <view class={props.class} style={props.style}>
80
+ {slots.default?.()}
81
+ </view>
82
+ );
83
+ });
84
+
85
+ function normaliseScheme(raw: unknown): ColorScheme | null {
86
+ if (!raw || typeof raw !== 'object') return null;
87
+ const v = (raw as Record<string, unknown>)['colorScheme'];
88
+ return v === 'dark' ? 'dark' : v === 'light' ? 'light' : null;
89
+ }