Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ Block-based editor (Craft/Notion-like) for Compose Multiplatform. Unidirectional
| Sample comments screen | `sample/src/commonMain/kotlin/io/github/linreal/cascade/screens/comments/CommentsScreen.kt`, `CommentComposer.kt`, `CommentsScreenModel.kt`, `CommentModel.kt` | `CommentsScreen()`, `CommentComposer()` (editor-as-chat-composer: focus-driven fade-in formatting bar via `rememberCascadeEditorToolbarController` + Send), `CommentsScreenModel` (`buildOwnComment()`/`resetComposer()`), `Comment` + `rememberCommentAnnotatedString()` (consumer-side `SpanStyle`→`AnnotatedString`) |
| Main composable | `ui/CascadeEditor.kt` | `CascadeEditor(stateHolder, textStates, spanStates, registry, slashRegistry, slashCommand, ...)` |
| Native iOS SDK technical context | `docs/iOsNativeSdk.md` | Swift controller facade, UIKit host, native custom blocks/slash commands, public API, integration constraints |
| Theme customization recipes | `docs/recipes/ThemeCustomization.md` | Agent-oriented Compose Multiplatform and native Swift tutorials for complete palettes and family-aware runtime appearance switching |
| Native iOS color bridge | `editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/theme/CascadeEditorColorsBridge.kt` | Swift-visible `CascadeEditorColors` complete ARGB palette; controller snapshot mapping into core colors |
| Native iOS publication runbook | `docs/iOsPublication.md` | Version/tag contract, GitHub/SwiftPM setup, immutable release sequence, recovery |
| Native iOS release packaging | `scripts/package-ios-sdk.sh`, `distribution/swift-package/` | Serializes memory-heavy native test/release linking, verifies the dynamic Release XCFramework, and emits one-root ZIP, SHA-256, and external Swift package files |
| Native iOS clean consumer validation | `scripts/validate-ios-consumer.sh`, `iosConsumerSmokeTest/` | Packaging installs an ignored ZIP-derived framework for direct Xcode use; validation removes it in a temporary copy, then performs generic-device Release and simulator UI/resource tests using only the supplied packaged binary |
Expand Down Expand Up @@ -361,6 +363,7 @@ All state changes go through `EditorAction.reduce(state) → newState`.
| Theming / styling API — data models | Done | `CascadeEditorTheme`, `CascadeEditorColors`, `CascadeEditorTypography`, `LocalCascadeTheme`; light/dark presets |
| Theming / styling API — color migration | Done | All UI colors read from `LocalCascadeTheme.current.colors` |
| Theming / styling API — typography migration | Done | All UI typography reads from `LocalCascadeTheme.current.typography` |
| Native iOS theming — color bridge | Done | Swift can seed a complete 24-slot `CascadeEditorColors` bag from either preset, apply a snapshotted palette at runtime with `setColors`, and restore preset selection with `clearCustomColors`; the native sample separates user-selected Violet/Forest families from OS-selected light/dark variants and reapplies both values when `colorScheme` changes |
| Localization — data models | Done | `CascadeEditorStrings`, `CascadeEditorBlockStrings`, `BlockLocalizedStrings`, `LocalCascadeStrings`, `LocalCascadeBlockStrings` |
| Localization — UI string migration | Done | `SlashCommandPopup`, `UnknownBlockRenderer`, `RichTextToolbar` read from `LocalCascadeStrings`; link labels and validation-error labels live in `CascadeEditorStrings` |
| Localization — slash command system | Done | `BuiltInSlashCommandFactory.generate()` accepts `CascadeEditorBlockStrings?` for localized titles/descriptions/keywords |
Expand Down Expand Up @@ -429,6 +432,7 @@ All state changes go through `EditorAction.reduce(state) → newState`.
| `SlashPopupUtilsTest.kt` | Popup pure functions: estimatePopupHeightDp (compact/clamped), calculatePopupOffset (below/above/clamp), resolveNextHighlight (null/down/up/first/last/clamped/unknown) |
| `CascadeEditorSlashIntegrationTest.kt` | Slash integration: registry coexistence (built-in + custom), custom override, custom execution alongside built-ins, session invalidation pure function (no session, healthy, drag, selection, anchor missing, different block deleted), full scenarios (drag start, anchor deletion) |
| `CascadeEditorColorsTest.kt` | Light/dark presets: non-transparent slots including `linkText`, known values, light vs dark differ on key slots, copy/equality semantics |
| `editor-ios-sdk/.../CascadeEditorColorsBridgeTest.kt` | Native iOS bridge: light/dark preset parity, all 24 Swift-facing ARGB slots map exactly, runtime values are snapshotted, custom palettes survive semantic dark-mode changes, and clearing restores preset selection |
| `CascadeEditorTypographyTest.kt` | Default preset: positive font sizes, monotonically decreasing headings, monospace code, medium-weight toolbar, copy/equality |
| `CascadeEditorStringsTest.kt` | Default preset: non-empty strings, unsupportedBlock interpolation, copy with custom values, known English defaults |
| `CascadeEditorBlockStringsTest.kt` | Default preset: all built-in typeIds present, non-empty displayName/description/keywords, forType null for unknown, BlockLocalizedStrings defaults |
Expand Down
36 changes: 31 additions & 5 deletions docs/iOsNativeSdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## 1. Feature Overview

The Native iOS SDK exposes CascadeEditor to Swift applications without requiring consumers to use the Kotlin Multiplatform editor API, Compose types, or internal state holders directly. It packages the editor as a dynamic `CascadeEditor.xcframework`, provides a `CascadeEditorController` for document and editing operations, and hosts the Compose editor in a UIKit `UIViewController` that can be embedded in SwiftUI. The bridge also supports native UIKit/SwiftUI custom blocks, native slash commands, localization, runtime configuration, document import/export, and app-owned formatting chrome. A native Swift sample demonstrates persisted editing, a rich-text comments composer, and custom-block integrations while sharing the same serialized document format as the existing KMP sample.
The Native iOS SDK exposes CascadeEditor to Swift applications without requiring consumers to use the Kotlin Multiplatform editor API, Compose types, or internal state holders directly. It packages the editor as a dynamic `CascadeEditor.xcframework`, provides a `CascadeEditorController` for document and editing operations, and hosts the Compose editor in a UIKit `UIViewController` that can be embedded in SwiftUI. The bridge also supports native UIKit/SwiftUI custom blocks, native slash commands, localization, complete runtime color palettes, runtime configuration, document import/export, and app-owned formatting chrome. A native Swift sample demonstrates persisted editing, a rich-text comments composer, custom-block integrations, and live family-aware theme switching: the user selects Violet or Forest while iOS appearance automatically selects that family's light/dark variant.

## 2. Architecture & Design Decisions

Expand All @@ -28,14 +28,14 @@ included in every release slice. Framework version metadata and
- `BlockTextStates` and `BlockSpanStates`
- `BlockRegistry` seeded with built-in block types
- `SlashCommandRegistry`
- configuration and localization snapshots
- configuration, color-palette, and localization snapshots
- mounted toolbar actions and derived toolbar state

This is a facade pattern: Swift sees curated value objects and controller methods, while the controller translates them into core editor operations. The owned registries are intentionally stable for the controller lifetime, allowing custom blocks and slash commands to be registered before or after the editor is mounted.

### Compose-to-UIKit host

`CascadeEditorController.makeViewController()` creates a `ComposeUIViewController` and mounts `CascadeEditor` with the controller-owned state, runtime holders, and registries. It maps `CascadeEditorConfiguration` to `CascadeEditorConfig`, theme, toolbar, link popup, and slash-command slots. The host is created non-opaque (`opaque = false`): `CascadeEditor` paints no canvas background, so the native screen background behind the view controller shows through the editor.
`CascadeEditorController.makeViewController()` creates a `ComposeUIViewController` and mounts `CascadeEditor` with the controller-owned state, runtime holders, and registries. It maps `CascadeEditorConfiguration` to `CascadeEditorConfig`, theme, toolbar, link popup, and slash-command slots. A Swift-supplied color snapshot replaces all core `CascadeEditorColors` slots while retaining the preset typography and dimensions; without a custom snapshot, `isDark` selects the built-in light/dark theme. The host is created non-opaque (`opaque = false`): `CascadeEditor` paints no canvas background, so the native screen background behind the view controller shows through the editor.

The host uses Compose snapshot state and `snapshotFlow` for two bridge streams:

Expand Down Expand Up @@ -175,13 +175,38 @@ Editing commands:
- `indentForward()`, `indentBackward()`
- `applyLink(url, title)`, `removeLink()`

Runtime configuration and localization:
Runtime configuration, colors, and localization:

- `updateConfiguration(value)`, `setReadOnly(value)`, `setDarkMode(value)`, `setToolbarMode(value)`, `setSlashCommandsEnabled(value)`
- `setColors(CascadeEditorColors)`, `clearCustomColors()`
- `setLocalization(CascadeEditorLocalization)`
- `CascadeEditorColors` is a mutable, complete 24-slot palette seeded from the built-in light (`init()`) or light/dark (`init(isDark:)`) preset. Every property is a Swift `Int64` containing `0xAARRGGBB`.
- `CascadeLocalizedStrings` supplies nullable overrides for built-in UI/accessibility strings.
- `CascadeLocalizedBlockStrings` supplies slash-menu name, description, and additive keywords by block type ID.

`setColors` snapshots the whole bag and can be called before or after
`makeViewController()`. Later bag mutations do not affect the editor until
`setColors` is called again. A custom palette remains active when `setDarkMode`
changes because `isDark` is also semantic state exposed to native custom blocks;
apps with named themes should update both values together. `clearCustomColors`
returns color selection to the built-in light/dark presets.

The native sample demonstrates the recommended host integration. It does not
force `preferredColorScheme`; instead it observes SwiftUI's
`@Environment(\.colorScheme)`, resolves the selected theme family's matching
variant, and calls both `setDarkMode` and `setColors` whenever appearance
changes. The SDK remains explicit and host-driven, so apps retain control over
their own theme-family model.

Example Swift runtime theme application:

```swift
let colors = CascadeEditorColors(isDark: theme.isDark)
colors.primary = 0xFF147D64
controller.setDarkMode(value: theme.isDark)
controller.setColors(colors: colors)
```

Callbacks:

- `onDocumentChanged: (() -> Unit)?`
Expand Down Expand Up @@ -237,7 +262,7 @@ controller.onDocumentChanged = { save(controller.exportJson()) }
- **Registry system:** native blocks extend `BlockRegistry`; native commands extend `SlashCommandRegistry`. Observable revisions make runtime registration visible to an already-mounted editor.
- **Serialization:** JSON uses `DocumentSchema` plus `NativeCustomBlockCodec`; HTML uses `HtmlProfile.Default`. Both share the same live text/span holders as rendering.
- **Rich text and toolbar:** the bridge maps `FormattingState`, `IndentationState`, and `LinkState` from `CascadeEditorToolbarController` into Swift-friendly state and actions.
- **Localization and theme:** Swift overrides are resolved into core `CascadeEditorStrings` and `CascadeEditorBlockStrings`; `isDark` selects the core light/dark theme and is exposed to native custom blocks.
- **Localization and theme:** Swift string overrides resolve into core `CascadeEditorStrings` and `CascadeEditorBlockStrings`. `CascadeEditorColors` snapshots map all 24 ARGB slots into the core palette and recompose live. Without a custom palette, `isDark` selects the built-in colors; it remains exposed to native custom blocks in either mode.
- **Local build:** `scripts/build-xcframework.sh` assembles the dynamic debug XCFramework at `editor-ios-sdk/build/XCFrameworks/debug/CascadeEditor.xcframework`. The Xcode sample links and embeds this local artifact.
- **External distribution:** `scripts/package-ios-sdk.sh` tests and assembles the release framework, validates resources/privacy/version/dSYMs, and emits a one-root ZIP plus SwiftPM checksum and manifest. `release-ios-sdk.yml` publishes immutable source-tag assets and updates `linreal/cascade-editor-ios`.
- **Consumer validation:** `scripts/validate-ios-consumer.sh` stages the publication ZIP in `iosConsumerSmokeTest`, builds a generic-device Release app, executes its UI test on an arm64 simulator, and inspects the embedded framework.
Expand All @@ -246,6 +271,7 @@ controller.onDocumentChanged = { save(controller.exportJson()) }
## 7. Edge Cases & Known Constraints

- All state-mutating controller methods, context operations, and ordinary state getters are main-thread-only. Off-main misuse reports `onInternalError` and returns a stable fallback/no-op. Export methods are the exception: they synchronously hop to the main queue to return authoritative content. Callers must not block the main thread waiting for an off-main export, or they can deadlock.
- Color values use the low 32 bits of each `Int64` as `0xAARRGGBB`. `setColors` is a complete replacement, not a partial merge; seed the bag with `CascadeEditorColors(isDark:)`, change the desired slots, and reapply it for every named-theme switch.
- Toolbar actions, `undo()`, and `redo()` are unavailable before the Compose editor is mounted. `canUndo` and `canRedo` return `false` in that state. Document load/export does not require a mounted view.
- Structural history requires live text/span runtime holders. `dispatchStructuralAction(...)` still applies the action when no holders are bound or passed, but the change is not undoable and is logged.
- JSON parse failure is preflighted and preserves the current document and history. Successful JSON load/reset is a hard replacement and clears history. HTML load follows the core hard-replacement path; fatal warning categories affect `success` but do not provide the same explicit preflight-preservation guarantee.
Expand Down
164 changes: 164 additions & 0 deletions docs/recipes/ThemeCustomization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# Theme Customization

Use this recipe when an app owns named theme families such as `Forest` and each
family has light and dark variants.

## Implementation contract

- Keep the user-selected family and the current light/dark appearance as
separate state.
- Resolve `(family, isDark)` to one complete editor palette.
- Start from the matching built-in preset, then override the app's colors.
- Changing `primary`, `popupBackground`, or `inlineCodeBackground` does not
implicitly change `linkText`, `toolbarBackground`, or
`codeBlockBackground`; override those slots explicitly when required.
- CascadeEditor does not choose a custom family. The host resolves and applies
the variant.

## Compose Multiplatform

1. Model the family and resolve both inputs to a `CascadeEditorTheme`:

```kotlin
enum class ThemeFamily { Violet, Forest }

fun ThemeFamily.editorTheme(isDark: Boolean): CascadeEditorTheme {
val base = if (isDark) CascadeEditorTheme.dark() else CascadeEditorTheme.light()
val colors = when (this) {
ThemeFamily.Violet -> violetColors(isDark)
ThemeFamily.Forest -> forestColors(isDark)
}
return base.copy(colors = colors)
}

private fun forestColors(isDark: Boolean): CascadeEditorColors =
(if (isDark) CascadeEditorColors.dark() else CascadeEditorColors.light()).copy(
primary = if (isDark) Color(0xFF67D4AF) else Color(0xFF147D64),
onPrimary = if (isDark) Color(0xFF07231A) else Color.White,
text = if (isDark) Color(0xFFE5F5EF) else Color(0xFF102A23),
popupBackground = if (isDark) Color(0xFF102820) else Color.White,
toolbarBackground = if (isDark) Color(0xFF163229) else Color.White,
linkText = if (isDark) Color(0xFF8FE0C4) else Color(0xFF147D64),
cursor = if (isDark) Color(0xFF67D4AF) else Color(0xFF147D64),
)
```

Define `violetColors` the same way. Add every app-specific slot from the
checklist below.

2. Resolve and pass the theme at the editor call site:

```kotlin
@Composable
fun EditorScreen(family: ThemeFamily) {
val isDark = isSystemInDarkTheme()
val editorTheme = remember(family, isDark) { family.editorTheme(isDark) }

CascadeEditor(
// existing state and registries
theme = editorTheme,
)
}
```

`isSystemInDarkTheme()` invalidates composition when system appearance changes,
so an open editor automatically moves from, for example, Forest Light to Forest
Dark. If the app has its own appearance override, pass that Boolean instead.

## Native iOS (Swift/SwiftUI)

The native SDK accepts colors as `0xAARRGGBB` values in a mutable
`CascadeEditorColors` bag. Seed the bag first so it always contains all 24
slots:

```swift
enum ThemeFamily {
case violet
case forest

func colors(isDark: Bool) -> CascadeEditorColors {
switch (self, isDark) {
case (.forest, false): forestLightColors()
case (.forest, true): forestDarkColors()
case (.violet, false): violetLightColors()
case (.violet, true): violetDarkColors()
}
}
}

private func forestDarkColors() -> CascadeEditorColors {
let colors = CascadeEditorColors(isDark: true)
colors.primary = 0xFF67D4AF
colors.onPrimary = 0xFF07231A
colors.text = 0xFFE5F5EF
colors.popupBackground = 0xFF102820
colors.toolbarBackground = 0xFF163229
colors.linkText = 0xFF8FE0C4
colors.cursor = 0xFF67D4AF
return colors
}
```

Create one factory per family variant and set every app-specific slot from the
checklist below.

Observe iOS appearance in every screen that owns an active editor:

```swift
struct CascadeEditorHost: UIViewControllerRepresentable {
let controller: CascadeEditorController

func makeUIViewController(context: Context) -> UIViewController {
controller.makeViewController()
}

func updateUIViewController(
_ viewController: UIViewController,
context: Context
) {}
}

struct EditorScreen: View {
@Environment(\.colorScheme) private var colorScheme
@State private var family: ThemeFamily = .forest
let controller: CascadeEditorController

private var isDark: Bool { colorScheme == .dark }

var body: some View {
CascadeEditorHost(controller: controller)
.onAppear { applyTheme() }
.onChange(of: family) { _, _ in applyTheme() }
.onChange(of: colorScheme) { _, _ in applyTheme() }
}

private func applyTheme() {
controller.setDarkMode(value: isDark)
controller.setColors(colors: family.colors(isDark: isDark))
}
}
```

Always call both methods. `setDarkMode` updates semantic state exposed to native
custom blocks; `setColors` replaces the rendered palette. The SDK does not
observe `UITraitCollection` or SwiftUI environment changes itself. `setColors`
snapshots the bag, so mutate it first and then call `setColors` again for every
variant change.

## Color-slot checklist

Both APIs expose the same 24 semantic slots:

`primary`, `onPrimary`, `text`, `popupBackground`,
`unknownBlockBackground`, `toolbarIcon`, `toolbarIconDisabled`,
`slashItemTitle`, `slashChevron`, `unknownBlockText`, `uiDivider`,
`contentDivider`, `slashSelectedItem`, `inlineCodeBackground`, `highlight`,
`cursor`, `textSelectionBackground`, `quoteBorder`, `quoteBackground`,
`selectionOverlay`, `linkText`, `error`, `codeBlockBackground`, and
`toolbarBackground`.

Reference implementations:

- Multiplatform: `sample/src/commonMain/kotlin/io/github/linreal/cascade/theme/SampleEditorTheme.kt`
- Native iOS: `iosNativeSample/iosNativeSample/App/AppTheme.swift` and
`iosNativeSample/iosNativeSample/Screens/EditorDemoScreen.swift`
Loading
Loading