diff --git a/README.md b/README.md index ed72f57..a1fa6bf 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,12 @@ import { KeyflowAvoidingView, useKeyflow } from 'react-native-keyflow'; import type { KeyflowThemeOverrides } from 'react-native-keyflow'; const keyflowTheme = { - keyboard: { background: '#F4F0FF' }, + keyboard: { + background: '#F4F0FF', + cornerRadius: 28, + borderColor: '#C77DFF', + borderWidth: 2, + }, keys: { background: '#342B62', color: '#FFFFFF' }, specialKeys: { background: '#E05B8D', color: '#FFFFFF' }, } satisfies KeyflowThemeOverrides; @@ -140,6 +145,9 @@ import type { KeyflowThemeOverrides } from 'react-native-keyflow'; export const keyflowTheme = { keyboard: { background: '#F4F0FF', + cornerRadius: 28, + borderColor: '#C77DFF', + borderWidth: 2, material: { type: 'raised', depth: 4, shadowColor: '#241D46' }, }, font: { size: 20, weight: 'medium' }, @@ -176,7 +184,7 @@ const { keyflowInputProps } = useKeyflow({ | `selection` | Focused accent appearance; also used by Android’s active Caps key | | `toolbar` | Android clipboard and dismiss controls | -Sections support colors, pressed colors, borders, corner radius, fonts, and icon sizing where applicable. `color` supplies the icon and pressed foreground unless explicitly overridden. If you set `iconColor`, remember to give selected states a contrasting icon color too. +Sections support colors, pressed colors, borders, corner radius, fonts, and icon sizing where applicable. The outer panel uses `keyboard.cornerRadius`, `keyboard.borderColor`, and `keyboard.borderWidth`; its border follows the same rounded top edge on iOS and Android. `color` supplies the icon and pressed foreground unless explicitly overridden. If you set `iconColor`, remember to give selected states a contrasting icon color too. Set the custom return key to app-owned text or a portable native icon: diff --git a/android/src/main/java/com/keyflow/KeyflowKeyboardView.kt b/android/src/main/java/com/keyflow/KeyflowKeyboardView.kt index d21c6fa..0ff7fc0 100644 --- a/android/src/main/java/com/keyflow/KeyflowKeyboardView.kt +++ b/android/src/main/java/com/keyflow/KeyflowKeyboardView.kt @@ -558,7 +558,16 @@ internal class KeyflowKeyboardView( } private fun styleKeys() { - background = GradientDrawable().apply { setColor(theme.background) } + background = + GradientDrawable().apply { + setColor(theme.background) + val radius = theme.keyboardRadius * resources.displayMetrics.density + cornerRadii = floatArrayOf(radius, radius, radius, radius, 0f, 0f, 0f, 0f) + setStroke( + (theme.keyboardBorderWidth * resources.displayMetrics.density).toInt(), + theme.keyboardBorderColor, + ) + } val content = theme.json.optJSONObject("returnKeyContent") keys.forEach { if (it.action == "submit") { diff --git a/android/src/main/java/com/keyflow/KeyflowTheme.kt b/android/src/main/java/com/keyflow/KeyflowTheme.kt index c6b3f5f..7902172 100644 --- a/android/src/main/java/com/keyflow/KeyflowTheme.kt +++ b/android/src/main/java/com/keyflow/KeyflowTheme.kt @@ -92,6 +92,15 @@ internal data class KeyflowTheme(val json: JSONObject = JSONObject()) { val radius get() = bounded("keyCornerRadius", 6f, 0f, 24f) + val keyboardRadius + get() = bounded("keyboardCornerRadius", 28f, 0f, 48f) + + val keyboardBorderWidth + get() = bounded("keyboardBorderWidth", 0f, 0f, 3f) + + val keyboardBorderColor + get() = color("keyboardBorderColor", "#00000000") + val depth get() = bounded("keyDepth", 4f, 0f, 6f) diff --git a/docs/api.md b/docs/api.md index 2f502ed..c1cd062 100644 --- a/docs/api.md +++ b/docs/api.md @@ -196,16 +196,20 @@ Available bases: `lightKeyflowTheme`, `darkKeyflowTheme`, `androidKeyflowTheme`, Sections are `keys`, `specialKeys`, `deleteKey`, `returnKey`, `preview`, `selection`, and `toolbar`. Each supports these fields where applicable: -| Fields | Values | -| ------------------------------------------------------ | --------------------------------------------- | -| `background`, `color`, `iconColor`, `placeholderColor` | `#RRGGBB` or `#RRGGBBAA` | -| `pressedBackground`, `pressedColor`, `borderColor` | Same color format, alpha last | -| `borderWidth` | 0–3 logical pixels | -| `cornerRadius` | 0–24 logical pixels, bounded by the face | -| `fontFamily` | Registered font name, system alias, or `null` | -| `fontSize` | 10–32 logical pixels | -| `fontWeight` | `regular`, `medium`, `bold` | -| `iconSize` | 12–28 logical pixels | +| Fields | Values | +| ------------------------------------------------------ | ----------------------------- | +| `background`, `color`, `iconColor`, `placeholderColor` | `#RRGGBB` or `#RRGGBBAA` | +| `pressedBackground`, `pressedColor`, `borderColor` | Same color format, alpha last | +| `borderWidth` | 0–3 logical pixels | + +The `keyboard` section also accepts `cornerRadius` (0–48), `borderColor`, and +`borderWidth` (0–3) for the outer keyboard panel. The border is drawn as one +continuous path across the top edge and around both top corners. +| `cornerRadius` | 0–24 logical pixels, bounded by the face | +| `fontFamily` | Registered font name, system alias, or `null` | +| `fontSize` | 10–32 logical pixels | +| `fontWeight` | `regular`, `medium`, `bold` | +| `iconSize` | 12–28 logical pixels | Global typography uses `font: { family, size, weight }`, with size 12–32. Sections inherit global values. Partial section updates preserve their other settings. `color` supplies icon/pressed foregrounds unless explicitly overridden; `fontFamily: null` restores the system font for that section. diff --git a/example/src/themes/studio.ts b/example/src/themes/studio.ts index 9632783..89a65d1 100644 --- a/example/src/themes/studio.ts +++ b/example/src/themes/studio.ts @@ -4,6 +4,9 @@ import { createKeyflowTheme } from 'react-native-keyflow'; export const studioTheme = createKeyflowTheme({ keyboard: { material: { type: 'raised', depth: 4, shadowColor: '#241D46' }, + cornerRadius: 28, + borderColor: '#C77DFF', + borderWidth: 2, }, background: '#F4F0FF', keyBackground: '#342B62', diff --git a/ios/KeyflowInputView.swift b/ios/KeyflowInputView.swift index babaca4..0b4a338 100644 --- a/ios/KeyflowInputView.swift +++ b/ios/KeyflowInputView.swift @@ -172,9 +172,12 @@ final class KeyflowInputView: ExpoView { func updateInputContext() { keyboard.updateContext(beforeCursor()) } private let keyboard = KeyflowKeyboardView() - // Keep a nonzero transparent input surface. A zero-height input view makes - // UIKit report keyboardDidHide on subsequent accessory presentations. - private let emptyInput = UIView(frame: CGRect(x: 0, y: 0, width: 390, height: 1)) + // UIKit requires a nonzero input surface to present the system keyboard on a + // later handoff. Keep it below one rendered pixel so transparent accessories + // do not leave a visible host row at the bottom of the screen. + private static let emptyInputHeight: CGFloat = 0.01 + private let emptyInput = UIView( + frame: CGRect(x: 0, y: 0, width: 390, height: KeyflowInputView.emptyInputHeight)) private var languagesJSON = "" private var languages: [KeyflowLanguage] = [.english] private var language = KeyflowLanguage.english @@ -219,7 +222,7 @@ final class KeyflowInputView: ExpoView { self, selector: #selector(refreshLanguages), name: UIApplication.willEnterForegroundNotification, object: nil) emptyInput.backgroundColor = .clear - emptyInput.heightAnchor.constraint(equalToConstant: 1).isActive = true + emptyInput.heightAnchor.constraint(equalToConstant: Self.emptyInputHeight).isActive = true customInputSurface = keyboard customKeyboard = nil synchronizeInputSurface() @@ -418,7 +421,6 @@ final class KeyflowInputView: ExpoView { customInputSurface = surface customKeyboard = accessory ? keyboard : nil synchronizeInputSurface() - keyboard.setInputSurfaceHeight(accessory ? 1 : 0) if attachedEditor?.isFirstResponder == true { requestInputReload() } } diff --git a/ios/KeyflowKeyboardView.swift b/ios/KeyflowKeyboardView.swift index e0f4c3f..10e5a3c 100644 --- a/ios/KeyflowKeyboardView.swift +++ b/ios/KeyflowKeyboardView.swift @@ -31,12 +31,15 @@ final class KeyflowKeyboardView: UIView { isTablet ? tabletBasis * (landscape ? 0.10252 : 0.07734) : (landscape ? 106.0 / 3.0 : 54) } private var topInset: CGFloat { - isPad ? (landscape ? 2 : 17) : (isTablet ? tabletBasis * (landscape ? 0.0132 : 0.0084) : 4) + isPad ? (landscape ? 2 : 17) : (isTablet ? tabletBasis * (landscape ? 0.0132 : 0.0084) : 8) + } + // Leave the native keyboard's compact clearance above iPhone letter rows + // without reserving the full QuickType suggestion band. iPad keeps its + // measured input-assistant height so its rows align with the system keyboard. + private var inputAssistantHeight: CGFloat { + guard !isPad else { return 0 } + return isTablet ? tabletBasis * 0.06595 : 16 } - // The native iPad keyboard reserves its input-assistant band above the key - // rows. Keyflow leaves it visually empty because predictions are unsupported, - // but preserves the band so the keys and app avoidance line up with UIKit. - private var tabletAssistantHeight: CGFloat { isTablet && !isPad ? tabletBasis * 0.06595 : 0 } func setKeyboardType(_ value: String) { let type = ["number-pad", "decimal-pad", "phone-pad"].contains(value) ? value : "default" guard keyboardType != type else { return } @@ -82,10 +85,8 @@ final class KeyflowKeyboardView: UIView { // Preserve the native portrait typing position even without dock controls. // The dock includes ergonomic clearance above the home-indicator safe area. private var bottomInset: CGFloat = 73 - private var inputSurfaceHeight: CGFloat = 0 private let panelBackground = UIView() - private let panelMask = CAShapeLayer() - private let panelBottom = UIView() + private let panelShape = CAShapeLayer() private var preferredHeight: CGFloat = panelHeight private var presentationActive = true private var heightConstraint: NSLayoutConstraint! @@ -99,7 +100,7 @@ final class KeyflowKeyboardView: UIView { private var capsLocked = false private var lastShiftTime: TimeInterval = 0 private var rows: [[KeyflowKey]] = [] - private var typingTop: CGFloat { tabletAssistantHeight + topInset } + private var typingTop: CGFloat { inputAssistantHeight + topInset } private var holdWork: DispatchWorkItem? private var heldTouch: ObjectIdentifier? private var heldOrigin: CGPoint = .zero @@ -146,13 +147,11 @@ final class KeyflowKeyboardView: UIView { insertSubview(panelBackground, at: 0) panelBackground.isUserInteractionEnabled = false panelBackground.isOpaque = false - panelBottom.isOpaque = false - // Unlike a UIView-backed layer, the standalone mask has default Core - // Animation actions. Layout must not start bounds/position/path animations - // that keep an accessory presentation (and XCTest quiescence) active. - panelMask.actions = ["bounds": NSNull(), "position": NSNull(), "path": NSNull()] - panelBackground.layer.mask = panelMask - panelBackground.addSubview(panelBottom) + panelShape.actions = [ + "bounds": NSNull(), "position": NSNull(), "path": NSNull(), "fillColor": NSNull(), + "strokeColor": NSNull(), "lineWidth": NSNull(), + ] + panelBackground.layer.addSublayer(panelShape) translatesAutoresizingMaskIntoConstraints = false heightConstraint = heightAnchor.constraint(equalToConstant: Self.panelHeight) heightConstraint.isActive = true @@ -222,12 +221,6 @@ final class KeyflowKeyboardView: UIView { ] } - func setInputSurfaceHeight(_ value: CGFloat) { - guard inputSurfaceHeight != value else { return } - inputSurfaceHeight = value - rebuildKeys() - } - func reset() { cancelTouches() page = isTablet && keyboardType != "default" ? .numbers : .letters @@ -242,17 +235,19 @@ final class KeyflowKeyboardView: UIView { override func layoutSubviews() { super.layoutSubviews() - // Clip only the background siblings, never the keys or callouts. - // Round only the panel's top corners. The bottom surface reaches the - // screen edges; the device supplies its own physical corner clipping. + // Draw the fill and border as one path so a rounded panel always has a + // continuous top edge instead of exposing a corner-only host outline. UIView.performWithoutAnimation { panelBackground.frame = bounds - panelBottom.frame = panelBackground.bounds - panelMask.frame = panelBackground.bounds - panelMask.path = + panelShape.frame = panelBackground.bounds + let borderWidth = CGFloat(theme.keyboardBorderWidth) + let panelBounds = panelBackground.bounds.insetBy(dx: borderWidth / 2, dy: borderWidth / 2) + panelShape.path = UIBezierPath( - roundedRect: panelBackground.bounds, byRoundingCorners: [.topLeft, .topRight], - cornerRadii: CGSize(width: 28, height: 28) + roundedRect: panelBounds, byRoundingCorners: [.topLeft, .topRight], + cornerRadii: CGSize( + width: CGFloat(theme.keyboardCornerRadius), + height: CGFloat(theme.keyboardCornerRadius)) ).cgPath } KeyflowKeyboardLayout( @@ -297,8 +292,8 @@ final class KeyflowKeyboardView: UIView { // the custom pad remains aligned with the system pad in both orientations. let tabletPadTopReserve: CGFloat = isTablet && keyboardType != "default" ? 5 : 0 let targetHeight = - tabletAssistantHeight + topInset + 4 * rowHeight + bottomInset + tabletPadTopReserve - + (isPad && !landscape ? 1 : 0) - inputSurfaceHeight + inputAssistantHeight + topInset + 4 * rowHeight + bottomInset + tabletPadTopReserve + + (isPad && !landscape ? 1 : 0) let heightChanged = preferredHeight != targetHeight preferredHeight = targetHeight heightConstraint.constant = presentationActive ? targetHeight : 0 @@ -334,7 +329,9 @@ final class KeyflowKeyboardView: UIView { item.theme = popupTheme } backgroundColor = .clear - panelBottom.backgroundColor = UIColor(keyflowHex: theme.background) + panelShape.fillColor = UIColor(keyflowHex: theme.background).cgColor + panelShape.strokeColor = UIColor(keyflowHex: theme.keyboardBorderColor).cgColor + panelShape.lineWidth = CGFloat(theme.keyboardBorderWidth) for key in rows.flatMap({ $0 }) { if key.action == .submit { let fallback = language.base == "fr" ? "retour" : "return" diff --git a/ios/KeyflowTheme.swift b/ios/KeyflowTheme.swift index 6f7568f..ee263a5 100644 --- a/ios/KeyflowTheme.swift +++ b/ios/KeyflowTheme.swift @@ -25,6 +25,9 @@ struct KeyflowTheme: Decodable, Equatable { var fontFamily: String? = nil var fontSize: Double = 22 var keyCornerRadius: Double = 8 + var keyboardCornerRadius: Double = 28 + var keyboardBorderColor = "#00000000" + var keyboardBorderWidth: Double = 0 func font( size: CGFloat, weight: UIFont.Weight = .regular, keyboard: Bool = false, diff --git a/scripts/ci/workflow.test.mjs b/scripts/ci/workflow.test.mjs index d4698c5..f07c270 100644 --- a/scripts/ci/workflow.test.mjs +++ b/scripts/ci/workflow.test.mjs @@ -241,7 +241,8 @@ test('device sources contain the proven inventory plus glyph and Shift regressio group === 'iosRendering' ? [ 'testPanelCoversBottomCornersWithoutExtraOpacity', - 'testPanelMaskLayoutDoesNotStartImplicitAnimations', + 'testPhonePanelFillsItsHostAndKeepsNativeTopClearance', + 'testPanelRadiusAndBorderUseOneContinuousShape', 'testTrackpadCaretFloatsBetweenCharactersAndRestoresOnRelease', 'testTrackpadSnapsToTheVisuallyNearestCaretInsteadOfTheTrailingHit', 'testTrackpadReleaseCommitsTheVisibleCaretBoundary', diff --git a/scripts/ios-tests/KeyflowRenderingTests.swift b/scripts/ios-tests/KeyflowRenderingTests.swift index 44dbce1..b21c320 100644 --- a/scripts/ios-tests/KeyflowRenderingTests.swift +++ b/scripts/ios-tests/KeyflowRenderingTests.swift @@ -57,6 +57,57 @@ final class KeyflowRenderingTests: XCTestCase { XCTAssertTrue(field.inputView === original) } + func testPhonePanelFillsItsHostAndKeepsNativeTopClearance() throws { + try XCTSkipIf(UIDevice.current.userInterfaceIdiom == .pad, "iPhone panel geometry") + let keyboard = KeyflowKeyboardView() + keyboard.updateViewport( + CGSize(width: 390, height: 844), + insets: UIEdgeInsets(top: 0, left: 0, bottom: 34, right: 0)) + keyboard.frame = CGRect( + x: 0, y: 0, width: 390, height: keyboard.intrinsicContentSize.height) + keyboard.layoutIfNeeded() + + let firstKey = try XCTUnwrap( + keyboard.subviews.compactMap { $0 as? KeyflowKey }.first { $0.caption == "Q" }) + firstKey.layoutIfNeeded() + XCTAssertEqual(firstKey.frame.minY, 24, accuracy: 0.01) + XCTAssertEqual(firstKey.face.frame.minY, 6, accuracy: 0.01) + XCTAssertEqual(keyboard.intrinsicContentSize.height, 313, accuracy: 0.01) + + let panel = try XCTUnwrap( + Mirror(reflecting: keyboard).children.first { $0.label == "panelBackground" }?.value + as? UIView) + XCTAssertEqual(panel.frame, keyboard.bounds) + XCTAssertNil(panel.layer.mask, "The panel must not expose a partial rounded outline") + } + + func testPanelRadiusAndBorderUseOneContinuousShape() throws { + let keyboard = KeyflowKeyboardView() + var theme = KeyflowTheme() + theme.keyboardCornerRadius = 28 + theme.keyboardBorderColor = "#C77DFF" + theme.keyboardBorderWidth = 2 + keyboard.theme = theme + keyboard.updateViewport( + CGSize(width: 390, height: 844), + insets: UIEdgeInsets(top: 0, left: 0, bottom: 34, right: 0)) + keyboard.frame = CGRect( + x: 0, y: 0, width: 390, height: keyboard.intrinsicContentSize.height) + keyboard.layoutIfNeeded() + + let shape = try XCTUnwrap( + Mirror(reflecting: keyboard).children.first { $0.label == "panelShape" }?.value + as? CAShapeLayer) + XCTAssertEqual(shape.lineWidth, 2) + XCTAssertEqual(shape.strokeColor, UIColor(keyflowHex: "#C77DFF").cgColor) + XCTAssertEqual(shape.fillColor, UIColor(keyflowHex: theme.background).cgColor) + let path = try XCTUnwrap(shape.path) + XCTAssertEqual(path.boundingBox.minX, 1, accuracy: 0.01) + XCTAssertEqual(path.boundingBox.maxX, 389, accuracy: 0.01) + XCTAssertTrue(path.contains(CGPoint(x: 195, y: 1)), "The top border must span the panel") + XCTAssertFalse(path.contains(CGPoint(x: 1, y: 1)), "The configured top corner must remain rounded") + } + func testAccentPopupOmitsBordersAndUsesKeyRadius() throws { let style = KeyflowSectionStyle( background: "#FFFF00", color: "#FFFFFF", placeholderColor: "#FFFFFF", @@ -119,31 +170,6 @@ final class KeyflowRenderingTests: XCTestCase { func testPhonePadRaisedLargeSquareFits() { assertFits("phone-pad", "raised", 32.0, 0.0) } func testPhonePadRaisedLargeRoundedFits() { assertFits("phone-pad", "raised", 32.0, 24.0) } - func testPanelMaskLayoutDoesNotStartImplicitAnimations() throws { - let keyboard = KeyflowKeyboardView() - let scene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first - let window = scene.map { UIWindow(windowScene: $0) } ?? UIWindow(frame: UIScreen.main.bounds) - let controller = UIViewController() - window.rootViewController = controller - window.makeKeyAndVisible() - defer { window.isHidden = true } - controller.view.addSubview(keyboard) - keyboard.frame = CGRect(x: 0, y: 100, width: 390, height: keyboard.intrinsicContentSize.height) - keyboard.layoutIfNeeded() - let mask = try XCTUnwrap(keyboard.subviews.compactMap { $0.layer.mask }.first) - CATransaction.flush() - // UIKit lays the accessory out during an animated keyboard presentation. - // Static clipping geometry must not add another animation on every layout. - CATransaction.begin() - CATransaction.setAnimationDuration(10) - keyboard.frame.size.width += 1 - keyboard.setNeedsLayout() - keyboard.layoutIfNeeded() - CATransaction.commit() - CATransaction.flush() - XCTAssertEqual(mask.animationKeys() ?? [], [], "Static mask animations can keep XCTest waiting for the app to idle") - } - func testPanelCoversBottomCornersWithoutExtraOpacity() throws { for color in ["#163B50", "#163B5080"] { let keyboard = KeyflowKeyboardView() diff --git a/scripts/visual/transparency.mjs b/scripts/visual/transparency.mjs index 8bd8406..37501d2 100644 --- a/scripts/visual/transparency.mjs +++ b/scripts/visual/transparency.mjs @@ -123,6 +123,12 @@ const colors = (png, metrics) => { width: metrics.width * 0.1, height: 2, }), + bottom: sampleColor(png, metrics, { + x: metrics.width * 0.4, + y: metrics.height - 2, + width: metrics.width * 0.2, + height: 1, + }), }; }; const delta = (a, b) => @@ -185,6 +191,10 @@ try { delta(samples.clear.panel, samples['half-keys'].panel) < 1, 'Key alpha leaked into the transparent panel', ); + assert( + Math.min(...samples.clear.bottom) < 220, + 'Transparent iOS input host left a white seam below the keyboard', + ); assert(delta(samples.clear.space, samples['half-keys'].space) > 40); assert( delta(samples['half-keys'].space, samples['background-only'].space) > 40, diff --git a/src/__tests__/theme.test.ts b/src/__tests__/theme.test.ts index 149ecae..a09acba 100644 --- a/src/__tests__/theme.test.ts +++ b/src/__tests__/theme.test.ts @@ -96,6 +96,35 @@ test('supports square keys and rejects blank fonts', () => { expect(() => createKeyflowTheme({ fontFamily: ' ' })).toThrow(TypeError); }); +test('resolves and serializes the outer keyboard radius and border', () => { + const theme = createKeyflowTheme({ + keyboard: { + cornerRadius: 32, + borderColor: '#C77DFF', + borderWidth: 2, + }, + }); + expect(theme).toMatchObject({ + keyboardCornerRadius: 32, + keyboardBorderColor: '#C77DFF', + keyboardBorderWidth: 2, + }); + expect(JSON.parse(serializeKeyflowTheme(theme))).toMatchObject({ + keyboardCornerRadius: 32, + keyboardBorderColor: '#C77DFF', + keyboardBorderWidth: 2, + }); + expect(() => createKeyflowTheme({ keyboard: { cornerRadius: 49 } })).toThrow( + RangeError, + ); + expect(() => createKeyflowTheme({ keyboard: { borderWidth: 4 } })).toThrow( + RangeError, + ); + expect(() => + createKeyflowTheme({ keyboard: { borderColor: '#BAD' } }), + ).toThrow(TypeError); +}); + test.each([-1, 7, 13, NaN, Infinity])( 'rejects invalid raised depth %s', (depth) => { diff --git a/src/serializeTheme.ts b/src/serializeTheme.ts index 6061759..6f273fa 100644 --- a/src/serializeTheme.ts +++ b/src/serializeTheme.ts @@ -11,6 +11,7 @@ const surfaceColors = new Set([ 'selectedKeyBackground', 'pressedBackground', 'borderColor', + 'keyboardBorderColor', 'keyShadow', ]); const returnKeyIcons = new Set([ @@ -63,7 +64,10 @@ export function serializeKeyflowTheme( // keycaps, controls, or popups and follow keyOpacity instead. const opacity = theme.surfaceOpacity * - (this === nativeTheme && key === 'background' ? 1 : theme.keyOpacity); + (this === nativeTheme && + (key === 'background' || key === 'keyboardBorderColor') + ? 1 + : theme.keyOpacity); if (opacity === 1) return value; const alpha = value.length === 9 ? parseInt(value.slice(7), 16) : 255; return `${value.slice(0, 7)}${Math.round(alpha * opacity) diff --git a/src/theme.ts b/src/theme.ts index b154c75..4015022 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -5,6 +5,8 @@ import type { KeyflowTheme, KeyflowThemeOverrides } from './types'; export const keyflowThemeLimits = Object.freeze({ fontSize: Object.freeze({ min: 12, max: 32 }), keyCornerRadius: Object.freeze({ min: 0, max: 24 }), + keyboardCornerRadius: Object.freeze({ min: 0, max: 48 }), + keyboardBorderWidth: Object.freeze({ min: 0, max: 3 }), materialDepth: Object.freeze({ min: 0, max: 6 }), surfaceOpacity: Object.freeze({ min: 0, max: 1 }), keyOpacity: Object.freeze({ min: 0, max: 1 }), @@ -33,6 +35,9 @@ export const lightKeyflowTheme: KeyflowTheme = Object.freeze({ fontFamily: null, fontSize: 22, keyCornerRadius: 8, + keyboardCornerRadius: 28, + keyboardBorderColor: '#00000000', + keyboardBorderWidth: 0, }); export const darkKeyflowTheme: KeyflowTheme = Object.freeze({ @@ -82,6 +87,12 @@ export function createKeyflowTheme( if (overrides.keyboard?.background !== undefined) theme.background = overrides.keyboard.background; + if (overrides.keyboard?.cornerRadius !== undefined) + theme.keyboardCornerRadius = overrides.keyboard.cornerRadius; + if (overrides.keyboard?.borderColor !== undefined) + theme.keyboardBorderColor = overrides.keyboard.borderColor; + if (overrides.keyboard?.borderWidth !== undefined) + theme.keyboardBorderWidth = overrides.keyboard.borderWidth; if ( overrides.keyboard?.material !== undefined && !['flat', 'raised'].includes(overrides.keyboard.material.type) @@ -117,6 +128,7 @@ export function createKeyflowTheme( 'specialKeyBackground', 'actionKeyBackground', 'actionKeyForeground', + 'keyboardBorderColor', ] as const) { if (!/^#(?:[\da-f]{6}|[\da-f]{8})$/i.test(theme[key])) { throw new TypeError(`${key} must be #RRGGBB or #RRGGBBAA`); @@ -141,6 +153,8 @@ export function createKeyflowTheme( for (const key of [ 'fontSize', 'keyCornerRadius', + 'keyboardCornerRadius', + 'keyboardBorderWidth', 'surfaceOpacity', 'keyOpacity', ] as const) { diff --git a/src/types.ts b/src/types.ts index e833a03..67d5db2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -48,6 +48,11 @@ export type KeyflowTheme = Readonly<{ fontSize: number; /** 0–24 logical pixels, additionally bounded to half the face size. */ keyCornerRadius: number; + /** Outer keyboard panel radius, 0–48 logical pixels. */ + keyboardCornerRadius: number; + keyboardBorderColor: KeyboardColor; + /** Outer keyboard panel border, 0–3 logical pixels. */ + keyboardBorderWidth: number; }>; export type KeyboardSectionStyle = Readonly<{ @@ -78,6 +83,9 @@ export type KeyflowThemeOverrides = Omit, 'material'> & { background?: KeyboardColor; /** Panel alpha, 0–1. Overrides the background color's alpha without fading keys. */ backgroundOpacity?: number; + cornerRadius?: number; + borderColor?: KeyboardColor; + borderWidth?: number; /** Keycaps, controls, and popups, 0–1. Leaves the panel, text, and icons unchanged. */ keyOpacity?: number; /** All keyboard surfaces, including keycaps and popups, 0–1. */