From dfe0add1f6f0795406a3e702fc73d16b433a276d Mon Sep 17 00:00:00 2001 From: James Yong Date: Fri, 2 Oct 2026 18:39:34 -0700 Subject: [PATCH 1/4] feat(terminal): report the color scheme to programs that ask Programs with an automatic theme (Claude Code's Auto, Neovim) can detect light or dark through the color-scheme query and mode 2031, but the shim never installed the color-scheme callback: CSI ? 996 n went unanswered, and DECRQM reported mode 2031 as supported while no change report was ever sent. A theme switch left those programs on the old scheme. The shim now derives the scheme from the default background (light when dark text out-contrasts light text), answers CSI ? 996 n with it, and queues CSI ? 997 ; 1|2 n on the PTY response path when a theme change flips the scheme and the program enabled mode 2031. New sessions also get COLORFGBG unless the caller set one, for programs that read it at startup. Verified against a live daemon: Claude Code's Auto preview now follows a mid-session theme reload; with the previous binary it stayed dark. Co-Authored-By: Claude Opus 5.5 --- .../crates/ghosttea-core/src/model.rs | 31 +++++++++ .../crates/ghosttea-vt-sys/src/ghostty_shim.c | 47 ++++++++++++- .../src/ghostty_shim_internal.h | 3 + native/ghosttea/crates/ghosttea-vt/src/lib.rs | 36 ++++++++++ native/ghosttea/src/service.rs | 2 + native/ghosttea/src/session.rs | 68 +++++++++++++++++++ 6 files changed, 186 insertions(+), 1 deletion(-) diff --git a/native/ghosttea/crates/ghosttea-core/src/model.rs b/native/ghosttea/crates/ghosttea-core/src/model.rs index 342fdc7d..a8228f2a 100644 --- a/native/ghosttea/crates/ghosttea-core/src/model.rs +++ b/native/ghosttea/crates/ghosttea-core/src/model.rs @@ -732,6 +732,37 @@ mod tests { } } + #[test] + fn theme_change_reports_the_new_color_scheme_to_the_program() { + let runtime = Arc::new(TerminalRuntime::discover().unwrap()); + let mut model = TerminalModel::new( + runtime, + TerminalModelOptions { + session_handle: 4, + session_epoch: 1, + layout_epoch: 1, + cols: 20, + rows: 4, + scrollback_bytes: 4096, + }, + ) + .unwrap(); + model.feed(b"\x1b[?2031h", RenderRequest::None).unwrap(); + let update = model + .set_appearance( + [0x4c, 0x4f, 0x69], + [0xef, 0xf1, 0xf5], + [0xdc, 0x8a, 0x78], + &[], + RenderRequest::Full, + ) + .unwrap(); + assert!(update.as_slice().iter().any(|effect| matches!( + effect, + TerminalEffect::WriteToTransport(bytes) if bytes == b"\x1b[?997;2n" + ))); + } + #[test] fn terminal_reply_precedes_semantic_and_render_effects() { let runtime = Arc::new(TerminalRuntime::discover().unwrap()); diff --git a/native/ghosttea/crates/ghosttea-vt-sys/src/ghostty_shim.c b/native/ghosttea/crates/ghosttea-vt-sys/src/ghostty_shim.c index fde9b683..2b8ecc4a 100644 --- a/native/ghosttea/crates/ghosttea-vt-sys/src/ghostty_shim.c +++ b/native/ghosttea/crates/ghosttea-vt-sys/src/ghostty_shim.c @@ -1,4 +1,5 @@ #include "ghostty_shim_internal.h" +#include #include #include @@ -9,6 +10,30 @@ struct EgTrackedSelection { bool rectangle; }; +static double eg_srgb_to_linear(uint8_t value) { + double v = value / 255.0; + return v <= 0.04045 ? v / 12.92 : pow((v + 0.055) / 1.055, 2.4); +} + +// A background is light when dark text out-contrasts light text on it, which +// is WCAG relative luminance above ~0.179. +static GhosttyColorScheme eg_scheme_for_background(GhosttyColorRgb background) { + double luminance = 0.2126 * eg_srgb_to_linear(background.r) + + 0.7152 * eg_srgb_to_linear(background.g) + + 0.0722 * eg_srgb_to_linear(background.b); + return (luminance + 0.05) / 0.05 > 1.05 / (luminance + 0.05) + ? GHOSTTY_COLOR_SCHEME_LIGHT + : GHOSTTY_COLOR_SCHEME_DARK; +} + +static bool eg_color_scheme(GhosttyTerminal terminal, + void* userdata, + GhosttyColorScheme* out_scheme) { + (void)terminal; + *out_scheme = ((EgTerminal*)userdata)->color_scheme; + return true; +} + int eg_terminal_set_colors(EgTerminal* state, uint8_t fg_r, uint8_t fg_g, uint8_t fg_b, uint8_t bg_r, uint8_t bg_g, uint8_t bg_b, @@ -23,8 +48,27 @@ int eg_terminal_set_colors(EgTerminal* state, result = ghostty_terminal_set( state->terminal, GHOSTTY_TERMINAL_OPT_COLOR_BACKGROUND, &background); if (result != GHOSTTY_SUCCESS) return result; - return ghostty_terminal_set( + result = ghostty_terminal_set( state->terminal, GHOSTTY_TERMINAL_OPT_COLOR_CURSOR, &cursor); + if (result != GHOSTTY_SUCCESS) return result; + + // Programs that enabled mode 2031 (Claude Code's Auto theme, Neovim) learn + // about a light/dark flip without re-querying. The report rides the normal + // PTY response buffer, so it reaches the program with the next snapshot. + GhosttyColorScheme scheme = eg_scheme_for_background(background); + if (scheme == state->color_scheme) return GHOSTTY_SUCCESS; + state->color_scheme = scheme; + bool reporting = false; + if (ghostty_terminal_mode_get( + state->terminal, GHOSTTY_MODE_COLOR_SCHEME_REPORT, &reporting) != GHOSTTY_SUCCESS || + !reporting) + return GHOSTTY_SUCCESS; + char report[32]; + size_t written = 0; + if (ghostty_color_scheme_report_encode(scheme, report, sizeof report, &written) == + GHOSTTY_SUCCESS) + (void)eg_buffer_append(&state->response, (const uint8_t*)report, written); + return GHOSTTY_SUCCESS; } int eg_terminal_set_palette(EgTerminal* state, @@ -158,6 +202,7 @@ EgTerminal* eg_terminal_new(uint16_t cols, uint16_t rows, size_t max_scrollback) ghostty_terminal_set(state->terminal, GHOSTTY_TERMINAL_OPT_TITLE_CHANGED, (const void*)eg_title_changed); ghostty_terminal_set(state->terminal, GHOSTTY_TERMINAL_OPT_PWD_CHANGED, (const void*)eg_pwd_changed); ghostty_terminal_set(state->terminal, GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE, (const void*)eg_clipboard_write); + ghostty_terminal_set(state->terminal, GHOSTTY_TERMINAL_OPT_COLOR_SCHEME, (const void*)eg_color_scheme); if (eg_terminal_set_colors( state, 255, 255, 255, diff --git a/native/ghosttea/crates/ghosttea-vt-sys/src/ghostty_shim_internal.h b/native/ghosttea/crates/ghosttea-vt-sys/src/ghostty_shim_internal.h index 037feaff..56248929 100644 --- a/native/ghosttea/crates/ghosttea-vt-sys/src/ghostty_shim_internal.h +++ b/native/ghosttea/crates/ghosttea-vt-sys/src/ghostty_shim_internal.h @@ -28,6 +28,9 @@ struct EgTerminal { EgBuffer grapheme; bool mouse_pressed; uint32_t effects; + // Derived from the embedder's default background; answers CSI ? 996 n and + // drives mode-2031 reports when a theme change flips it. + GhosttyColorScheme color_scheme; }; bool eg_buffer_reserve(EgBuffer* buffer, size_t required); diff --git a/native/ghosttea/crates/ghosttea-vt/src/lib.rs b/native/ghosttea/crates/ghosttea-vt/src/lib.rs index 56e6720b..04b17e39 100644 --- a/native/ghosttea/crates/ghosttea-vt/src/lib.rs +++ b/native/ghosttea/crates/ghosttea-vt/src/lib.rs @@ -2163,6 +2163,42 @@ mod tests { assert_eq!(snapshot.cells[0][3].foreground_palette, Some(4)); } + #[test] + fn answers_color_scheme_queries_from_the_default_background() { + let mut terminal = GhosttyTerminalCore::new(20, 2, 100).unwrap(); + terminal.feed(b"\x1b[?996n"); + assert_eq!(terminal.take_pty_response(), b"\x1b[?997;1n"); + + terminal + .set_colors([0x4c, 0x4f, 0x69], [0xef, 0xf1, 0xf5], [0xdc, 0x8a, 0x78]) + .unwrap(); + terminal.feed(b"\x1b[?996n"); + assert_eq!(terminal.take_pty_response(), b"\x1b[?997;2n"); + } + + #[test] + fn reports_scheme_flips_only_to_programs_that_enabled_mode_2031() { + let mut terminal = GhosttyTerminalCore::new(20, 2, 100).unwrap(); + let light = ([0x4c, 0x4f, 0x69], [0xef, 0xf1, 0xf5], [0xdc, 0x8a, 0x78]); + let dark = ([0xcd, 0xd6, 0xf4], [0x1e, 0x1e, 0x2e], [0xf5, 0xe0, 0xdc]); + + terminal.set_colors(light.0, light.1, light.2).unwrap(); + assert!(terminal.take_pty_response().is_empty()); + + terminal.feed(b"\x1b[?2031h"); + terminal.set_colors(dark.0, dark.1, dark.2).unwrap(); + assert_eq!(terminal.take_pty_response(), b"\x1b[?997;1n"); + + // Another dark theme is not a scheme change. + terminal + .set_colors([0xff, 0xff, 0xff], [0x28, 0x2c, 0x34], [0xff, 0xff, 0xff]) + .unwrap(); + assert!(terminal.take_pty_response().is_empty()); + + terminal.set_colors(light.0, light.1, light.2).unwrap(); + assert_eq!(terminal.take_pty_response(), b"\x1b[?997;2n"); + } + #[test] fn distinguishes_defaults_palette_truecolor_and_osc_overrides() { let mut terminal = GhosttyTerminalCore::new(20, 2, 100).unwrap(); diff --git a/native/ghosttea/src/service.rs b/native/ghosttea/src/service.rs index 7d53462b..c6971c8a 100644 --- a/native/ghosttea/src/service.rs +++ b/native/ghosttea/src/service.rs @@ -2235,6 +2235,8 @@ async fn create_local_session( .unwrap_or(terminal_config.scrollback_bytes); let scrollback_bytes = usize::try_from(effective_scrollback_bytes) .context("scrollbackBytes does not fit this platform")?; + let mut options = options; + options.hint_color_scheme(terminal_config.background); tokio::task::spawn_blocking(move || { let session = Session::spawn_configured( options, diff --git a/native/ghosttea/src/session.rs b/native/ghosttea/src/session.rs index 4133a5db..33465530 100644 --- a/native/ghosttea/src/session.rs +++ b/native/ghosttea/src/session.rs @@ -335,6 +335,45 @@ pub struct SpawnOptions { pub scrollback_bytes: Option, } +impl SpawnOptions { + /// Advertise whether the default background is light or dark through + /// `COLORFGBG` (the rxvt `fg;bg` palette-index convention). Programs that + /// read it at startup instead of querying OSC 11 pick a matching theme. + /// A value the caller already set wins. + pub(crate) fn hint_color_scheme(&mut self, background: [u8; 3]) { + let value = if background_is_light(background) { + "0;15" + } else { + "15;0" + }; + let variables = match self.environment.as_mut() { + Some(SessionEnvironment::Inherit { overrides }) => overrides, + Some(SessionEnvironment::Clean { variables }) => variables, + None => &mut self.env, + }; + variables + .entry("COLORFGBG".to_owned()) + .or_insert_with(|| value.to_owned()); + } +} + +/// Matches the VT shim's color-scheme answer: light when dark text +/// out-contrasts light text, i.e. WCAG relative luminance above ~0.179. +fn background_is_light(background: [u8; 3]) -> bool { + let linear = |value: u8| { + let v = f64::from(value) / 255.0; + if v <= 0.04045 { + v / 12.92 + } else { + ((v + 0.055) / 1.055).powf(2.4) + } + }; + let luminance = 0.2126 * linear(background[0]) + + 0.7152 * linear(background[1]) + + 0.0722 * linear(background[2]); + (luminance + 0.05) / 0.05 > 1.05 / (luminance + 0.05) +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "lowercase")] pub enum KeyAction { @@ -2823,6 +2862,35 @@ mod tests { assert_eq!(maximum.scrollback_bytes, Some(MAX_JSON_SAFE_INTEGER)); } + #[test] + fn color_hint_follows_the_background_and_yields_to_the_caller() { + let options = |environment: serde_json::Value| -> SpawnOptions { + let mut json = spawn_options_json(0.into()); + json["environment"] = environment; + serde_json::from_value(json).unwrap() + }; + let mut light = options(serde_json::json!({ "mode": "clean", "variables": {} })); + light.hint_color_scheme([0xef, 0xf1, 0xf5]); + let Some(SessionEnvironment::Clean { variables }) = &light.environment else { + panic!() + }; + assert_eq!(variables["COLORFGBG"], "0;15"); + + let mut dark: SpawnOptions = serde_json::from_value(spawn_options_json(0.into())).unwrap(); + dark.hint_color_scheme([0x1e, 0x1e, 0x2e]); + assert_eq!(dark.env["COLORFGBG"], "15;0"); + + let mut chosen = options(serde_json::json!({ + "mode": "inherit", + "overrides": { "COLORFGBG": "7;4" } + })); + chosen.hint_color_scheme([0xff, 0xff, 0xff]); + let Some(SessionEnvironment::Inherit { overrides }) = &chosen.environment else { + panic!() + }; + assert_eq!(overrides["COLORFGBG"], "7;4"); + } + #[test] fn scrollback_override_rejects_negative_fractional_and_unsafe_numbers() { for value in [ From e30357f79d9d7248bc9541fac0c1f4e7972aeee7 Mon Sep 17 00:00:00 2001 From: James Yong Date: Fri, 2 Oct 2026 18:39:34 -0700 Subject: [PATCH 2/4] feat(react): adapt dark-painted panes to light themes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applications that paint every cell with their own background ignore the terminal theme; Grok's default theme fills the screen with #141414, so a light theme never shows. Under a light theme, the WebGPU renderer now remaps a pane when at least 60% of its cells carry an explicit dark background (released below 35% to avoid flicker), so programs that follow the theme are never touched. The remap runs per style in resolveStyle, before blending, leaving glyph coverage, color glyphs, and the shader stack alone. Surfaces mirror their toe-corrected OKLab lightness around the theme background, neutral ink keeps its distance from its cell background, and accents land in an APCA mid-tone band with their hue kept. Block elements use the surface mapping so art seams line up. Against each app's own light design this measured mean ink ΔE 3.1 (Grok), 3.3 (Codex), 3.7 (OpenCode), versus 5.0, 10.1, and 5.5 for a full-screen invert. Configuration defaults TerminalTheme.lightAdaptation to "auto"; embedders can pass "off" through the workspace theme override. Co-Authored-By: Claude Opus 5.5 --- docs/webgpu-appearance-pipeline.md | 24 ++ packages/ghosttea-react/src/config.test.ts | 1 + packages/ghosttea-react/src/config.ts | 1 + .../src/renderers/light-adaptation.test.ts | 91 ++++++ .../src/renderers/light-adaptation.ts | 278 ++++++++++++++++++ .../ghosttea-react/src/renderers/types.ts | 6 + .../src/renderers/webgpu-renderer.ts | 82 +++++- 7 files changed, 474 insertions(+), 9 deletions(-) create mode 100644 packages/ghosttea-react/src/renderers/light-adaptation.test.ts create mode 100644 packages/ghosttea-react/src/renderers/light-adaptation.ts diff --git a/docs/webgpu-appearance-pipeline.md b/docs/webgpu-appearance-pipeline.md index f24104e4..9519bf79 100644 --- a/docs/webgpu-appearance-pipeline.md +++ b/docs/webgpu-appearance-pipeline.md @@ -46,6 +46,30 @@ A solid block cursor is inserted after selection backgrounds and before glyphs; the covered glyph uses `cursor-text`. Bar, underline, and hollow cursors remain late overlay geometry. +## Light adaptation + +Some applications paint every cell with their own truecolor background, so a +light theme never shows through (Grok's default theme paints `#141414` +everywhere). With `lightAdaptation: "auto"`, the default from configuration, +a pane is remapped when the theme background is light and at least 60% of its +cells carry an explicit dark background. It releases below 35%, so partially +painted scrolls do not flicker. Applications that follow the theme (Codex, +OpenCode, Claude Code's Auto theme) never cross that threshold. + +The remap runs in `resolveStyle`, per style and before blending, so glyph +coverage, color glyphs, and the shader stack are untouched. Surfaces mirror +their OKLab-with-toe lightness around the theme background, anchored at the +application's dominant surface. Neutral ink keeps its distance from its own +cell background, and accents land in an APCA mid-tone band with their hue +intact. Default foreground and background stay theme-owned. Engaging, +re-keying, or releasing invalidates the persistent scene and geometry cache. + +Applications that switch themes themselves need the terminal to say which +scheme it is. ghosttead answers `CSI ? 996 n` from the default background's +luminance, sends `CSI ? 997 ; 1|2 n` when a theme change flips it and mode +2031 is set, and sets `COLORFGBG` for new sessions unless the caller provides +one. + ## Catalogs and persistence The linked `ghostty.style` gallery seeds its built-in collection from every diff --git a/packages/ghosttea-react/src/config.test.ts b/packages/ghosttea-react/src/config.test.ts index 4e747eb0..0bb73277 100644 --- a/packages/ghosttea-react/src/config.test.ts +++ b/packages/ghosttea-react/src/config.test.ts @@ -27,6 +27,7 @@ describe("shared configuration presentation", () => { selection: [4 / 255, 5 / 255, 6 / 255, 1], selectionForeground: [7 / 255, 8 / 255, 9 / 255, 1], backgroundOpacityCells: false, + lightAdaptation: "auto", }); expect(terminalEffectsFromConfig({ renderer })).toEqual({ postProcess: "better-crt", diff --git a/packages/ghosttea-react/src/config.ts b/packages/ghosttea-react/src/config.ts index a7d18cc7..ee63c1b2 100644 --- a/packages/ghosttea-react/src/config.ts +++ b/packages/ghosttea-react/src/config.ts @@ -23,6 +23,7 @@ export function terminalThemeFromConfig(config: Pick selection: rgba(renderer.selectionBackground), selectionForeground: rgba(renderer.selectionForeground), backgroundOpacityCells: renderer.backgroundOpacityCells ?? false, + lightAdaptation: "auto", }; } diff --git a/packages/ghosttea-react/src/renderers/light-adaptation.test.ts b/packages/ghosttea-react/src/renderers/light-adaptation.test.ts new file mode 100644 index 00000000..0f89eb71 --- /dev/null +++ b/packages/ghosttea-react/src/renderers/light-adaptation.test.ts @@ -0,0 +1,91 @@ +import type { StyleDefinition, StyleRun } from "@vibecook/ghosttea-frame"; +import { describe, expect, it } from "vitest"; +import { createLightAdaptation, darkPaintedBase, isLightBackground, type Rgb } from "./light-adaptation.js"; +import { DEFAULT_THEME, type RenderView, type Rgba, type TerminalTheme } from "./types.js"; + +const rgba = ([r, g, b]: Rgb): Rgba => [r / 255, g / 255, b / 255, 1]; +const bytes = (color: Rgba): Rgb => [color[0], color[1], color[2]].map((v) => Math.round(v * 255)) as unknown as Rgb; +const near = (actual: Rgba, expected: Rgb, tolerance = 6): void => { + bytes(actual).forEach((value, index) => expect(Math.abs(value - expected[index]!)).toBeLessThanOrEqual(tolerance)); +}; + +// GrokNight's base and GrokDay's paper, with a neutral ink so no tint shifts. +const grok = createLightAdaptation([0x14, 0x14, 0x14], rgba([0xee, 0xee, 0xee]), rgba([0x26, 0x26, 0x26])); + +describe("light adaptation colors", () => { + it("maps the app's base surface onto the theme paper", () => { + near(grok.surface([0x14, 0x14, 0x14]), [0xee, 0xee, 0xee], 1); + }); + + it("keeps the gray hierarchy of GrokNight close to GrokDay", () => { + near(grok.ink([0xe1, 0xe1, 0xe1], grok.base), [0x26, 0x26, 0x26], 10); // primary + near(grok.ink([0x73, 0x73, 0x73], grok.base), [0x74, 0x74, 0x74], 16); // secondary + near(grok.ink([0x33, 0x33, 0x33], grok.base), [0xcd, 0xcd, 0xcd], 8); // border + }); + + it("keeps an accent's hue instead of inverting it", () => { + const [r, g, b] = bytes(grok.ink([0xe0, 0xaf, 0x68], grok.base)); + expect(r).toBeGreaterThan(g); + expect(g).toBeGreaterThan(b); + near(grok.ink([0xe0, 0xaf, 0x68], grok.base), [0xa2, 0x76, 0x12], 14); + }); + + it("keeps a visible tint on dark diff rows", () => { + const claude = createLightAdaptation([0x1e, 0x1e, 0x2e], rgba([0xef, 0xf1, 0xf5]), rgba([0x4c, 0x4f, 0x69])); + const [r, g, b] = bytes(claude.surface([0x3d, 0x01, 0x00])); + expect(r).toBeGreaterThan(240); + expect(r - Math.min(g, b)).toBeGreaterThan(20); + }); + + it("classifies backgrounds the same way the VT shim answers CSI ? 996 n", () => { + expect(isLightBackground([0xef, 0xf1, 0xf5])).toBe(true); + expect(isLightBackground([0x1e, 0x1e, 0x2e])).toBe(false); + }); +}); + +function view(theme: TerminalTheme, cols: number, rows: number, paintedRows: number): RenderView { + const style: StyleDefinition = { + id: 7, + bold: false, + italic: false, + faint: false, + inverse: false, + invisible: false, + strikethrough: false, + underline: false, + background: [0x14, 0x14, 0x14], + }; + const painted: StyleRun[] = [{ styleId: 7, cellStart: 0, cellSpan: cols }]; + return { + cols, + rows: Array(rows).fill(""), + nativeStyleRows: Array.from({ length: rows }, (_, row) => (row < paintedRows ? painted : [])), + styleDefinitions: new Map([[7, style]]), + theme, + } as unknown as RenderView; +} + +const light: TerminalTheme = { + ...DEFAULT_THEME, + background: rgba([0xef, 0xf1, 0xf5]), + foreground: rgba([0x4c, 0x4f, 0x69]), + lightAdaptation: "auto", +}; + +describe("light adaptation engagement", () => { + it("engages for a pane the app paints dark under a light theme", () => { + expect(darkPaintedBase(view(light, 10, 10, 10), false)).toEqual([0x14, 0x14, 0x14]); + }); + + it("stays off for dark themes, opt-outs, and apps that follow the theme", () => { + expect(darkPaintedBase(view({ ...light, background: rgba([0x1e, 0x1e, 0x2e]) }, 10, 10, 10), false)).toBeNull(); + expect(darkPaintedBase(view({ ...light, lightAdaptation: "off" }, 10, 10, 10), false)).toBeNull(); + expect(darkPaintedBase(view(light, 10, 10, 1), false)).toBeNull(); + }); + + it("uses hysteresis so partially painted scrolls do not flicker", () => { + expect(darkPaintedBase(view(light, 10, 10, 5), false)).toBeNull(); + expect(darkPaintedBase(view(light, 10, 10, 5), true)).toEqual([0x14, 0x14, 0x14]); + expect(darkPaintedBase(view(light, 10, 10, 3), true)).toBeNull(); + }); +}); diff --git a/packages/ghosttea-react/src/renderers/light-adaptation.ts b/packages/ghosttea-react/src/renderers/light-adaptation.ts new file mode 100644 index 00000000..39ec5e98 --- /dev/null +++ b/packages/ghosttea-react/src/renderers/light-adaptation.ts @@ -0,0 +1,278 @@ +// Light adaptation: render a dark-painted application (one that fills the +// screen with its own truecolor backgrounds) as a light design under a light +// theme. Colors are remapped per style before blending, so glyph coverage, +// color emoji, and the shader stack are untouched. +// +// The rules come from comparing candidate remaps with three applications' own +// light designs (GrokDay, Codex, OpenCode): +// +// surfaces mirror around the paper in Lr (OKLab lightness with Ottosson's +// toe, which tracks CIE L*): a panel 0.05 above the app's base +// lands 0.05 below the theme background; the base's tint becomes +// the paper's. Tinted rows step down until a visible tint fits. +// neutral ink keeps its Lr distance from its own cell background, mirrored, +// so primary/secondary/border grays keep their order. +// accents land in a mid-tone band: APCA Lc = clamp(0.5·|Lc| + 28, 45, 70) +// against the new background, chroma raised toward 0.12. +// +// Theme-owned colors (default fg/bg) are left to the theme. + +import type { StyleDefinition } from "@vibecook/ghosttea-frame"; +import type { RenderView, Rgba } from "./types.js"; + +export type Rgb = readonly [number, number, number]; +type Lab = [number, number, number]; + +const clamp = (value: number, lo = 0, hi = 1): number => Math.min(hi, Math.max(lo, value)); +const toLinear = (v: number): number => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4); +const toGamma = (v: number): number => (v <= 0.0031308 ? v * 12.92 : 1.055 * v ** (1 / 2.4) - 0.055); + +function oklab([r8, g8, b8]: Rgb): Lab { + const r = toLinear(r8 / 255); + const g = toLinear(g8 / 255); + const b = toLinear(b8 / 255); + const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b); + const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b); + const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b); + return [ + 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, + 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, + 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s, + ]; +} + +function oklabToLinear([L, a, b]: Lab): Lab { + const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3; + const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3; + const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3; + return [ + 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, + -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, + -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s, + ]; +} + +const inGamut = (linear: Lab): boolean => linear.every((v) => v >= -1e-4 && v <= 1 + 1e-4); +const fromLch = (L: number, C: number, h: number): Lab => [L, C * Math.cos(h), C * Math.sin(h)]; +const toRgb = (lab: Lab): Rgb => + oklabToLinear(lab).map((v) => Math.round(clamp(toGamma(clamp(v))) * 255)) as unknown as Rgb; + +/** Keep L and hue; reduce chroma until the color fits sRGB. */ +function gamutMap(L: number, C: number, h: number): Rgb { + L = clamp(L); + if (inGamut(oklabToLinear(fromLch(L, C, h)))) return toRgb(fromLch(L, C, h)); + let lo = 0; + let hi = C; + for (let i = 0; i < 20; i += 1) { + const mid = (lo + hi) / 2; + if (inGamut(oklabToLinear(fromLch(L, mid, h)))) lo = mid; + else hi = mid; + } + return toRgb(fromLch(L, lo, h)); +} + +function maxChroma(L: number, h: number): number { + let lo = 0; + let hi = 0.4; + for (let i = 0; i < 16; i += 1) { + const mid = (lo + hi) / 2; + if (inGamut(oklabToLinear(fromLch(L, mid, h)))) lo = mid; + else hi = mid; + } + return lo; +} + +const K1 = 0.206; +const K2 = 0.03; +const K3 = (1 + K1) / (1 + K2); +const toe = (L: number): number => 0.5 * (K3 * L - K1 + Math.sqrt((K3 * L - K1) ** 2 + 4 * K2 * K3 * L)); +const toeInv = (Lr: number): number => (Lr * Lr + K1 * Lr) / (K3 * (Lr + K2)); + +// APCA 0.0.98G-4g. Positive Lc = dark text on a light background. +function apcaY([r, g, b]: Rgb): number { + const y = 0.2126729 * (r / 255) ** 2.4 + 0.7151522 * (g / 255) ** 2.4 + 0.072175 * (b / 255) ** 2.4; + return y < 0.022 ? y + (0.022 - y) ** 1.414 : y; +} +function apca(text: Rgb, background: Rgb): number { + const yt = apcaY(text); + const yb = apcaY(background); + if (Math.abs(yb - yt) < 0.0005) return 0; + if (yb > yt) { + const sapc = (yb ** 0.56 - yt ** 0.57) * 1.14; + return sapc < 0.1 ? 0 : (sapc - 0.027) * 100; + } + const sapc = (yb ** 0.65 - yt ** 0.62) * 1.14; + return sapc > -0.1 ? 0 : (sapc + 0.027) * 100; +} + +const smoothstep = (e0: number, e1: number, x: number): number => { + const t = clamp((x - e0) / (e1 - e0)); + return t * t * (3 - 2 * t); +}; + +/** Light when dark text out-contrasts light text: WCAG luminance above ~0.179. Matches the VT shim. */ +export function isLightBackground([r, g, b]: Rgb): boolean { + const luminance = 0.2126 * toLinear(r / 255) + 0.7152 * toLinear(g / 255) + 0.0722 * toLinear(b / 255); + return (luminance + 0.05) / 0.05 > 1.05 / (luminance + 0.05); +} + +const toRgba = ([r, g, b]: Rgb): Rgba => [r / 255, g / 255, b / 255, 1]; +const fromRgba = (color: Rgba): Rgb => [ + Math.round(clamp(color[0]) * 255), + Math.round(clamp(color[1]) * 255), + Math.round(clamp(color[2]) * 255), +]; +const keyOf = ([r, g, b]: Rgb): number => (r << 16) | (g << 8) | b; + +export interface LightAdaptation { + /** Stable identity for cache keys: base, paper, and ink. */ + readonly key: string; + readonly base: Rgb; + /** A color painted as an area: cell backgrounds and block-element art. */ + surface(color: Rgb): Rgba; + /** A glyph color, judged against the background it was designed on. */ + ink(color: Rgb, sourceBackground: Rgb): Rgba; +} + +export function createLightAdaptation(base: Rgb, paperColor: Rgba, inkColor: Rgba): LightAdaptation { + const paper = fromRgba(paperColor); + const inkRgb = fromRgba(inkColor); + const baseLab = oklab(base); + const paperLab = oklab(paper); + const inkLab = oklab(inkRgb); + const baseLr = toe(baseLab[0]); + const paperLr = toe(paperLab[0]); + + const surfaces = new Map(); + const surfaceRgb = (color: Rgb): Rgb => { + const cached = surfaces.get(keyOf(color)); + if (cached) return cached; + const [L, a, b] = oklab(color); + let Lr = clamp(paperLr - (toe(L) - baseLr)); + const ap = a - baseLab[1] + paperLab[1]; + const bp = b - baseLab[2] + paperLab[2]; + const C = Math.hypot(ap, bp); + const h = Math.atan2(bp, ap); + if (C > 0.015) { + const want = Math.min(C, 0.045); + const stop = Lr - 0.15; + while (Lr > stop && maxChroma(toeInv(Lr), h) < want) Lr -= 0.005; + } + const result = gamutMap(toeInv(Lr), C, h); + surfaces.set(keyOf(color), result); + return result; + }; + + const solveLc = (want: number, C: number, h: number, background: Rgb): Rgb => { + const backgroundL = oklab(background)[0]; + let lo = want > 0 ? 0 : backgroundL; + let hi = want > 0 ? backgroundL : 1; + let best = gamutMap(want > 0 ? 0 : 1, C, h); + for (let i = 0; i < 18; i += 1) { + const mid = (lo + hi) / 2; + const candidate = gamutMap(mid, C, h); + const lc = apca(candidate, background); + const enough = want > 0 ? lc >= want : lc <= want; + if (enough) best = candidate; + if (want > 0 === enough) lo = mid; + else hi = mid; + } + return best; + }; + + const inks = new Map(); + const ink = (color: Rgb, sourceBackground: Rgb): Rgba => { + const cacheKey = `${keyOf(color)}/${keyOf(sourceBackground)}`; + const cached = inks.get(cacheKey); + if (cached) return cached; + const mapped = surfaceRgb(sourceBackground); + const [L, a0, b0] = oklab(color); + const C0 = Math.hypot(a0, b0); + // Neutral ink: mirror its Lr distance from the background, and lean + // toward the theme ink's tint so app grays sit in the chosen theme. + let a = a0; + let b = b0; + if (C0 < 0.03) { + const w = (1 - C0 / 0.03) * 0.7; + a = a0 * (1 - w) + inkLab[1] * w; + b = b0 * (1 - w) + inkLab[2] * w; + } + const C = Math.hypot(a, b); + const h = Math.atan2(b, a); + const delta = toe(L) - toe(oklab(sourceBackground)[0]); + const sign = delta >= 0 ? -1 : 1; + const Lr = clamp(toe(oklab(mapped)[0]) + sign * Math.max(Math.abs(delta), 0.06)); + let result = gamutMap(toeInv(Lr), C, h); + const w = smoothstep(0.03, 0.08, C0); + if (w > 0) { + const source = apca(color, sourceBackground); + const lightOnDark = source < 0 || (source === 0 && delta >= 0); + const magnitude = clamp(Math.abs(source) * 0.5 + 28, 45, 70); + const boosted = C0 + w * clamp(0.12 - C0, 0, C0 * 0.8); + const accent = solveLc(lightOnDark ? magnitude : -magnitude, boosted, Math.atan2(b0, a0), mapped); + if (w >= 1) result = accent; + else { + const p = oklab(result); + const q = oklab(accent); + result = toRgb([p[0] * (1 - w) + q[0] * w, p[1] * (1 - w) + q[1] * w, p[2] * (1 - w) + q[2] * w]); + } + } + const rgba = toRgba(result); + inks.set(cacheKey, rgba); + return rgba; + }; + + return { + key: [base, paper, inkRgb].map((color) => keyOf(color).toString(16)).join("/"), + base, + surface: (color) => toRgba(surfaceRgb(color)), + ink, + }; +} + +/** Engage when this share of cells carries an explicit dark background… */ +export const ENGAGE_SHARE = 0.6; +/** …and stay engaged until it drops below this, so scrolling content does not flicker. */ +export const RELEASE_SHARE = 0.35; + +/** + * The surface a dark-painted application uses as its base, or null when the + * pane should render as-is: adaptation is off, the theme is dark, or too few + * cells carry an explicit dark background (the app follows the theme). + */ +export function darkPaintedBase(view: RenderView, engaged: boolean): Rgb | null { + if ((view.theme.lightAdaptation ?? "off") === "off") return null; + if (!isLightBackground(fromRgba(view.theme.background))) return null; + const rows = Math.max(view.rows.length, view.nativeStyleRows.length); + const total = rows * view.cols; + if (total === 0) return null; + const areas = new Map(); + let dark = 0; + const darkness = new Map(); + for (const runs of view.nativeStyleRows) { + for (const run of runs) { + const style: StyleDefinition | undefined = view.styleDefinitions.get(run.styleId); + const background = style?.inverse ? style.foreground : style?.background; + if (!background) continue; + const key = keyOf(background); + let isDark = darkness.get(key); + if (isDark === undefined) { + isDark = !isLightBackground(background); + darkness.set(key, isDark); + } + if (!isDark) continue; + dark += run.cellSpan; + areas.set(key, (areas.get(key) ?? 0) + run.cellSpan); + } + } + if (dark / total < (engaged ? RELEASE_SHARE : ENGAGE_SHARE)) return null; + let bestKey = 0; + let bestArea = -1; + for (const [key, area] of areas) { + if (area > bestArea) { + bestKey = key; + bestArea = area; + } + } + return [(bestKey >> 16) & 0xff, (bestKey >> 8) & 0xff, bestKey & 0xff]; +} diff --git a/packages/ghosttea-react/src/renderers/types.ts b/packages/ghosttea-react/src/renderers/types.ts index f1561161..30be77d6 100644 --- a/packages/ghosttea-react/src/renderers/types.ts +++ b/packages/ghosttea-react/src/renderers/types.ts @@ -19,6 +19,12 @@ export interface TerminalTheme { selectionForeground: Rgba; /** Apply the window background alpha to explicit cell backgrounds too. */ backgroundOpacityCells?: boolean; + /** + * Under a light theme, remap panes that an application paints dark with its + * own backgrounds (Grok, fixed-theme TUIs) into a light design. "auto" + * engages per pane only when most cells carry an explicit dark background. + */ + lightAdaptation?: "auto" | "off"; } export type TerminalPostProcess = "none" | "better-crt"; diff --git a/packages/ghosttea-react/src/renderers/webgpu-renderer.ts b/packages/ghosttea-react/src/renderers/webgpu-renderer.ts index 291ac3f7..c2575118 100644 --- a/packages/ghosttea-react/src/renderers/webgpu-renderer.ts +++ b/packages/ghosttea-react/src/renderers/webgpu-renderer.ts @@ -21,6 +21,7 @@ import { graphemeCellWidth, splitGraphemes } from "../cell-width.js"; import { backgroundRunBounds } from "./background-geometry.js"; import { rowsForDamage } from "./render-damage.js"; import { SHADER_EFFECT_WGSL } from "./shader-effects.js"; +import { createLightAdaptation, darkPaintedBase, type LightAdaptation } from "./light-adaptation.js"; const ATLAS_SIZE = 2048; const GEOMETRY_CACHE_LIMIT = 8; @@ -485,6 +486,8 @@ interface WebGpuSurface extends PixelSize { geometryCache: Map; geometryCandidates: Map; sceneValid: boolean; + /** Engaged light adaptation for a dark-painted pane, or null. */ + adaptation: LightAdaptation | null; } interface EffectPass { @@ -548,6 +551,7 @@ function geometryCacheKey( rows: readonly number[], hasNativeRows: boolean, atlasGenerations: readonly [number, number, number], + adaptationKey: string, ): string { const selection = view.selection ? `${view.selection.anchor.row},${view.selection.anchor.column},${view.selection.focus.row},${view.selection.focus.column}` @@ -567,6 +571,7 @@ function geometryCacheKey( ...view.theme.selectionForeground, ].join(","), view.theme.backgroundOpacityCells ? "opacity-cells" : "opaque-cells", + adaptationKey, selection, effectiveCursorStyle(view) === CursorStyle.Block ? `${view.cursor.x},${view.cursor.y}` : "-", atlasGenerations.join(","), @@ -849,26 +854,55 @@ function pushBlockCursorBackground( interface ResolvedStyle { foreground: Rgba; background: Rgba | null; + /** Foreground used as an area fill (block elements); differs only under light adaptation. */ + fill: Rgba; underline: boolean; strikethrough: boolean; invisible: boolean; } -function resolveStyle(style: StyleDefinition | undefined, theme: RenderView["theme"]): ResolvedStyle { +function resolveStyle( + style: StyleDefinition | undefined, + theme: RenderView["theme"], + adaptation: LightAdaptation | null = null, +): ResolvedStyle { let foreground: Rgba = style?.foreground ? rgb(style.foreground) : theme.foreground; let background: Rgba | null = style?.background ? rgb(style.background) : null; - if (style?.inverse) { + let fill = foreground; + if (adaptation) { + // Application colors were designed on the app's dark base; theme-owned + // defaults already belong to the light theme and pass through. + const source = { foreground: style?.foreground, background: style?.background }; + if (style?.inverse) { + background = source.foreground ? adaptation.surface(source.foreground) : theme.foreground; + foreground = source.background + ? adaptation.ink(source.background, source.foreground ?? adaptation.base) + : theme.background; + fill = foreground; + } else { + foreground = source.foreground + ? adaptation.ink(source.foreground, source.background ?? adaptation.base) + : theme.foreground; + background = source.background ? adaptation.surface(source.background) : null; + fill = source.foreground ? adaptation.surface(source.foreground) : theme.foreground; + } + } else if (style?.inverse) { const originalForeground = foreground; foreground = background ?? theme.background; background = originalForeground; + fill = foreground; } if (background && theme.backgroundOpacityCells) { background = [background[0], background[1], background[2], theme.background[3]]; } - if (style?.faint) foreground = [foreground[0], foreground[1], foreground[2], foreground[3] * 0.55]; + if (style?.faint) { + foreground = [foreground[0], foreground[1], foreground[2], foreground[3] * 0.55]; + fill = [fill[0], fill[1], fill[2], fill[3] * 0.55]; + } return { foreground, background, + fill, underline: style?.underline ?? false, strikethrough: style?.strikethrough ?? false, invisible: style?.invisible ?? false, @@ -1039,6 +1073,7 @@ export class WebGpuTerminalRenderer implements TerminalRenderer { readonly #monoAtlas: NativeGlyphAtlas; readonly #colorAtlas: NativeGlyphAtlas; readonly #fallbackAtlas: FallbackGlyphAtlas; + readonly #adaptations = new Map(); #performanceMeasurementEnabled = false; private constructor( @@ -1249,6 +1284,7 @@ export class WebGpuTerminalRenderer implements TerminalRenderer { geometryCache: new Map(), geometryCandidates: new Map(), sceneValid: false, + adaptation: null, }; this.#surfaces.set(id, surface); this.#configure(surface); @@ -1422,9 +1458,32 @@ export class WebGpuTerminalRenderer implements TerminalRenderer { }; } + /** + * Engage, re-key, or release light adaptation for a pane. Any change remaps + * every color, so the persistent scene and cached geometry are invalidated. + */ + #prepareAdaptation(surface: WebGpuSurface, view: RenderView): void { + const base = darkPaintedBase(view, surface.adaptation !== null); + let next: LightAdaptation | null = null; + if (base) { + const key = `${base.join(",")}|${view.theme.background.join(",")}|${view.theme.foreground.join(",")}`; + next = this.#adaptations.get(key) ?? null; + if (!next) { + next = createLightAdaptation(base, view.theme.background, view.theme.foreground); + this.#adaptations.set(key, next); + if (this.#adaptations.size > 8) this.#adaptations.delete(this.#adaptations.keys().next().value!); + } + } + if ((next?.key ?? null) === (surface.adaptation?.key ?? null)) return; + surface.adaptation = next; + surface.sceneValid = false; + clearGeometryCache(surface); + } + render(id: string, view: RenderView): TerminalRenderMetrics | undefined { const surface = this.#surfaces.get(id); if (!surface) return; + this.#prepareAdaptation(surface, view); const encoder = this.device.createCommandEncoder({ label: `terminal frame ${id}` }); const metrics = view.damage?.geometryChanged === false @@ -1441,6 +1500,7 @@ export class WebGpuTerminalRenderer implements TerminalRenderer { const encoder = this.device.createCommandEncoder({ label: `terminal frame batch (${active.length} panes)` }); const byId = new Map(); for (const { id, view } of active) { + this.#prepareAdaptation(this.#surfaces.get(id)!, view); byId.set( id, view.damage?.geometryChanged === false @@ -1463,6 +1523,7 @@ export class WebGpuTerminalRenderer implements TerminalRenderer { scale: number, viewportWidth: number, viewportHeight: number, + adaptation: LightAdaptation | null, ): CpuGeometry { const rectangleVertices: number[] = []; const glyphVertices: number[] = []; @@ -1472,7 +1533,7 @@ export class WebGpuTerminalRenderer implements TerminalRenderer { const styleFor = (styleId: number): ResolvedStyle => { const cached = resolvedStyles.get(styleId); if (cached) return cached; - const resolved = resolveStyle(view.styleDefinitions.get(styleId), view.theme); + const resolved = resolveStyle(view.styleDefinitions.get(styleId), view.theme, adaptation); resolvedStyles.set(styleId, resolved); return resolved; }; @@ -1596,6 +1657,7 @@ export class WebGpuTerminalRenderer implements TerminalRenderer { if (blockElement) { let backdrop = style.background ?? view.theme.background; if (isSelected) backdrop = view.theme.selection; + const blockForeground = foreground === style.foreground ? style.fill : foreground; if ( pushBlockElement( rectangleVertices, @@ -1603,7 +1665,7 @@ export class WebGpuTerminalRenderer implements TerminalRenderer { instance.cellStart, row, scale, - over(foreground, backdrop), + over(blockForeground, backdrop), viewportWidth, viewportHeight, ) @@ -1727,7 +1789,7 @@ export class WebGpuTerminalRenderer implements TerminalRenderer { const styleFor = (styleId: number): ResolvedStyle => { const cached = resolvedStyles.get(styleId); if (cached) return cached; - const resolved = resolveStyle(view.styleDefinitions.get(styleId), view.theme); + const resolved = resolveStyle(view.styleDefinitions.get(styleId), view.theme, surface.adaptation); resolvedStyles.set(styleId, resolved); return resolved; }; @@ -1854,7 +1916,8 @@ export class WebGpuTerminalRenderer implements TerminalRenderer { if (blockElement) { let backdrop = style.background ?? view.theme.background; if (isSelected) backdrop = view.theme.selection; - const blockColor = over(foreground, backdrop); + const blockForeground = foreground === style.foreground ? style.fill : foreground; + const blockColor = over(blockForeground, backdrop); if ( pushBlockElement( rectangleVertices, @@ -2155,7 +2218,7 @@ export class WebGpuTerminalRenderer implements TerminalRenderer { this.#colorAtlas.generation, this.#fallbackAtlas.generation, ]; - let key = geometryCacheKey(view, damage.rows, hasNativeRows, generations()); + let key = geometryCacheKey(view, damage.rows, hasNativeRows, generations(), surface.adaptation?.key ?? "-"); const cached = surface.geometryCache.get(key); const cacheHit = cached !== undefined; if (cached) { @@ -2174,8 +2237,9 @@ export class WebGpuTerminalRenderer implements TerminalRenderer { scale, viewportWidth, viewportHeight, + surface.adaptation, ); - key = geometryCacheKey(view, damage.rows, hasNativeRows, generations()); + key = geometryCacheKey(view, damage.rows, hasNativeRows, generations(), surface.adaptation?.key ?? "-"); const promote = admitted && key === candidateKey; geometry = { rectangleBuffer: promote From ceb5ee1009198765fb4fd9239977edeb034599fd Mon Sep 17 00:00:00 2001 From: James Yong Date: Fri, 2 Oct 2026 19:16:46 -0700 Subject: [PATCH 3/4] chore(deps): async-trait 0.1.92 for Rust 1.99's clippy Rust 1.99.0 (2026-09-28) added clippy::double_must_use, which fires on the code async-trait 0.1.89 generates for the mesh traits, so the required verify and windows gates fail under `-D warnings` on any branch. 0.1.92 no longer emits the redundant attribute. It and its new syn 3 dependency need Rust 1.71, inside the workspace's 1.88 minimum. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 47 +++++++++++++++++++++++++++++------------------ 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7d6ccbda..2f81ad10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,7 +44,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] @@ -56,18 +56,18 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] [[package]] @@ -149,7 +149,7 @@ checksum = "f65693059b6b9c588b9f62fed1cedbf0a8b805631457ea162d68f0de186f3de5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -409,7 +409,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -568,7 +568,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -883,7 +883,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn", + "syn 2.0.119", ] [[package]] @@ -902,7 +902,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1600,7 +1600,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1783,6 +1783,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -1791,7 +1802,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1844,7 +1855,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1855,7 +1866,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1928,7 +1939,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1977,7 +1988,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2202,7 +2213,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "wasm-bindgen-shared", ] @@ -2443,7 +2454,7 @@ checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] From 21c7135d9b45c5cfd4ce8a985e20e4c4582e2c96 Mon Sep 17 00:00:00 2001 From: James Yong Date: Fri, 2 Oct 2026 19:33:18 -0700 Subject: [PATCH 4/4] chore(ios): record the async-trait lockfile change in the release inventory async-trait is not in the aarch64-apple-ios graph, so the reviewed components are unchanged; only the Cargo.lock digest and the BOM and resource hashes derived from it move. Co-Authored-By: Claude Opus 5.5 --- apple/GhostteaApp/Resources/Ghosttea-iOS.cdx.json | 2 +- apple/GhostteaApp/Resources/THIRD-PARTY-NOTICES.txt | 2 +- .../Compatibility/ios-release-resources.lock.json | 6 +++--- apple/GhostteaKit/Compatibility/ios-release.cdx.json | 2 +- .../GhostteaKit/Compatibility/ios-rust-components.lock.json | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apple/GhostteaApp/Resources/Ghosttea-iOS.cdx.json b/apple/GhostteaApp/Resources/Ghosttea-iOS.cdx.json index b657bf24..62d67d4c 100644 --- a/apple/GhostteaApp/Resources/Ghosttea-iOS.cdx.json +++ b/apple/GhostteaApp/Resources/Ghosttea-iOS.cdx.json @@ -129,7 +129,7 @@ }, { "name": "ghosttea:cargo-lock:sha256", - "value": "6da56d16f37b315fb22ebe023528c7f4ff8b00b6674613e894838a658c2c83d3" + "value": "7cdc22165195a7f98749dadbf1de0801225a9a3fcac38cfc0a18b931767268e3" }, { "name": "ghosttea:rust-target", diff --git a/apple/GhostteaApp/Resources/THIRD-PARTY-NOTICES.txt b/apple/GhostteaApp/Resources/THIRD-PARTY-NOTICES.txt index 3065fa48..befc15d2 100644 --- a/apple/GhostteaApp/Resources/THIRD-PARTY-NOTICES.txt +++ b/apple/GhostteaApp/Resources/THIRD-PARTY-NOTICES.txt @@ -2,7 +2,7 @@ GHOSTTEA iOS THIRD-PARTY NOTICES ================================ This file is generated from the reviewed iOS release dependency graph. -CycloneDX SHA-256: be5713a39ecb1300ec9c5f41db8010e63fe0de4697e9c64f6e3966ac357d9b43 +CycloneDX SHA-256: 9733b5e2f5f8662b650181292deb5e5d130de982ada840cb9e53b5ffaa4f2065 Components: 108 Unique license documents: 97 diff --git a/apple/GhostteaKit/Compatibility/ios-release-resources.lock.json b/apple/GhostteaKit/Compatibility/ios-release-resources.lock.json index 598cfbc4..828fbbbb 100644 --- a/apple/GhostteaKit/Compatibility/ios-release-resources.lock.json +++ b/apple/GhostteaKit/Compatibility/ios-release-resources.lock.json @@ -2,15 +2,15 @@ "schemaVersion": 1, "sourceBom": { "path": "apple/GhostteaKit/Compatibility/ios-release.cdx.json", - "sha256": "be5713a39ecb1300ec9c5f41db8010e63fe0de4697e9c64f6e3966ac357d9b43" + "sha256": "9733b5e2f5f8662b650181292deb5e5d130de982ada840cb9e53b5ffaa4f2065" }, "bundledBom": { "path": "apple/GhostteaApp/Resources/Ghosttea-iOS.cdx.json", - "sha256": "be5713a39ecb1300ec9c5f41db8010e63fe0de4697e9c64f6e3966ac357d9b43" + "sha256": "9733b5e2f5f8662b650181292deb5e5d130de982ada840cb9e53b5ffaa4f2065" }, "notices": { "path": "apple/GhostteaApp/Resources/THIRD-PARTY-NOTICES.txt", - "sha256": "ba2189bb8022dd15234bbb6438fea7905df0ae1a8f226121fd89fa501bc06aec", + "sha256": "584197ad95026d1abed13290bf951db8c74db0aaa46afd8f2e64db8820cb143f", "componentCount": 108, "licenseDocumentCount": 97 } diff --git a/apple/GhostteaKit/Compatibility/ios-release.cdx.json b/apple/GhostteaKit/Compatibility/ios-release.cdx.json index b657bf24..62d67d4c 100644 --- a/apple/GhostteaKit/Compatibility/ios-release.cdx.json +++ b/apple/GhostteaKit/Compatibility/ios-release.cdx.json @@ -129,7 +129,7 @@ }, { "name": "ghosttea:cargo-lock:sha256", - "value": "6da56d16f37b315fb22ebe023528c7f4ff8b00b6674613e894838a658c2c83d3" + "value": "7cdc22165195a7f98749dadbf1de0801225a9a3fcac38cfc0a18b931767268e3" }, { "name": "ghosttea:rust-target", diff --git a/apple/GhostteaKit/Compatibility/ios-rust-components.lock.json b/apple/GhostteaKit/Compatibility/ios-rust-components.lock.json index 1e38847e..cb898ae9 100644 --- a/apple/GhostteaKit/Compatibility/ios-rust-components.lock.json +++ b/apple/GhostteaKit/Compatibility/ios-rust-components.lock.json @@ -10,7 +10,7 @@ "pkg:cargo/ghosttea-font-fixture-ffi@0.12.0" ] }, - "cargoLockSha256": "6da56d16f37b315fb22ebe023528c7f4ff8b00b6674613e894838a658c2c83d3", + "cargoLockSha256": "7cdc22165195a7f98749dadbf1de0801225a9a3fcac38cfc0a18b931767268e3", "components": [ { "ref": "pkg:cargo/anyhow@1.0.103",