The first version of this screen proved the vendored adaptive modules render, but it did not really demonstrate adaptation: it left rememberListDetailPaneScaffoldNavigator on its default directive, which is the WINDOW size class, and pinned the scaffold to a fixed 420dp. The window is the wrong input here. The screen does not get the window - the demo shell spends 190dp on the sidebar plus 48dp of padding - so defaulting folds ~240dp too late and leaves two panes side by side in a region already too cramped for them. The directive is now computed from the width the screen actually gets (BoxWithConstraints), which is what any pane-hosting app that is not full-window should do; the pane height tracks the window height instead of being a constant. The header prints the window size class and the pane-area size class side by side so the difference is visible while dragging. Also adds selection highlighting and a Back button that only appears while folded, so the single-pane mode is actually navigable. New `--adaptivetest` probe: drives one window wide -> narrow -> wide with setSize and asserts the scaffold folds and unfolds. This is what proves the resize chain end to end - SDL resize, the owner's snapshot-backed containerSize, recomposition, BoxWithConstraints re-measure, a new PaneScaffoldDirective on the navigator, a new scaffold value. The --screen=Adaptive --width=N screenshots could only ever prove the fold was right for the size the window OPENED at, since each run is a fresh process. The probe reads a small internal seam on the screen (gAdaptiveTwoPane); it is documented as a probe hook and is not UI. Verified on mingwX64: folds at 800dp and stays two-pane at 1100/1400dp, --adaptivetest passes repeatedly, and the JVM parity leg plus commonMain metadata still compile.
67 KiB
ROAD TO 1.0.0
Objective: get ComposeNativeSDL3 as close to upstream Compose Multiplatform as possible - vendor every file we can instead of rewriting, stay performant, and look faithful (ideally pixel-identical across macOS / Linux / Windows). The port is already at 99% upstream API coverage with maximal vendoring and no steady-state perf gap on macOS/Metal; the remaining 1.0.0 work is cross-platform rendering fidelity (Windows skiko-fork divergences), completing native-actual stubs, and the release mechanics (1.12.0-stable re-pin + WIN-SMOKE).
Status / handoff: the macOS/Linux-verifiable work is done and committed on main (see
"Landed toward 1.0.0" below - don't redo it). The remaining blockers need a Windows host
→ jump to "▶ Continuing on Windows (mingwX64) - START HERE". This file is self-contained: it
has the build/run commands, the exact confirm steps, the macOS baseline data to diff against, and
per-task fixes. So on Windows you can say "continue PLAN.md" and have everything.
Definition of done for 1.0.0
- No text tofu on any platform - Windows tab fix CONFIRMED on-device (§1a, 2026-08-04:
literal
\t→space, no.notdef); CJK / color-emoji / control chars render clean too (§1c). - Fork-vs-official divergence surface (FontMgr, gamma, ICU) audited on native Windows (2026-08-04): metrics == JVM (§1b), FontMgr fallback works (CJK + color emoji, §1c), ICU classification works (§1c). Net - the fork's Skia matches upstream; the only deltas are its thin extern-C bindings, handled in shared Kotlin (LineMetrics reconstruction). No fork-side fix needed for 1.0.
- [~] Native-actual fidelity blockers closed: float pointer coords DONE, screen-reader no-op DONE, text context menu RECONCILED - already works via the legacy path (§2). Date/time localization is P1 polish (formatter already works).
- Vendor hygiene stays clean: zero drift (all 10 manual vendors match pin), zero
commonMain rule-1 violations, zero vendored files touched this session - verified via
check-vendor-drift.py. - [~] SDL static lib slimmed to the used subsystem surface (§3) - DONE + verified on macOS/Metal; other hosts pending WIN-SMOKE.
- Refs re-pinned to Compose 1.12.0 stable, JVM parity versions bumped, WIN-SMOKE fidelity pass green on a real Windows host.
CLAUDE.mddocumentation map consistent with tree - line-1 typo fixed; the stale historical docsRENDERER.md+SKIKO-MINGW-FEASIBILITY.mdremoved (current renderer essentials inlined into CLAUDE.md, the rest is in git history); all dead doc links (TODO.md / RENDERER.md / SKIKO) removed or repointed to PLAN.md.
Landed toward 1.0.0 (this pass - all build + run verified on macOS/Metal)
Everything below is committed; details + file refs are in the sections that follow.
- Tab tofu fix (§1a P0) - normalize
\t→space before shaping so the Windows fork can't render.notdef. Platform-independent, length-preserving. - HiDPI caret quantization fix (§2 P0) - pointer/wheel coords carried as
Floatend-to-end instead of truncated toInt. - Screen-reader no-op (§2 P0) -
LocalPlatformScreenReaderdefaults to inactive instead of throwing. - SDL slimming (§3) - 10 unused subsystems disabled in
build-all.py; 6 now-dead macOS frameworks dropped fromsdl3.def. Rebuilt; demo + apidemo link, demo runs. - Context menu reconciled (§2 P0) - confirmed already working via the vendored legacy path; the 3 "NOP" seams are vestigial (disabled new API) - misleading TODOs corrected.
CDN_TEXT_METRICSdiagnostic (§1b) - env-gated line-metrics dump; macOS baseline captured, hypothesis sharpened (fork likely drops lineHeight leading).- Doc hygiene (§5) - removed the stale historical
RENDERER.md+SKIKO-MINGW-FEASIBILITY.md(renderer essentials folded intoCLAUDE.md; rest in git history), fixed theCLAUDE.mdtypo + all dead doc links; extracted the fork FreeType-scaler lead into §1c before deleting. Vendor drift verified clean.
Windows pass (2026-08-04, this host - DPR 1). On a real Windows host at last: (8) tab fix
CONFIRMED on-device (§1a); (9) vertical metrics RESOLVED - native --metricsprobe heights
== JVM --metrics exactly, feared "dropped leading" DISPROVEN (§1b); (10) fork LineMetrics. ascent/descent mis-decode FIXED in shared Kotlin (SkiaParagraphOps.lineMetrics(), guarded
no-op on official skiko) - repairs Windows caret/selection; (11) fork FontMgr/ICU AUDITED - CJK
- color-emoji + control-char fallback all work → the "empty FreeType stub" fear DISPROVEN (§1c);
(12)
FontRasterizationSettings.native.ktverified to mirror upstream VERBATIM + given aVENDOR-BASEprovenance line; rasterization stays upstream-faithful per-OS (user decision). Net: the fork's Skia matches upstream - the only deltas are its thin extern-C bindings, fixed in shared Kotlin, zero fork rebuild.
Still not done - WIN-SMOKE interactive + release (§5): interactive caret-height check (the one
behavioral consequence of the LineMetrics fix), the PrintWindow probe, compileCommonMainKotlin Metadata, apiDump + publish FROM Windows, and the Compose 1.12.0-stable re-pin. Deliberately
deferred (large/structural, rationale in-section): RTL, PlatformFontLoader, grapheme source-set
move, date CLDR, brush/gradient text - plus the big vendor-the-upstream-skiko-text-engine
refactor (§6), sequenced after these fixes land.
▶ Continuing on Windows (mingwX64) - START HERE
Read this before doing anything. This file is the single source of truth for the road to
1.0.0. Everything in "Landed toward 1.0.0" above is already implemented and committed on
main - do NOT redo it, just verify it on this host. Every open [ ] / [~] below is
remaining work. The macOS/Linux side is done and verified; the remaining blockers need a real
Windows host - the mingwX64 build cross-compiled from mac/Linux fails at cinterop, and the
bitsycore skiko fork can only be rebuilt on Windows.
The Windows fidelity reference is skiko-on-JVM Compose Desktop on THIS Windows host
(gradlew :demo:run - also DirectWrite/system fonts), NOT macOS. mac↔Windows metric deltas are
expected in upstream Compose Desktop too, so "matches Windows-JVM" is the acceptance bar, not
"matches macOS".
Build / run (Windows shell, repo root)
gradlew.bat :demo:runDebugExecutableMingwX64 :: native demo (the port under test)
gradlew.bat :apidemo:runDebugExecutableMingwX64 :: native apidemo (has the Session/Override buttons)
gradlew :demo:run :: JVM Compose Desktop = the FIDELITY REFERENCE
gradlew :apidemo:run :: JVM apidemo reference
- The bridge plugin auto-drops
skiko-windows-x64.dllnext to the exe (installWindowsSkiaDll). Fork coordscom.bitsycore.skiko:skiko:0.150.1-mingw.2(override-PskikoMingwVersion). :demoCLI flags (native AND jvm):--screen=<Name>,--screenshot=<path.bmp>(capture at quiescence then quit),--gpu=auto|software|skia.opengl.CDN_TEXT_METRICS=1env var dumps text metrics (§1b).CDN_PROFILE=1frame profiler.:apidemohas NO screenshot CLI - drive it by hand for the button-metrics visual.- If an IC-cache error appears after any module churn: delete
demo\build\kotlin-native-ic-cache.
✅ Windows session results (2026-08-04, this host - DPR 1.0)
Items 1–3 below are DONE + verified on the shipped mingwX64 demo binary; findings folded into §1a/§1b/§1c. Only 4–5 (WIN-SMOKE interactive gate + apiDump/publish) remain. TL;DR:
- §1a tab fix - CONFIRMED.
--screen=BasicTextwith"start>a\tb<end\tcols\tand\tthere"renderedstart>a b<end cols and there- every\ta SPACE, zero.notdef. (Structural proof too: the whole module has exactly 2.addText(sites, bothshapedText.) - §1b vertical metrics - RESOLVED, the feared bug is DISPROVEN. Native
--metricsprobevs JVM--metrics(same host, NotoSans, density 1): every paragraph height + non-M3 baseline matches JVM EXACTLY (size 11→24). Leading is NOT dropped. Two residual deltas found (details in §1b): (a) the fork'sLineMetrics.ascent/descentare mis-decoded - fixed in Kotlin this session; (b) M3 baseline drifts ≤0.77px (halfLeading, a shared-engine omission on ALL platforms, sub-pixel). - §1c FontMgr/ICU - the "empty FreeType stub" fear is DISPROVEN. Native render of CJK
(你好世界 日本語 한국어) + full-COLOR emoji (😀🎉🚀❤) + control chars (nbsp/zwsp/en-dash) = zero
tofu. Color emoji + CJK fallback prove
FontMgr.defaultis a real DirectWrite-backed system manager, not an empty custom FreeType one. Remaining §1c work is doc-only (captureargs.gn).
Reference commands actually used (both probes print metrics: lines; native runs a real window):
demo\build\bin\mingwX64\debugExecutable\demo.exe --metricsprobe :: native/fork metrics
gradlew :demo:run --args="--metrics" :: JVM/upstream metrics
set CDN_TEXT_METRICS=1 && ...demo.exe --screen=Buttons :: raw per-paragraph dump
(The JVM leg's screenshot flag is --screenshot-all=<dir>, not the native --screenshot=<file>.)
Ordered Windows work (do top-to-bottom; full detail in the numbered sections below)
-
[§1a] Confirm the tab fix - ✅ DONE (see session results above). Rendered a literal-tab string on the fork; all
\t→space, no tofu. TheSkiaParagraphEngine.shapedTextnormalization holds; no bypass path exists (2.addText(sites, bothshapedText). -
[§1b] Confirm/resolve vertical metrics - ✅ RESOLVED (feared bug DISPROVEN). Used the
--metricsprobe(native) vs--metrics(JVM) pair, NOT the screenshot diff (the JVM leg has no--screenshot=<file>; it dumps the samemetrics:table). Every paragraph HEIGHT matches JVM exactly (leading applied identically) → correct-by-reference. Only a ≤0.77px M3-baseline drift remains (halfLeading, all platforms). Separately fixed the fork's brokenLineMetrics.ascent/ descentin Kotlin. Full data in §1b. -
[§1c] Audit the fork FontMgr / gamma / ICU - ✅ AUDITED (fallback WORKS). CJK + color emoji + control chars all render with zero tofu on the fork →
FontMgr.defaultis a real system (DirectWrite) manager with working glyph fallback, NOT an empty FreeType stub. Remaining: capture the fork's REALargs.gn(SK_GAMMA_*, ICU packaging, freetype-vs-dwrite) into the fork repo so drift is auditable (doc-only, not a code blocker). -
[§5] WIN-SMOKE gate (pre-ship): (1) the §1b metrics dump, (2) tab/control-char render clean, (3) the Windows-only
PrintWindowprobe (scripts/probe/), (4)gradlew :<module>:compileCommonMainKotlinMetadata(only the Windows job compiles common metadata). -
[§5] apiDump + publish FROM Windows (host-specific; only the Windows job carries the full mingwX64 variant table). Then, when Compose 1.12.0 stable ships, do the re-pin + version bump. Release runbook: TOOLING.md.
1. Cross-platform FIDELITY (headline)
All Windows divergences live below Kotlin - the mingw source set reuses
src/skikoRendererMain/kotlin byte-for-byte (compose/ui/ui/build.gradle.kts:102-113),
the only mingw-unique file is PlatformGpu.mingw.kt (13 lines, GPU only). So every
divergence is in the fork's Skia binary, in FontMgr.default's per-OS impl, or in
the GPU backend. The fork is external (bitsycore/skiko,
com.bitsycore.skiko:skiko:0.150.1-mingw.2) and its GN args are not verifiable from this
tree - recovering them is a prerequisite for several fixes below.
Fidelity reference for Windows is skiko-on-JVM Compose Desktop on Windows (also DirectWrite), NOT macOS/Linux. macOS↔Windows metric deltas partly exist in upstream Compose Desktop too (per-platform font backends). Reframe V-items as "match JVM-Windows" acceptance tests, not "bugs vs macOS".
1a. Windows tab-character tofu - HIGH CONFIDENCE (root cause traced)
Kotlin sets ParagraphStyle.replaceTabCharacters = true
(compose/ui/ui-text/src/skikoRendererMain/kotlin/.../renderer/skia/SkiaParagraphEngine.kt:234-236,
exact mirror of upstream ParagraphBuilder.skiko.kt:625, CMP-6589). Works on
macOS/Linux (official skiko honors the flag → U+0009 replaced by space before shaping).
On Windows the same call runs against the fork's flat extern-C DLL, which evidently does
not wire the setter → raw \t reaches HarfBuzz → NotoSans has no tab glyph → .notdef
box. Font fallback is a red herring: no font has a tab glyph, only tab→space
replacement cures it.
- P0 Confirm - ✅ DONE (Windows 2026-08-04): rendered a literal-tab string on the fork
(
--screen=BasicText); every\tshows a SPACE, no.notdef. The fork renders everything else clean too, so tab→space was the only gap. Structural backstop: the module has exactly 2.addText(sites, bothshapedText- no raw tab can reach skiko. - P0 Fix (ship now, author project code): in
.../SkiaParagraphEngine.kt, feed skiko a normalized copy - addedprivate val shapedText = if (text.indexOf('\t') >= 0) text.replace('\t', ' ') else text, used ataddText(no-span path) and viashapedText.substring(segStart, segEnd)(span path);textkept for ALL length/index queries (getRectsForRange,getCursorRect,wordBoundary,lineMetrics, span cut-points). Length-preserving so every offset consumer stays correct; this is exactly what Skia's flag does internally → upstream-faithful; makes behavior platform-independent (no fork rebuild needed). KeptreplaceTabCharacters = true(harmless/redundant elsewhere). DONE - compiles clean on macosArm64. Windows tofu render still needs on-device confirmation (WIN-SMOKE §5). - P2 Long-term true fix (fork, out of tree): wire
ParagraphStyle::setReplaceTabCharactersthrough the fork's extern-C surface, rebuild/republishskiko-windows-x64.dll, bump-PskikoMingwVersion. Do this in addition to the Kotlin fix, not instead - it keeps the port thin. Can't be verified in-tree.
1b. macOS-vs-Windows vertical text metrics - ✅ RESOLVED (Windows 2026-08-04): heights match JVM, feared bug DISPROVEN
WINDOWS RESULT (2026-08-04). All targets render through the SAME Skia, so metrics are expected
to be similar - and they are. Native --metricsprobe (fork) vs JVM --metrics (upstream skiko) on
this host (NotoSans, density 1) print IDENTICAL paragraph heights and non-M3 baselines for every
size 11→24 (cell/one/three/oneM3/threeM3, base1/base3 all match). The fork does NOT
drop lineHeight leading - the hypothesis below is disproven. Two small deltas remained, both
handled in SHARED Kotlin (no fork rebuild, keeping fork-reliance minimal):
- Fork
LineMetrics.ascent/descentmis-decode → FIXED in Kotlin (this session).CDN_TEXT_METRICSshowed the fork's per-lineascent/descentconstant (18.837/5.163) regardless offontPx, whilebaseline/height/widthare correct (mac scales them: 37.674@30px). This is the fork's flat extern-C LineMetrics binding, NOT a Skia difference. Lines stack contiguously so true ascent =baseline - lineTop;SkiaParagraphOps.lineMetrics()now rebuilds ascent/descent from the reliable baseline+height when they're internally inconsistent (guard|ascent-(baseline-lineTop)|>0.5= no-op on official skiko). Repairs caret height / getLineTop/Bottom / vertical hit-test on Windows. Behavioral caret check → WIN-SMOKE. - M3 baseline drift ≤0.77px (halfLeading) - SHARED-engine omission, all platforms.
base1M3differs native-vs-JVM by ≤0.77px in the 11→24 sweep (size 14 → 0.28px, sub-pixel), 2.19px in the contrivedboundary 24/25case. Root cause is NOT the fork:SkiaParagraphEnginemapsLineHeightStyle.trim→HeightMode but never wireshalfLeading/LineHeightStyle.Alignment, so macOS-native drifts from JVM by the same amount (parity tolerated it as sub-pixel). Fixable in shared Kotlin (textStyle.halfLeadingfrom the alignment); deferred pending a cross-platform parity re-check (can't run mac parity from a Windows host).
Native-fork metricsprobe vs JVM --metrics (this host) - identical except base?M3:
size=14 lh=20 cell=19 one=19 three=59 base1=14.898 base3=54.898 oneM3=20 threeM3=60 base1M3: 15.682 fork / 15.398 jvm
size=24 lh=30 cell=33 one=33 three=93 base1=25.968 base3=85.968 oneM3=30 threeM3=90 base1M3: 23.546 fork / 24.312 jvm
boundary 24/25 m3=25 base: 19.622 fork / 21.812 jvm
The vertical-layout code path is identical on all targets. The lineHeight/HeightMode
path is a byte-faithful port of upstream and is NOT the cause: m3 labelLarge has
lineHeight 20sp > fontSize 14sp with LineHeightStyle.Default.trim == Both →
HeightMode.DISABLE_ALL, which collapses the single-line box to fontAscent + fontDescent
of the font itself (SkiaParagraphEngine.kt:244-249, matches upstream
ParagraphBuilder.skiko.kt:631-646). So box height is driven entirely by the font's
reported ascent/descent.
Root cause: FontMgr.default resolves to a different Skia font scaler per build for
the same font/NotoSans.ttf bytes (SkiaFonts.kt:38,44,50) - CoreText on macOS,
fontconfig/FreeType on Linux, DirectWrite (or FreeType, unverified) in the fork on Windows.
Different backends select different metric tables (hhea ascender/descender vs OS/2
sTypoAscender/Descender vs usWinAscent/Descent) and honor the OS/2 USE_TYPO_METRICS
fs_selection bit differently. NotoSans is the textbook case (large top-heavy ascent).
SkiaParagraphEngine.kt:147-148 read -defaultFont.metrics.ascent/.descent;
:150-169 lineMetrics() forwards skiko's numbers verbatim; SkiaParagraph.native.kt:116,148-153
consume them. Contributors V2 (hinting Normal on Windows grid-fits vertically, CoreText
ignores it) and V3 (subpixel/edging) are secondary and per-scaler.
Hypothesis, not confirmed: whether Windows-native actually diverges from Windows-JVM. If it matches JVM-Windows it's correct-by-reference (not a bug), and the macOS↔Windows delta is expected upstream behavior.
-
P0 Confirm FIRST - ✅ RESOLVED (Windows 2026-08-04; heights == JVM, see WINDOWS RESULT above). Diagnostic LANDED:
SkiaParagraphEngine.ktdumpsdefaultFont.metrics.{ascent,descent,leading},paragraph.height,lineMetrics[0].{ascent,descent,baseline,height},familyName+fontPxper built paragraph whenCDN_TEXT_METRICS=1(mirrors theCDN_PROFILEenv-flag convention). RunCDN_TEXT_METRICS=1 <app> --screen=Buttons(native) and the JVM parity app on the SAME host, diff the lines. macOS baseline captured (see below). REMAINING (Windows host): compare Windows-native vs skiko-JVM-Windows. Expectation:fontPx+ lineHeight + heightMode identical,metrics.ascentdiffers by scaler. Acceptance test: Windows-native == skiko-JVM-Windows (→ correct-by-reference, not a bug); if they differ, it's a fork scaler fix (next item).**macOS baseline captured (`--screen=Buttons`, DPR 2):** for the fontPx=30 button labels ("Filled Button" / "OutlinedButton" / "TextButton"): font metrics `ascent=-32.07 descent=8.79 leading=0` (font box = **40.86px**), but the line box is `line0[ascent=37.67 descent=10.33 height=48.0]`, `paraHeight=48`. So macOS DOES apply the lineHeight leading (48 > 40.86) and distributes it **asymmetrically - ~5.6px above, ~1.5px below** → exactly the "extra space on top of the text" the report describes. **SHARPENED HYPOTHESIS:** this is NOT a mac bug - it's correct lineHeight distribution. The likely Windows story is the MIRROR of §1a: the fork's flat extern-C surface doesn't wire the `ParagraphStyle` height/strut path, so Windows renders **tight to the font box (~41px, no leading)** = the "fitted to text" look. **Sharpened WIN-SMOKE check:** for the SAME label, compare `paraHeight` + `line0.height`. If macOS/JVM = 48 but Windows-native ≈ 41, the fork drops lineHeight leading (fork fix: wire `heightMode`/strut, like `replaceTabCharacters`); a Kotlin fallback is a manual `StrutStyle`. If Windows-native == Windows-JVM, it's correct-by-reference. **macOS baseline raw lines** (`CDN_TEXT_METRICS=1 demo --screen=Buttons`, DPR 2) - diff Windows-native + Windows-JVM against these; the fontPx=30 rows are the cleanest signal: ``` text='Filled Button' fontPx=30.0 ascent=-32.070007 descent=8.789978 leading=0.0 paraHeight=48.0 line0[ascent=37.674 descent=10.326 baseline=37.674 height=48.0] text='OutlinedButton' fontPx=30.0 ascent=-32.070007 descent=8.789978 leading=0.0 paraHeight=48.0 line0[ascent=37.674 descent=10.326 baseline=37.674 height=48.0] text='TextButton' fontPx=30.0 ascent=-32.070007 descent=8.789978 leading=0.0 paraHeight=48.0 line0[ascent=37.674 descent=10.326 baseline=37.674 height=48.0] text='Outlined' fontPx=28.0 ascent=-29.932007 descent=8.203980 leading=0.0 paraHeight=40.0 line0[ascent=31.395 descent=8.605 baseline=31.364 height=40.0] ``` (family='Noto Sans' on all.) Expectation if correct: `fontPx`, `paraHeight`, `line0.height` match JVM-Windows exactly; the raw `ascent`/`descent` may differ from these macOS numbers because the Windows scaler is FreeType (§1c), but the LINE BOX must still match JVM-Windows. -
P1
If Windows-native != Windows-JVM: fix in the fork- N/A (2026-08-04): heights MATCH JVM-Windows exactly, so no fork scaler/FontMgr change is needed for vertical metrics. ThemakeFromDatatypefaces already get the same metric-table selection as skiko-JVM-Windows. The one residual (M3 halfLeading) is a shared-Kotlin fix, not a fork one. -
P2 (Optional, STRONGER than upstream) If cross-platform pixel-identity is wanted over matching each host's stock Compose Desktop: build a single FreeType/
SkFontMgr_Custom_Empty-backedSkFontMgrused on ALL native targets for the bundled fonts, so NotoSans yields identical ascent/descent everywhere. Intentional departure from per-platform upstream behavior - decide deliberately. -
DO NOT "fix" by injecting a default
LineHeightStyleor clamping ascent inSkiaParagraphEngine.kt- that desyncs from upstreamDISABLE_ALLand masks, not resolves, the metric divergence.
1c. Other fork-vs-official divergences
- P1
- DISPROVEN (Windows 2026-08-04). Native render of CJK (你好世界 日本語 한국어) + full-COLOR emoji (😀🎉🚀❤) + control chars = zero tofu. Color emoji + CJK fallback are impossible with an empty custom FreeType manager, so the fork'sFontMgr.defaultmay be an empty/stub manager on the forkFontMgr.defaultIS a real DirectWrite-backed system manager with working glyph enumeration/fallback. No fork change needed.SkiaFonts.ktwiring stays as-is. - P1
FontMgrWithFallback- ANALYZED + fallback now VERIFIED on Windows. The port doessetDefaultFontManager(fontMgr)[system] +setAssetFontManager(provider)[bundled] (SkiaFonts.kt:49-52). This is functionally equivalent for missing-glyph fallback on macOS/Linux/Windows-JVM: skiko's shaper tries the asset provider (bundled Noto + aliases) first, then the default/system manager for CJK/emoji fallback, which resolves because the system FontMgr has those fonts. Rewriting tosetDefaultFontManager(FontMgrWithFallback(provider))risks a macOS fallback regression with no observable gain, and I can't visually verify CJK/emoji fallback on this host. The real gap was thought to be Windows-fork-only (forkFontMgr.defaultempty stub → no system fallback) - but that's DISPROVEN (2026-08-04): CJK + color-emoji fallback works on native Windows too (item above). So the setDefault/setAsset wiring is fine on ALL targets. Decision: leave the Kotlin as-is; nothing to fix in the fork here. Re-open only if a CJK/emoji fallback bug is actually observed. - P2
ICU/unicode data packaged differently in the fork- VERIFIED OK (2026-08-04). Rendered U+00A0 (nbsp → visible space), U+200B (zwsp → zero-width/collapsed), en-dash, and U+0009 (tab, pre-replacement) on native Windows: all classified/handled correctly, no tofu. The force-exporteduloc_*_skikosymbols do their job; skunicode classification works. (This is also why HarfBuzz shaping + skparagraph bidi work at all - ICU data is present via icudtl.dat.) - [~] P2 Text gamma/AA edges: a Skia build-time constant
(
SK_GAMMA_EXPONENT/SK_GAMMA_CONTRAST/SK_GAMMA_APPLY_TO_A8), NOT a GL-vs-Metal runtime difference. Note (2026-08-04): native Windows text AA looked clean in the render (no visible AA anomaly), so no fork gamma fix is pursued for 1.0. Still worth capturing the fork's values in the fork repo (see the args.gn item below). Kotlin-side rasterization settled: theRASTER_EDGING/HINTING/SUBPIXELinSkiaParagraphEnginecome fromFontRasterizationSettings.PlatformDefault, which was verified (2026-08-04) to reproduce upstream Compose Desktop's per-OS defaults VERBATIM (Win/mac=Normal hinting, Linux=Slight). User decision: keep the upstream-faithful per-OS behavior (match each platform's stock CMP), NOT a forced uniform cross-platform look. So there is no SDL_TTF-era gamma hack left to strip - Skia is configured exactly as upstream does it. - GL-vs-Metal AA/gamma/color-space - RULED OUT as a primary cause. Both bridges
create the surface
colorSpace = null(SkiaGLBridge.kt:68-74,SkiaMetalBridge.kt:133-139) - identical un-color-managed legacy blending. Only deltas are cosmetic-correct:RGBA_8888/BOTTOM_LEFT(GL) vsBGRA_8888/TOP_LEFT(Metal) - channel order + Y-flip, matched to buffers. Windows uses OpenGL (PlatformGpu.kt:20), same backend as Linux, so GL-vs-Metal can't explain a Windows-vs-Linux gap at all. Deprioritized - no action. - [~] P1 The fork's Skia build config (GN args). Lead extracted from git history
(the old
SKIKO-MINGW-FEASIBILITY.md, deleted as stale - its build recipe lives ingit show bdb5c64d^:SKIKO-MINGW-FEASIBILITY.md): the fork's Route-1a recipe builds Skia withskia_use_freetype, i.e. the Windows text scaler is FreeType, not DirectWrite and not macOS CoreText - a concrete reason the SAME NotoSans bytes yield different ascent/descent on Windows (different metric-table selection) AND why forkFontMgr.defaultmay not enumerate Windows system fonts for glyph fallback (FreeType has no system fontmgr without fontconfig). CAVEAT: that's the recommended recipe, not a verified dump of the shipped0.150.1-mingw.2GN args. REMAINING: whoever rebuilds the fork should capture the realargs.gn(SK_GAMMA_*, ICU packaging, freetype-vs-dwrite) alongside the fork sources so drift is auditable - NOT as a doc in this repo (kept lean); a comment in the fork repo orbuild-sdl.properties-style pin.
2. VENDORING & upstream-fidelity debt
Vendor hygiene is release-ready: check-vendor-drift.py reports all 10 manual vendors
match pin v1.12.0-beta03+dev4483; zero commonMain rule-1 violations (the 3 authored
androidx.compose.* files in commonMain are all provenance-tracked Rule-3 vendors). The
macosMain DIAGNOSTIC GAP families (ui/ui 12, foundation 23: CoreTextField.macos.kt,
SelectionManager.macos.kt, PlatformClipboard.macos.kt, …) are correctly excluded
(AppKit/NSView; the port uses SDL) - keep them in gaps. Do not spend 1.0.0 effort
re-vendoring. The debt is completing native-actual stubs.
P0 - blocks fidelity/correctness
- [~] P0 Text context menu (right-click copy/paste/select-all) - RECONCILED: already
works, no reimplementation. Static trace confirmed the menu is wired end-to-end
through the vendored LEGACY path: text-level
ContextMenuArea(ContextMenu.native.kt) →CommonContextMenuArea(vendor/common/.../text/CommonContextMenuArea.kt) → (nativeComposeFoundationFlags.isNewContextMenuEnabled = false, so the legacy branch) →contextmenu.ContextMenuArea→contextMenuGestures/onRightClickDown(isSecondaryPressed) →ContextMenuPopup→Popup.native.kt(hosted byLocalPopupHostat the app root). Items are real:TextFieldSelectionManager/SelectionManager.contextMenuBuilderemit Cut/Copy/Paste/SelectAll with real actions. SDL right-click →PointerButton.Secondary(AppEvent.kt) →isSecondaryPressed(PointerEventBridge). The three "NOP" seams belong to the DISABLED new context-menu API and are unreachable - DONE: their misleadingTODO(CMP-7819)comments corrected to say so, in all three files. REMAINING: an interactive right-click smoke check (no headless driver here - fold into manual/WIN-SMOKE). Known limitation, not a breakage: Paste enablement is plain-text-only (ClipboardPasteState.hasClip = hasText, see P2 below). - P1 DatePicker/TimePicker localization - VERIFIED current state:
material3/.../internal/PlatformDateFormat.native.ktis a real kotlinx-datetime formatter that DOES honor CLDR patterns/skeletons (renders "Jul 29, 2026" / "July 2026" correctly - the agent's "always yyyy-MM-dd" finding was stale). Genuine remaining gaps, all narrower than a correctness blocker: field NAMES English-only (:29 weekdayNames,:147-154 MONTH_NAMES/ABBR- no CLDR data bundled),:27 firstDayOfWeek=1fixed Sunday,:67 is24HourFormat()=truefixed,:46 parse()ISO-8601-only. UpstreamdarwinMainusesNSDateFormatter(not portable). Fix (author): bundle a CLDR subset or K/N i18n lib for localized names + locale-aware first-day/24h; also unblocksCalendarLocale.native.kt:20(fixed"en"). Ships readable English dates today - polish, not a P0 gate. - P0 Float pointer coordinates -
AppEvent.kt.toInt()-truncated SDL's Floatmb.x/mb.y/mm.x/mm.yand the wheelmouse_x/ybefore the DPR multiply inComposeWindow.kt. On 2× displays a click at logical 100.9 → physical 200 not ~201, quantizing the caret to 2px steps near glyph edges. DONE: widenedLegacyPointerEvent.x/yandAppEvent.MouseWheel.x/ytoFloat, dropped the.toInt()at the three mapper construction sites, and removed the now-redundant.toFloat()at the twoComposeWindowread sites. Consumers fully contained (verified by grep). Builds + runs clean on macosArm64.
P1 - quality / parity
- P1 RTL text unsupported -
ui-text/.../SkiaParagraph.native.kt:112textDirection = ResolvedTextDirection.Ltrfixed field;getParagraphDirection()(:202) always Ltr. Bidi run direction (:204) does read box direction (partial machinery). Root cause:text/intl/Locale.native.kt isRtl()unimplemented. Fix (author) tied to P1.2 engine. - P1
PlatformFontLoaderNOP -ui-text/.../font/FontFamilyResolver.native.ktSdlPlatformFontLoader.loadBlocking/awaitLoadNOP;PlatformFontFamilyTypefaceAdapter.resolvereturnsImmutable(Unit). Raw androidxFont(bytes)/ResourceFontdon't load (compose-resourcesFont()works). UpstreamSkiaFontLoader.skiko.ktis the vendorable reference. This is the long-deferred P3.2 font resolver - high-risk L, parity harness wouldn't exercise it. Scope deliberately for 1.0.0 (accept-and-document vs attempt). NOW ALSO GATES §6 - the 2026-08-04 spike found the verbatim engine-vendor routes font resolution through this NOP, so implementing this loader (soSkiaFonts' icon/variable-axis model rides upstream's seam) and the §6 engine-vendor are ONE effort. Do them together, with macOS/Linux verification. - P1
CharHelpers.skiko.ktgrapheme-break logic - the ONE cheap selective-vendor win from the hand-rolled text engine. FixesfindPrecedingBreak/findFollowingBreaksplitting emoji/combining marks inStringHelpers.native.kt:31+CharHelpers.native.kt:14. (Giving:foundationaskikoRenderersource set would unblock this +DragAndDropSource.skiko- L-effort structural move, per §13a.) - [~] P1
Serif/Cursivegeneric families - RE-SCOPED after readingSkiaFonts.kt. They do NOT silently collapse to sans:baseTypeface()routesgeneric:serif/generic:cursivethroughresolveGeneric()→fontMgr.matchFamilyStyle()over a per-OS candidate list (genericFamilyAliases: Times/Noto Serif/… on each host), only falling back to the bundled default if NONE is installed. So serif/cursive already render as a real serif/cursive wherever the OS ships one (all three target OSes do). The only true gap is cross-platform pixel-identity / a host with no serif installed, which needs a BUNDLED Noto Serif - an app-level opt-in (buildSrcdownloadNotoFonts+ data.kres size cost), not a library correctness bug. Decision: leave library resolution as-is; offer bundled-serif as an opt-in later if an app needs it. - P1 Document the hand-rolled text engine as an accepted architectural
deviation - DONE: rationale header added to the top of
ui-text/compose-fork.txt(the exact place someone would go to "fill" the gaps), explaining the reduced local engine, the flat-source-set reason, and that RTL / stroke-DrawStyle / grapheme reductions are tracked here (§2), not fixed by vendoring. Also reflected inSkiaParagraphEngine.kt's header and CLAUDE.md's renderer summary.
P1/P2 - reduced-coverage + accessibility
- P1 Text paint path handles only
SolidColor(SkiaParagraph.native.kt:253-256) - no brush/gradient text fill,drawStyle(stroke), or non-SrcOverblendMode. Part of the reduced style coverage; extend on the local engine. - P1
SkiaParagraphEngine.kt:150-164span segmentation drops partialSpanStyleoverlaps (keeps only fully-coveringstart<=segStart && end>=segEnd). Documented divergence (b63-upstream-text-mingw.md) - widen to true interval segmentation. - [~] P1 Accessibility absent -
ComposeOwner.kt:~340builds asemanticsOwnernever traversed to any OS a11y API;SemanticsRegion.native.kt intersect()/difference()hardcodefalse. Not vendorable (NSAccessibility/UIA/AT-SPI). Decision for 1.0.0: out of scope for desktop. The full a11y pipeline stays P2/out. - [x] P0 No-opPlatformScreenReader-CompositionLocals.native.ktLocalPlatformScreenReaderdefault no longer throws (error(...)); it returns anInactivePlatformScreenReader(isActive = false) so a11y-gated vendored code degrades to "no reader present" instead of crashing. DONE - builds + runs clean. - P2
SkiaFonts.ktfidelity:resolveCacheunbounded (font-cache half of P3.4 deferred); variation key passesnulldensity (:65,78) so density-dependentopszin sp resolves wrong.NamedFont.equals/hashCodeaxis identity reportedly fixed - re-verify.
P2 - cosmetic / edge
- P2
BlendMode.Multiplyrenders wrong - opaque cyan×yellow reads back blue; likely Metal premultiply in graphics-layer flatten, not thetoSkia()map. Isolate on the Skia draw path. - P2 Drag-OUT of window NOP -
ui/.../draganddrop/Sdl3DragAndDropOwner.kt:39requestDragAndDropTransfer;DragAndDropSource.native.kt:36no drag-shadow. SDL3 has no portable start-drag → needs NSDraggingSession/DoDragDrop/XDND per-OS. Drop-INTO works. Accepted 1.0 gap (SDL platform limit) - document. - P2
NativeStringDelegate.native.kt:17toUpper/toLowerCaseignorelocale(Turkish i). Upstreamdarwinunvendorable (NSString). - P2 Prefetch scheduler NOP -
platform/PrefetchLocals.native.kt:18, lazy lists skip ahead-of-time composition (scroll-in jank). - P2
ClipboardPasteState.hasClipaliased tohasText(TextFieldSelectionState.native.kt:43) - image-only clipboard undetected. - P2
ComposeOwner.ktgaps:autofill/autofillManagernull (:324),hapticFeedBackNOP (:279), deprecatedclipboardManagerNOP (:287, realLocalClipboardworks),textToolbarstub (:311, touch-gated). No multi-monitor enumeration (SDL_GetDisplaysunused). Most match upstream desktop - verify, then leave or fill. - P2
installGlobals()→registerGenericFonts(): VERIFIED not a hot-path cost -registerGenericFonts()already early-returns on afRegisteredflag (GenericFonts.kt:20-26), so the per-event call is a single boolean check, not a re-register. Original premise inaccurate; no change needed. (Minor residue: theImeBridge.onSessionActiveChangelambda is reassigned per event - negligible, left as-is since it repoints per active window.)
3. SDL slimming
Static lib built from source per host (scripts/build-sdl/build-all.py, ref
release-3.4.12); linked into the exe via sdl3.def (staticLibraries=libSDL3.a). Today
only tests/examples + Windows D3D12/GPU are off (build-all.py:311-319). SDL_Init uses
SDL_INIT_VIDEO only (Sdl3Backend.kt:49).
USED (keep ON): Video/window, Events, Clipboard, Dialog (file open/save - no cheaper
substitute; Linux uses portal/zenity, keep deps), OpenGL + Metal contexts, Render (2D CPU-
raster blit path: SkiaSurfaceBridge uploads to SDL_Texture + SDL_RenderPresent),
Filesystem, Cursor, Locale, System theme, Text input/IME, OpenURL, timing/hints.
UNUSED (disable - zero references, no transitive need): Audio, Joystick (→disables Gamepad), Haptic, HIDAPI, Sensor, Power, Camera, GPU API, Offscreen video driver, virtual joystick.
- P1 Added to
build-all.pysharedvExtra:-DSDL_AUDIO=OFF,-DSDL_JOYSTICK=OFF,-DSDL_HAPTIC=OFF,-DSDL_HIDAPI=OFF,-DSDL_SENSOR=OFF,-DSDL_POWER=OFF,-DSDL_CAMERA=OFF,-DSDL_GPU=OFF,-DSDL_OFFSCREEN=OFF,-DSDL_VIRTUAL_JOYSTICK=OFF. Promoted-DSDL_GPU=OFFfrom the Windows-only branch to the shared list; kept the per-host-DSDL_RENDER_D3D12=OFF(Windows). (Skipped-DSDL_DISABLE_INSTALL_DOCS- not a real SDL3 option; the build'scmake --installis relied upon.) All ten confirmed zero-reference by grep before disabling. - [~] P1 After the flag change, rebuild + run apps to confirm no regression.
DONE on macOS/Metal:
build-all.pyrebuilt libSDL3.a clean with the new flags;:demolinks + runs to a settled screenshot (TextField screen, no visual regression);:apidemolinks clean too. PENDING: both apps on Linux + Windows hosts (fold into WIN-SMOKE §5). If audio/gamepad is ever needed by a consumer app, re-enable the flag and rebuild the static lib (build-time only). - P2 Prune now-unreferenced macOS frameworks from
sdl3.def. DONE: verified against the regeneratedlibs/SDL3/lib/pkgconfig/sdl3.pcLibs:line (source of truth), which after slimming no longer referencesCoreAudio,AudioToolbox,AVFoundation,GameController,ForceFeedback, or weakCoreHaptics- removed all six fromlinkerOpts.osx(keptCoreMedia, which the.pcstill lists). Demo relinks clean. (Left LinuxlinkerOptsalone - those-lentries serve Skia/GL/X11, not the disabled SDL subsystems.) - P2 (higher-risk, flag-don't-apply) Render-driver pruning to software-only. Only
the CPU-raster fallback uses
SDL_Render; GL/Metal go direct. ButSDL_CreateRenderer(window, null)(Sdl3Backend.kt:117) lets SDL pick the first driver - with only software present it picks software (fine) but couples the fallback to that assumption. Conservative: leave render drivers alone.
Do NOT add -DSDL_DISABLE_INSTALL - the build relies on cmake --install
(build-all.py:342) to stage libSDL3.a + headers. SDL_DYNAMIC_API auto-disables for
static builds (no flag needed). Risk: a future consumer app needing SDL audio/gamepad
requires a static-lib rebuild (build-time, not a code change).
4. Performance
Prior PLAN Phases 0–3 landed; §12 measurement showed no steady-state perf gap on macOS/Metal. Remaining perf question (if any) is Windows-GL or heavy-interaction specific. Retained-layer engine is byte-for-byte upstream - not the gap.
- P2 WON'T-FIX confirmed, keep as-is (don't relitigate): P0.4 per-frame full-surface
clear + P0.6 per-frame Metal drawable reacquire (
CAMetalDrawablesingle-use per frame, matches upstreamMetalRedrawer). - P2 P2.2 upstream
GlobalSnapshotManagerinvalidation-driven scheduling - deferred; coalescing half done by P1.3; full manager is skiko-windowing-coupled (an explicit renderer non-goal - skiko-windowing-coupled). Leave deferred unless a Windows-GL perf gap surfaces. - P2
SkiaImageCachefont-cache half of P3.4 - evicted typefaces stay referenced by live paragraphs +TypefaceFontProvider(no clean unregister) so eviction frees no memory. Native-resource lifecycle backstop is the periodicGC.collect()nudge (top- levelParagrapha liveTextholds has no Compose dispose seam). Accept for 1.0 or add a provider-unregister path. - P2 P4.1 second-renderer manual-vendor reversal (restore upstream RenderNode
shadows via
SkiaGraphicsContext.setLightingInfo+ relocateprepareTransformationMatrix; deltas D2–D6). Buys vendoring cleanliness at shadow-lighting/hit-test regression risk (currentNativeShadowCanvasshadows render, draw=0.08ms). Low ROI - deferred, needs full verify-mac + parity. - P2 Profile the shipped Windows-GL binary (
CDN_PROFILE=1) under heavy interaction before ship (present phase is vsync-capped by display refresh - profile on the target monitor). Only open perf unknown.
5. API coverage / release mechanics
API coverage vs upstream is 99% (8665/8751 decls via compose-coverage.py). Misses are
AppKit/UIKit/web host actuals (N/A), ImageComposeScene/renderComposeScene (test util),
and the ui-text.platform font layer (= P3.2 font resolver). The 1076 "extra" decls are
umbrella-repo modules the tool can't compare + version skew, not invented surface.
- P0 Re-pin refs to Compose 1.12.0 stable when it ships (currently
v1.12.0-beta03+dev4483- no clean beta03 tag on Maven). Bump bothscripts/compose-fork/compose.propertiesrefs, re-sync (scripts/compose-fork/sync.sh), let the build surface breakage. - P0 Bump
vComposeJvmVersionin:demo/:apidemo/:material-symbols(JVM parity leg currently forced to beta02 by documented skew - native leads). - P1 Run
check-vendor-drift.pyafter the ref bump - re-stampVENDOR-BASEon the 10 manual vendors, hand-reconcile any that actually changed base..pin. - [~] P0 WIN-SMOKE fidelity pass (Windows host only, pre-ship gate): the Mac runbook
cannot cover the shipped mingwX64 binary. Assert: (1) NotoSans
FontMetricsdump native-vs-JVM-Windows (§1b acceptance) - ✅ DONE 2026-08-04 (--metricsprobe==--metrics); (2)\t/control-char render clean (§1a) - ✅ DONE; (3) the Windows-onlyPrintWindowprobe - pending (interactive); (4) the common-metadata publish job - compile gate now ✅ GREEN (2026-08-04, see the fix below); the actual publish + downstream-consume smoke still owed. Remaining: (3)PrintWindowprobe + the interactive caret-height check for the §1b LineMetrics fix. - P0 apiDump is host-specific - do NOT commit macOS dumps. Only the Windows
publish job compiles common metadata (owns the root KotlinMultiplatform publications
- the only host declaring every target, so only its
.modulefiles carry the full variant table; macOS-published roots left v0.1.15 without mingwX64 variants). Testgradlew :<module>:compileCommonMainKotlinMetadatabefore tagging; publish from Windows. - P0 FIXED (2026-08-04) - common-metadata compile is now GREEN (
compileCommonMainKotlin Metadataacross all modules → BUILD SUCCESSFUL). Was RED, blocking the Windows publish. Running:material3:compileCommonMainKotlinMetadata(or the aggregate) had failed at:foundation:compileNativeMainKotlinMetadata(pulled in via:foundation:allMetadataJar):Scrollbar.skiko.kt+v2/Scrollbar.skiko.kt-Declaration annotated with '@OptionalExpectation' can only be used in common module sources(OPTIONAL_DECLARATION_USAGE_IN_NON_COMMON_SOURCE). Root cause: upstream keeps these.skiko.ktfiles inskikoMain, which INCLUDES the JVM-desktop target, so@JvmName/@JvmStatic/@file:JvmNameresolve; the port vendors them into native-onlynativeMain(no JVM target under it), so those JVM annotations are orphaned and the metadata compile rejects them (per-target compile tolerates them - that's why the APPS build fine, but the publish'sallMetadataJarrecompiles nativeMain metadata and dies).:uiand below pass;:foundationis the first break. Blast radius: 14 vendored native files use@Jvm*(grep -rE "@file:JvmName|@JvmName|@JvmStatic" .../src/vendor/native .../src/nativeMain); 11 are in the GITIGNORED, sync-regeneratedsrc/vendor/native/tree, so per-file edits DON'T survive a re-sync. Fix (build/sync-level, not per-file): either (a) async.shpost-step that injects@file:Suppress("OPTIONAL_DECLARATION_USAGE_IN_NON_COMMON_SOURCE")(or strips@Jvm*, which are no-ops with no JVM library target) into vendored native files - there's precedent:components-resources/…/ResourceState.blocking.ktalready carries that suppress; or (b) a KGP-level relaxation of the native metadata compilation. Needs a real publish + downstream-consume verification after. Dedicated effort - likely THE reason the Windows metadata publish never went green. FIX APPLIED (2026-08-04): the root turned out narrower - sync.py ALREADY injects the K2@file:Suppress("OPTIONAL_DECLARATION_USAGE_IN_NON_COMMON_SOURCE", "LESS_VISIBLE_TYPE_ACCESS_IN_INLINE")into everysrc/vendor/.kt (sync.py:313), so the regenerated tree was fine. The only gap was the two COMMITTED manual vendors insrc/nativeMainthat sync doesn't regenerate:foundation/…/Scrollbar.skiko.kt+v2/Scrollbar.skiko.kt. Added the identical@file:Suppressto both (provenance comment updated;check-vendor-driftstill clean). FullcompileCommonMainKotlinMetadatanow green. Remaining before publish: run the actual publish + a downstream-consume smoke (the compile gate is necessary, not proof the.modulevariant table is complete). - P1 Version bump to
1.0.0across published coords once the above are green. - P0 Doc-hygiene blocker:
CLAUDE.mddocumentation map referencedRENDERER.md,SKIKO-MINGW-FEASIBILITY.md, andTODO.md. DONE: removedRENDERER.md+SKIKO-MINGW-FEASIBILITY.md(stale/historical - current renderer essentials inlined in CLAUDE.md's doc map, the rest recoverable from git history); fixed theCLAUDE.mdline-1eantypo; dropped theTODO.mdlink (never committed) → its content is PLAN.md §2.git grepconfirms no remaining links to any deleted doc.
6. Vendor the upstream skiko text engine (next major refactor - approved 2026-08-04)
Goal: honour "VENDOR ALL WE CAN". Delete the hand-rolled reduced text engine
(SkiaParagraph.native.kt + NativeParagraphOps seam + SkiaParagraphEngine.kt +
FontRasterizationSettings.native.kt port) and vendor upstream's skiko text subsystem VERBATIM.
Why it was hand-rolled (the REAL reason, not SDL_TTF): Paragraph is expect sealed interface
(vendor/common/…/Paragraph.kt:56) whose actual sits in the skiko-FREE nativeMain, so a
sealed implementer can't touch skiko → the hand seam. nativeMain is skiko-free because it's the
shared parent of the official-skiko (mac/linux, libs.skiko) and fork-skiko (mingw,
com.bitsycore.skiko:skiko) legs - two different artifacts, so the shared parent can carry neither.
Why it's feasible NOW (all-skiko + srcDir reuse): every native leg already reaches
src/skikoRendererMain/kotlin (mingw via kotlin.srcDir(...) in skikoRendererMingwSharedMain,
ui-text/build.gradle.kts:54), and the fork exposes the SAME org.jetbrains.skia.* Kotlin API. So
the Paragraph actual + engine can move DOWN into the skiko source set (served to all targets)
instead of skiko-free nativeMain - which lets upstream's skiko files compile as-is.
⚠️ SPIKE FINDING (2026-08-04) - the real gate is the FONT LOADER, not the source-set move. A read-only feasibility map (no code changed) found two layers:
- Layer 1 - source-set / sealed move: FEASIBLE. The
actual sealed interface Paragraph(vendor/native/Paragraph.native.kt:36, signature-only mirror) + its implementer + the 11 factory actuals can move toskikoRendererMain(all leaves reach it; sealed same-source-set holds). Just mechanical. - Layer 2 - font model: THIS is the blocker, and it's the SAME task as §2-P1
PlatformFontLoader. Upstream's engine (SkiaParagraphIntrinsics.skiko.kt:38,61) resolves typefaces throughFontFamily.Resolver→PlatformFontLoader. The port'sSdlPlatformFontLoader(font/FontFamilyResolver.native.kt:30-49) is a NOP (loadBlocking/awaitLoad→Unit,resolve→Immutable(Unit)). The port's hand-rolled engine exists precisely to BYPASS that NOP by resolving throughSkiaFonts(icon fonts + variable axes + data.kres bytes). So vendoring the upstream engine verbatim routes font resolution through the NOP → all text goes blank/tofu until the loader is real. - Consequence: the verbatim engine-vendor REQUIRES first implementing upstream's font-loader path
(vendor
SkiaFontLoader.skiko.ktas the reference) so the port'sSkiaFontsicon/variable-axis model rides upstream'sPlatformFontLoader/FontLoadResultseam. This is the deferred high-risk P1 in §2 - it and this §6 are one effort. Intermediate option: vendor the engine files but keepSkiaFontsvia a Rule-3 edit at the one resolve call site (vendors most files verbatim, 1–2 stay hand-reconciled). Verification needs macOS + Linux hosts (parity), so this is a dedicated effort, not a Windows-only session - do it with mac access, on its own branch.
Plan (spike on a branch, verify on THIS Windows host - build + render mingwX64):
- Move the
Paragraph/ParagraphIntrinsicsactuals intoskikoRendererMain(reused by mingw via the existingsrcDir). Confirm theexpect sealedactual is accepted there for every leaf target (the b63 memo hit a sealed-in-nativeMainwall - test whether hosting the actual in the skiko set clears it; the sealed same-module rule is per-target-compilation, which should hold). - Vendor VERBATIM into
src/vendor/skikoRenderer/:SkiaParagraph.skiko.kt,ParagraphBuilder.skiko.kt,SkiaParagraphIntrinsics.skiko.kt,SkiaTextPaint.skiko.kt,ParagraphLayouter.skiko.kt,PlatformFont.skiko.kt,FontRasterizationSettings.skiko.kt,TextStyle.skiko.kt; add them toui-text/compose-fork.txt; delete the hand-rolled files. - Reconcile the ~7 hierarchy-crossing
expect/actualpairs the inverted layout needed; keep theSkiaFontsfamily/variable-axis model (it's the port's real value-add) behind upstream's seams. - Fork-only Rule-3 edit: upstream's code calls
paragraph.lineMetrics- the fork's extern-C binding mis-decodes ascent/descent (§1b finding). Either carry the guarded reconstruction (fromSkiaParagraphOps.lineMetrics()) into the vendoredSkiaParagraph.skiko.ktas a Rule-3 edit, or fix it in the fork DLL. Bonus: vendoringParagraphBuilder.skiko.ktverbatim wireshalfLeading→ the §1b M3-baseline drift disappears for free. - Verify: mingwX64 build +
--screen=BasicText/Buttons/AnnotatedStringrender clean +--metricsprobe== JVM--metrics. Then macOS/Linux verify + parity (needs those hosts).
Sequencing: AFTER the current shared-Kotlin fixes land (this session). If the sealed-actual move
proves infeasible, fall back to keeping the seam but at least converting the hand files to tracked
Rule-3 vendors (like FontRasterizationSettings.native.kt now has a VENDOR-BASE line).
7c. demo --multiwintest segfault on mingwX64 - ✅ FIXED
The multi-window probe exited 139 with no output, so it had silently been
asserting nothing. Root cause: an OpenGL context is per-WINDOW but "current" is
per-THREAD, and SDL_GL_MakeCurrent ran only once at creation
(Sdl3Backend.init) with nothing re-binding it per frame. With two windows open,
whichever initialised last owned the thread - so window A issued its draw and
swap against window B's context (silent corruption while both lived), and
destroying B left the current context dangling, faulting in present().
Fixed in SkiaGLBridge by binding the window's own context at every entry point
that touches GL: init, ensureSize (before its unchanged-size early return -
this is the per-frame bind that covers the draw phase), present, and destroy
(so a window frees its GPU objects against its own context). Purely additive, 29
lines.
Single-window rendering is bit-identical before and after (same mean RGB
[46.6 44.4 51.5] and 167 distinct colours on the Buttons screenshot), which is
expected: SDL short-circuits a redundant bind. --multiwintest now PASSes.
NOTE the multi-window path was never correct, not just at teardown - two live windows were sharing one context. Anything that looked like flicker or cross-window corruption before this is explained by it.
Accepted 1.0.0 gaps (documented, not fixed)
Drag-OUT of window (SDL platform limit; drop-IN works), full accessibility pipeline (out of
scope for desktop 1.0 - but ship the non-throwing PlatformScreenReader no-op), the
hand-rolled text engine as an architectural deviation (§2 P1), per-focus
SDL_StartTextInput/StopTextInput, loadImageBitmap/loadSvgPainter (JVM InputStream
signatures, N/A on K/N).
8. material3-adaptive ported to desktop native (2026-09-22) - ✅ LANDED
Asked for in issue #4:
org.jetbrains.compose.material3.adaptive:* 1.3.0-rc01 publishes ios_arm64 ios_simulator_arm64 macos_arm64 and nothing else - no linux, no mingw. (It DOES
carry macosArm64, which answers the reporter's open question.)
The port needed zero source edits. Two things made it a pure re-target:
- Every platform actual the native legs want already exists upstream -
adaptive/nonAndroidMain(calculatePosture),adaptive-layout/skikoMain(the ~75 l10n tables +Strings+ the drag handle) andadaptive-layout/nativeMain(identityHashCode,isGraphicsLayerElement). androidx.window:window-core- the dependencyadaptiveapi-exposes, and the obvious suspect for why the artifacts stop at apple - has publishedlinux_x64 linux_arm64 mingw_x64 macos_arm64klibs all along, under BOTH the google and jetbrains coordinates.
So the artifacts were missing purely because upstream never enabled the targets.
Landed as four modules under compose/material3/adaptive/
(com.bitsycore.compose.material3.adaptive:*), 125 files vendored verbatim off the
existing COMPOSE_CORE_REF pin - no new SET_REPO. Upstream's skikoMain lands in
src/vendor/native/ beside upstream's own nativeMain; they actualise disjoint
expects so they share one source set. Both bridges substitute all four. demo --screen=Adaptive renders a
ListDetailPaneScaffold, and demo --adaptivetest drives one window
wide -> narrow -> wide and asserts the scaffold folds and unfolds, which is what
actually proves the resize chain end to end (SDL resize -> the owner's
snapshot-backed containerSize -> recomposition -> BoxWithConstraints re-measure ->
a new PaneScaffoldDirective -> a new scaffold value).
The demo screen computes its directive from the width IT gets rather than taking
the rememberListDetailPaneScaffoldNavigator default, which is the window size
class: the shell spends 190dp on the sidebar plus 48dp of padding, so defaulting
folds ~240dp too late and leaves two panes in a region already too cramped for
them. Pane-hosting apps that are not full-window should do the same.
One wrinkle worth remembering, and a textbook case of the granular-metadata trap
already documented in CLAUDE.md: because adaptive is SUBSTITUTED on native
configs, KGP drops its transitives from the commonMain metadata classpath, so the
shared demo screen could not see WindowSizeClass until window-core was declared
DIRECTLY in demo's commonMain.
Not yet exercised: adaptive-navigation3 builds and dumps API but has no demo
screen - the nav3 SceneStrategy path is unproven at runtime.
7. Post-1.12.0 open items (opened 2026-09-12)
7a. JVM parity reference used a DIFFERENT default font than native - ✅ FIXED
Symptom (apidemo, SourceTag in InheritanceUi.kt): the "Session" / "Pack" pill is
Box(padding(horizontal = 5.dp, vertical = 1.dp)) { Text(fontSize = 9.sp) } - no explicit
vertical centering, so the pill's look is entirely decided by where the glyphs sit inside the
Text node's measured paragraph box. On the JVM leg the label looks vertically centred in the
pill; on native it does not.
Not a centering bug - a font-resolution divergence. The two stacks render that label in two different typefaces, which have different ascent / descent / line-gap, so the glyph run sits at a different height inside an equally-tall box:
- NATIVE:
FontFamily.Defaultresolves to the BUNDLED NotoSans -SkiaFonts.defaultTypeface(compose/ui/ui-text/src/skikoRendererMain/.../SkiaFonts.kt) loadsfont/NotoSans.ttffrom data.kres and registers it as "the fallback for every unresolved family". - JVM: nothing registers NotoSans as the default.
apidemo/src/jvmMain/kotlin/Fonts.jvm.ktonly definesmonoFontFamily;jvmProcessResourcesstages the .ttf on the classpath but never installs it. SoFontFamily.Defaultis Skiko's SYSTEM default (Segoe UI on Windows).
This is a defect in the PARITY HARNESS, not (necessarily) in the renderer: every Text that
doesn't name a fontFamily is being compared against a different typeface, so any such
difference is uninterpretable. It likely masks or manufactures other "port bugs".
Fixed by giving apidemo a defaultFontFamily expect/actual (Fonts.kt) that loads the
bundled font/NotoSans.ttf on BOTH legs - native through IconFont.register +
namedFontFamily, jvm from the classpath copy jvmProcessResources already stages - and
stamping it across the M3 Typography (withDefaultFontFamily), since typography styles
carry their own fontFamily and overriding LocalTextStyle alone would not reach them.
This is the SAME treatment :demo's parity leg already had (see the notoSans /
notoTypography block in demo/src/jvmMain/kotlin/MainJvm.kt, commented "so the parity %
measures real divergence, not 'different default typeface'"). apidemo had simply never been
given it. Verified: the SourceTag pill is now vertically centred on both stacks.
7a-bis. apidemo drift check is now mechanised - and two residuals found
:apidemo had no way to capture itself, so drift could only be eyeballed. Both legs now take
--screenshot=<path>:
apidemo.exe --screenshot=out.bmp # native: GPU readback
./gradlew :apidemo:run --args=--screenshot=out.png # jvm: ImageComposeScene
Both render OFFSCREEN to quiescence (virtual 60fps clock, infinite animations frozen via
upstream's InfiniteAnimationPolicy, capture after 3 quiet frames), mirroring :demo's
parity path. Neither needs a visible window, so the capture is immune to occlusion - a
screen-scrape approach was tried first and grabbed whatever happened to be in front.
First run after the font fix, 1240x820: 2.9% of pixels differ at all, 2.1% by >32/255. Two residuals, neither a text-metrics bug:
-
Inherited lineHeight leading distribution - ✅ FIXED. The port never forwarded
topRatioto skiko. Upstream'sParagraphBuilder.skiko.ktsetsres.topRatio = topRatioon EVERY SkTextStyle, sourced fromLineHeightStyle.Alignment.topRatio; the port setts.heightandheightModebut lefttopRatioalone, so skiko fell back to its own default (-1 = split the extra leading proportionally to the font's ascent:descent). For a line box much taller than the glyphs that pushes them visibly DOWN - while Material 3's type styles ask forAlignment.Center(topRatio 0.5).One line in
SkiaParagraphEngine.kt's SkTextStyle builder, withAlignment.Proportionalas the fallback exactly like upstream, so text specifying no lineHeightStyle is unchanged.topRatiois present on both the official skiko and the bitsycore mingw fork.Measured on apidemo (1240x820), whole window, native vs jvm:
differ >32/255 before 2.94% 2.14% after 1.52% 0.29% The Session/Pack pills are now pixel-identical. This ALSO killed the "constant ~2px vertical offset" logged alongside it - that was the same bug compounding through stacked text rows, not an independent issue: shifting the jvm capture now makes the match WORSE (0.29% at +0px vs 2.13% at +1px), where before +2px halved the error. The residual 0.29% is rasterizer AA.
-
Icon-font glyphs rendered LOW - ✅ FIXED. Material Symbols sat 2-4px lower on native than on jvm, the offset scaling with icon size (~13% of it). Cause: the JVM actual centres the glyph explicitly (
y = height/2 - (ascent+descent)/2), whileIconFontIconplacedIconTextin a Box at the DEFAULT TopStart alignment - and the glyph's natural text box is taller than the nominal icon size. Fixed by centring, withwrapContentSize(unbounded = true)so the text measures at its natural size first:Modifier.size()imposes FIXED constraints, so without it nothing overflows and contentAlignment is a no-op (a first attempt without it changed literally nothing). gear/download/lock are now pixel-exact; the eye is 1px off from half-pixel rounding (odd glyph height centred in an even box; jvm draws at a float y, native placement is integer). Whole-window >32/255 drift 0.29% -> 0.17%. -
Missing glyph in the session header. The leading icon next to "Untitled session" renders as a
.notdefTOFU on jvm and as NOTHING on native - so the glyph is absent from the subsetted Material Symbols font on both, and the two stacks just disagree on how to draw a missing glyph. The gear icon beside it is fine, so the icon-font path works; this is almost certainlyenableIconSubsettingmissing a NON-LITERALMaterialSymbols.<Name>reference thatfindMaterialSymbolsUsagecannot see. Check that call site, and consider making the native side draw .notdef too so a missing glyph is never silent.
7b. Vendor Koin + Coil3 + Pulse MVI (requested 2026-09-12) - ✅ DONE
Target-availability audit against Maven Central (the deciding fact - re-check on every version bump, and check BOTH the google and jetbrains coordinate sets before concluding an artifact "has no desktop klibs"):
| Artifact | mingwX64 | linuxX64/arm64 | macosArm64 | Action |
|---|---|---|---|---|
androidx.navigationevent:navigationevent-compose 1.1.2 |
YES | YES | YES | none - already used as-is |
org.jetbrains.androidx.navigationevent:navigationevent-compose 1.1.0 |
YES | YES | YES | none |
io.insert-koin:koin-core 4.2.2 |
YES | YES | YES | none - Maven as-is |
io.insert-koin:koin-core-viewmodel 4.2.2 |
no | no | YES | VENDOR |
io.insert-koin:koin-compose 4.2.2 |
no | no | YES | VENDOR |
io.insert-koin:koin-compose-viewmodel 4.2.2 |
no | no | YES | VENDOR |
io.insert-koin:koin-compose-viewmodel-navigation 4.2.2 |
no | no | YES | VENDOR |
io.coil-kt.coil3:coil-core 3.6.2 |
no | YES | YES | VENDOR (mingw gap) |
io.coil-kt.coil3:coil 3.6.2 |
no | YES | YES | VENDOR (mingw gap) |
io.coil-kt.coil3:coil-network-core 3.6.2 |
no | YES | YES | VENDOR (mingw gap) |
io.coil-kt.coil3:coil-network-ktor3 3.6.2 |
no | YES | YES | VENDOR (mingw gap) |
io.coil-kt.coil3:coil-svg 3.6.2 |
no | YES | YES | VENDOR (mingw gap) |
io.coil-kt.coil3:coil-compose-core 3.6.2 |
no | no | YES | VENDOR |
io.coil-kt.coil3:coil-compose 3.6.2 |
no | no | YES | VENDOR |
NOTE the original premise that navigationevent-compose lacks desktop-native artifacts is
WRONG - both coordinate sets publish mingwX64 + linux + macos, and the port already consumes
androidx.navigationevent:navigationevent-compose:1.1.2 unmodified ("Known Compatible" in
CLAUDE.md, exercised by demo --backtest). Nothing to vendor there.
Landed as koin/ (4 modules, com.bitsycore.koin), coil/ (7 modules,
com.bitsycore.coil3) and pulse/ (4 modules, com.bitsycore.compose.desktop.native.pulse) - 202 files vendored
verbatim across three new pinned repos (KOIN_REF=4.2.2, COIL_REF=3.6.2,
PULSE_REF=0.3.7), ZERO local source edits. All build for mingwX64 + linuxX64/arm64, are
BCV-dumped, publish, and carry bridge substitution rules so consumers keep declaring the
official coordinates.
Three things worth remembering from doing it:
- nav2 is a dead end here. koin-compose-viewmodel-navigation needs nav2's
navigation-compose, which has no mingwX64/linux klibs under either coordinate set. Vendoredkoin-compose-navigation3instead - it needs only koin-compose + navigation3-runtime, matching this port's nav3 story. - Upstream
non*source sets must stay ABOVE nativeMain, not merge into it. Several declareexpects whoseactuals live in upstream nativeMain; collapsing both into one source set makes them siblings, which is a hard error. - A shared-metadata compilation is a real compile. Coil's nonAndroid code uses
org.jetbrains.skia.*directly, so the INTERMEDIATE source set holding it needs skiko on its own classpath - and since mingwX64 needs the FORK while macOS/Linux need the official build, the two cannot share an intermediate. coil-core / coil-svg / coil-compose-core therefore carry two PARALLEL branches over the same srcDirs, the shape :compose:ui:ui-graphics already used. Kotlin emits no metadata compilation for the single-target fork branch, so they never collide.
Also hit the granular-metadata trap (CLAUDE.md "Common pitfalls") in coil-compose-core:
every artifact the shared code touches must be declared DIRECTLY and get its own bridge
rule, or compileCommonMainKotlinMetadata loses its transitives on the Windows publish job.
REMAINING (deliberate): upstream Koin/Coil/Pulse reference the
org.jetbrains.androidx.lifecycle:* mirrors while this port standardises on the GOOGLE
androidx.lifecycle:* coords. The build files repoint them, but a consumer that pulls the
jetbrains mirrors in itself will get duplicate unique_name klib warnings at metadata
compile. Harmless today; if it ever becomes an error, force the google coords repo-wide.