---
type: reference
title: "CAMDOM — Skia Rendering Pipeline & Ball Component"
description: "Deep-dive into Skia Canvas rendering: Ball component with ImageShader textures, Blur, Paragraph text, dual-ball system, state-driven visuals."
tags: [camdom, skia, rendering, canvas, ball, visual-effects, react-native]
timestamp: "2026-07-20"
---

# CAMDOM — Skia Rendering Pipeline & Ball Component

## Overview

CAMDOM's main screen is rendered entirely via a **React Native Skia Canvas**, not through the standard React Native View tree. The visual centerpiece is the **Ball** — a circular element representing the device's BLE connection state. The Ball is rendered as Skia primitives (Circles, ImageShaders, Blur filters, Paragraphs) all driven by Reanimated SharedValues, meaning the entire rendering pipeline runs on the UI thread with zero bridge crossings.

The architecture is: **SharedValue state --> useDerivedValue computation --> Skia Canvas primitives --> GPU-accelerated rasterization**.

---

## 1. Skia Canvas Architecture

### The Canvas Container

The `App` component (defined inline in `app/index.tsx`) renders a full-screen `<Canvas>` from `@shopify/react-native-skia`:

```tsx
<Canvas
  style={{
    flex: 1,
    backgroundColor:
      appCurrentState === "alarm"
        ? colors.alarmBackground   // #06101A (dark)
        : colors.background,       // #9DB0B9 (teal)
  }}
>
  {/* Red Ball — static background indicator */}
  <Ball cx={ballX} cy={cy} r={...} red={true} border={false} visible={showRedBall} ... />

  {/* Blue Ball — interactive foreground element, wrapped in transform Group */}
  <Group transform={derivedTransform} origin={derivedScaledOrigin}>
    <Ball cx={ballXV} cy={y} r={ballRadius} red={false} icon={iconVisibility} ... />
  </Group>

  {/* Skia Paragraph — ball text labels */}
  <Paragraph paragraph={paragraph} x={textAlignX} y={textAlign} width={240} />

  {/* Arrow icons — normal and alarm variants */}
  <SkiaImage ... /> {/* down arrow (normal) */}
  <SkiaImage ... /> {/* up arrow (normal) */}
  <SkiaImage ... /> {/* down arrow (alarm, blue) */}
  <SkiaImage ... /> {/* up arrow (alarm, blue) */}
</Canvas>
```

Everything inside this Canvas — the balls, text, icons — is a **Skia draw call**, not a React Native View. This means:

- All rendering is GPU-accelerated via Skia's hardware backend
- No layout passes, no bridge serialization, no Yoga layout engine involvement
- SharedValue updates propagate directly to Skia draw parameters on the UI thread
- The only React Native Views outside the Canvas are the gesture detector overlay and the conditional `TouchableOpacity` buttons for disconnect/alarm actions

### Background Color Switching

The Canvas background color switches based on `appCurrentState`:

| State | Color | Hex | Effect |
|---|---|---|---|
| Normal (disconnected, scanning, connecting, connected, synced) | Teal | `#9DB0B9` | Default calming background |
| Alarm | Dark | `#06101A` | High-contrast dark mode for emergency state |

This is set via React Native `style.backgroundColor` on the Canvas element itself — the only property that crosses the bridge, since it's a standard RN style prop. The alarm background creates visual contrast against the white alarm text and blue alarm arrows.

Source: `utils/theme/Theme.ts` — `colors.background` and `colors.alarmBackground`.

---

## 2. Ball Component Deep-Dive

The `Ball` component is a functional component defined at the top of `app/index.tsx` (lines 65-251). It accepts only SharedValue props — no primitive values — ensuring the entire Ball can update without triggering React re-renders.

### Props Interface

```tsx
const Ball: React.FC<{
  cx: SharedValue<number>;       // Center X coordinate
  cy: SharedValue<number>;       // Center Y coordinate
  r: SharedValue<number>;        // Base radius
  text?: SharedValue<string[]>;  // (unused internally — text rendered by parent's Paragraph)
  red?: SharedValue<boolean>;    // true = red ball variant, false = blue/black
  border: SharedValue<boolean>;  // Whether to show border stroke
  appState: SharedValue<AppConnectionState>;  // BLE connection state
  visible?: SharedValue<boolean>;  // Visibility toggle (opacity 0 or 1)
  icon?: SharedValue<boolean>;  // Whether to show the swipe-down icon
}>
```

### 2.1 Pulsing Animation System

The Ball has **two independent pulse animations** — an inner pulse and an outer pulse — each with different timing and easing characteristics.

#### Inner Pulse (`pulseFactor`)

```tsx
const pulseFactor = useSharedValue(1);

useEffect(() => {
  pulseFactor.value = withDelay(
    red?.value ? 315 : 0,  // 315ms delay for red ball, 0 for blue
    withRepeat(
      withTiming(1.1, {
        duration: 2500,
        easing: Easing.inOut(Easing.ease),
      }),
      -1,    // infinite repeat
      true,  // reverse (creates ping-pong: 1 -> 1.1 -> 1 -> ...)
    ),
  );
}, []);
```

- **Range:** 1.0 to 1.1 (10% pulse amplitude)
- **Duration:** 2500ms per half-cycle (5s full cycle)
- **Easing:** `Easing.inOut(Easing.ease)` — smooth acceleration and deceleration
- **Delay:** 315ms for red ball (staggered visual), 0ms for blue ball
- **Behavior:** Reverses at each end, creating a smooth oscillation

#### Outer Pulse (`pulseFactorOutter`)

```tsx
const pulseFactorOutter = useSharedValue(1);

useEffect(() => {
  pulseFactorOutter.value = withDelay(
    red?.value ? 315 : 0,
    withRepeat(
      withTiming(1.3, {
        duration: 1160,
        easing: Easing.in(Easing.ease),
      }),
      -1,
      false,  // does NOT reverse — resets to 1 each cycle (sawtooth-like)
    ),
  );
}, []);
```

- **Range:** 1.0 to 1.3 (30% pulse amplitude)
- **Duration:** 1160ms per cycle
- **Easing:** `Easing.in(Easing.ease)` — slow start, fast finish
- **Delay:** 315ms for red ball, 0ms for blue
- **Behavior:** Does NOT reverse — animates 1 -> 1.3 then snaps back to 1. This creates a more aggressive, heartbeat-like rhythm that contrasts with the inner pulse's smooth oscillation

### 2.2 State-Dependent Radius (`pulsingRadius`)

The effective radius of the ball changes dramatically based on the BLE connection state:

```tsx
const pulsingRadius = useDerivedValue(() => {
  switch (appState.value) {
    case "disconnected":
    case "connecting":
    case "scanning":
      return r.value;                              // Base radius only
    case "connected":
    case "synced":
      return r.value * pulseFactor.value * 2;      // 2x base with pulse
    case "alarm":
      return r.value * pulseFactor.value * 5;      // 5x base with pulse
    default:
      return r.value;
  }
}, [r, pulseFactor, appState]);
```

| State | Effective Radius | Visual Effect |
|---|---|---|
| `disconnected` | `r` (static) | Small, still ball |
| `connecting` | `r` (static) | Small, still ball |
| `scanning` | `r` (static) | Small, still ball |
| `connected` | `r * pulseFactor * 2` | Medium, pulsing (r to r*2.2) |
| `synced` | `r * pulseFactor * 2` | Medium, pulsing (r to r*2.2) |
| `alarm` | `r * pulseFactor * 5` | Massive, pulsing (r to r*5.5) |

With the default radius of 86 (small breakpoint) or 96 (medium+), this means:
- Disconnected: 86-96px radius
- Synced: 172-211px radius (pulsing)
- Alarm: 430-528px radius (pulsing) — fills most of the screen

#### Outer Stroke Radius (`pulsingRadiusOutter`)

A second, slightly larger circle follows a separate pulse:

```tsx
const pulsingRadiusOutter = useDerivedValue(() => {
  switch (appState.value) {
    case "disconnected":
    case "connecting":
    case "scanning":
      return r.value * pulseFactorOutter.value;         // Pulsing even in disconnected
    case "connected":
    case "synced":
      return r.value * pulseFactorOutter.value * 2;     // 2x with outer pulse
    case "alarm":
      return r.value * pulseFactorOutter.value * 5;     // 5x with outer pulse
    default:
      return r.value;
  }
}, [r, pulseFactor, appState]);
```

The outer stroke uses the faster, non-reversing pulse (`pulseFactorOutter`), creating a visual "echo" or "ripple" effect around the main ball. When the ball is in `disconnected`/`connecting`/`scanning` state, the outer stroke still pulses (the main ball does not), providing subtle visual feedback that the app is active.

### 2.3 Inner Stroke Radius

```tsx
const innerStrokeRadius = useDerivedValue(() => {
  return appState.value === "disconnected"
    ? r.value - 20                                    // Static inset
    : pulsingRadius.value - 20;                       // Pulsing inset
}, [pulsingRadius.value, appState.value, r.value]);
```

Always 20 units smaller than the main radius. When disconnected, it's static. Otherwise, it tracks the pulsing radius.

### 2.4 Outer Stroke Opacity

```tsx
const outterStrokeOpacity = useDerivedValue(() => {
  return appState.value === "scanning" || appState.value === "connecting"
    ? interpolate(pulseFactorOutter.value, [1, 1.2], [1, 0.2])
    : 0;
}, [appState.value, pulseFactorOutter.value]);
```

- **Visible only** during `scanning` or `connecting` states
- Opacity fades from 1.0 to 0.2 as the outer pulse progresses (interpolated from `pulseFactorOutter` value 1 to 1.2)
- This creates a "breathing ring" effect during the connection attempt

### 2.5 Border Color Logic

```tsx
const borderColor = useDerivedValue(() => {
  if (border?.value) {
    return appState.value !== "disconnected" ? "#BCD4DF" : "#889AA2";
  } else {
    return "transparent";
  }
}, [border?.value, appState]);
```

| Condition | Color | Hex | Visual |
|---|---|---|---|
| `border=true` + not disconnected | Light blue | `#BCD4DF` | Active border |
| `border=true` + disconnected | Muted gray | `#889AA2` | Inactive border |
| `border=false` | Transparent | — | No border visible |

### 2.6 ImageShader Texture Mapping

The Ball's circular surface is filled using an `ImageShader` that maps a dot texture onto the Circle:

```tsx
const redDot = useImageAsTexture(require("@assets/images/dots/red-dot.png"));
const blackDot = useImageAsTexture(require("@assets/images/dots/black-dot.png"));

const imageSelected = useDerivedValue(() => {
  return red?.value ? true : appState.value === "alarm";
}, [red?.value, appState.value]);
```

The texture selection logic:
- **Red dot:** When `red=true` (the red ball variant) OR when `appState === "alarm"`
- **Black dot:** All other states (the blue ball displays a black/dark texture)

The ImageShader is configured with specific positioning:

```tsx
const imageShaderX = useDerivedValue(() => cx.value - 215, [cx.value]);
const imageShaderY = useDerivedValue(() => {
  return red?.value ? cy.value - 258 : cy?.value - 258;
}, [cy.value, red?.value]);

const ballOrigin = useDerivedValue(() => vec(215, 258), [cy.value, cx.value]);
```

The origin `vec(215, 258)` corresponds to the center of the 430x516 texture image. The shader offsets (`cx - 215`, `cy - 258`) position the texture so its center aligns with the circle's center. The `fit="fill"` prop stretches the texture to cover the circle.

```tsx
<ImageShader
  fit={"fill"}
  image={imageSelected.value ? redDot : blackDot}
  y={imageShaderY}
  x={imageShaderX}
  origin={ballOrigin}
  transform={pulsingScale}     // [{ scale: 1 }] — currently static
  width={430}
  height={516}
/>
```

The `pulsingScale` is defined as `[{ scale: 1 }]` — effectively a no-op transform, likely a placeholder for a future animation.

### 2.7 Blur Effect

```tsx
<Blur blur={22} />
```

Applied as a child of the textured Circle, this applies a Gaussian blur with radius 22 to the entire filled circle. This softens the dot texture, creating a diffused, atmospheric glow effect rather than a sharp-edged circle. The blur is constant — it does not animate.

### 2.8 Circle Primitives (Strokes)

Two stroke-only circles are rendered on top of the filled ball:

```tsx
{/* Inner stroke — always visible (when border=true) */}
<Group style="stroke" strokeWidth={0.6}>
  <Circle cx={cx} cy={cy} r={innerStrokeRadius} color={borderColor} />
</Group>

{/* Outer stroke — visible only during scanning/connecting */}
<Group style="stroke" strokeWidth={0.75} opacity={outterStrokeOpacity}>
  <Circle cx={cx} cy={cy} r={outterStrokeRadius} color={"#BCD4DF"} />
</Group>
```

- **Inner stroke:** 0.6px width, color driven by `borderColor` (BCD4DF or 889AA2)
- **Outer stroke:** 0.75px width, hardcoded `#BCD4DF`, fades in/out via `outterStrokeOpacity`

### 2.9 Swipe-Down Icon (Inside Ball)

```tsx
const iconImage = useImageAsTexture(
  require("@assets/images/down_arrow_swipe/down_arrow_swipe.png"),
);

const iconAlignX = useDerivedValue(() => cx.value - 5, [cx.value]);
const iconAlignY = useDerivedValue(() => cy.value + 30, [cy.value]);
const iconOpacity = useDerivedValue(() => (icon?.value ? 1 : 0), [icon]);
```

The swipe-down icon is positioned 5px left and 30px below the ball center. It's a `SkiaImage` element (10x18px) that bounces vertically via `iconAnimationValue`:

```tsx
const iconAnimationValue = useSharedValue(-2);
useEffect(() => {
  iconAnimationValue.value = withRepeat(
    withSequence(
      withTiming(2, { duration: 1000 }),
      withTiming(-2, { duration: 1000 }),
    ),
    -1,
    true,
  );
}, []);

const iconAnimationY = useDerivedValue(() => {
  return iconAlignY.value + iconAnimationValue.value;
}, [iconAlignY.value, iconAnimationValue.value]);
```

The icon bounces 4px total (from -2 to +2) over a 2-second cycle. It's only visible when `icon=true`, which corresponds to the `disconnected` state where the user is being prompted to swipe.

### 2.10 Visibility

```tsx
const isVisible = useDerivedValue(() => (visible?.value ? 1 : 0), [visible]);
```

The entire Ball group is wrapped in `<Group opacity={isVisible}>`, providing instant show/hide without unmounting.

---

## 3. Skia Paragraph Text Rendering

Text is rendered using Skia's `Paragraph` API — not React Native `<Text>` components. This keeps text rendering on the GPU alongside all other Canvas elements.

### Paragraph Construction

```tsx
const paragraph = useDerivedValue(() => {
  if (!fontMgr || !ballText) return null;

  const builder = Skia.ParagraphBuilder.Make(
    {
      textAlign: TextAlign.Center,
      textHeightBehavior: TextHeightBehavior.All,
      textStyle: {
        fontSize: appState.value === "alarm" ? 14 : 11,
        fontFamilies: ["BasicSans"],
      },
    },
    fontMgr,
  );

  for (const line of ballText.value) {
    if (line.toLowerCase().trimStart().startsWith("f:")) {
      // Custom font size line
      const lineData = line.split("<>");
      let fontSize = parseInt(lineData[0].split(":")[1]);
      fontSize = breakpoint === "sm" ? fontSize - 1 : fontSize;

      builder.pushStyle({
        fontSize: fontSize,
        color: Skia.Color(appState.value === "alarm" ? "#D9D9D9" : colors.background),
        fontFamilies: ["BasicSans"],
      });
      const lineText = lineData[1];
      builder.addText(lineText.toUpperCase() + "\n");
    } else {
      // Default font size line
      builder.pushStyle({
        fontSize: breakpoint === "sm" ? 10 : 11,
        color: Skia.Color(appState.value === "alarm" ? "#D9D9D9" : colors.background),
        fontFamilies: ["BasicSans"],
      });
      builder.addText(line.toUpperCase() + "\n");
    }
  }

  const para = builder.build();
  para.layout(240);
  return para;
}, [fontMgr, ballText, ballText?.value, appState]);
```

### The "f:" Prefix System

Text lines in `ballText` support a custom font-size prefix:

**Standard line:**
```
SWIPE UP TO CONNECT
```
Rendered at default fontSize (11 on medium+, 10 on small breakpoint).

**Custom font-size line:**
```
f:24<>SOME TEXT
```
Parsed as:
- `f:24` -> fontSize = 24 (adjusted to 23 on small breakpoint)
- `<>` delimiter
- `SOME TEXT` -> the actual text content

This allows individual lines within the same paragraph to have different sizes — useful for hierarchical text display (e.g., a large number with a smaller label beneath it).

### Text Configuration

| Property | Value | Notes |
|---|---|---|
| Font family | `BasicSans` (SemiBold) | Loaded via `useFonts` from `@assets/fonts/BasicSans-SemiBold.otf` |
| Default fontSize | 11 (medium+), 10 (small) | Scales down on small devices |
| Alarm fontSize | 14 | Larger for readability in emergency state |
| Text alignment | `TextAlign.Center` | Centered within the layout box |
| Layout width | 240px | Fixed width for paragraph layout |
| Text transform | `.toUpperCase()` | All text is uppercased |
| Line separator | `\n` | Each line gets a trailing newline |
| Text height behavior | `TextHeightBehavior.All` | Full height metrics |

### Text Color Logic

```tsx
color: Skia.Color(appState.value === "alarm" ? "#D9D9D9" : colors.background)
```

- **Alarm state:** Light gray `#D9D9D9` — high contrast against dark `#06101A` background
- **Normal states:** `colors.background` = `#9DB0B9` — the same teal as the background, creating a subtle embossed/inset effect against the dark ball texture

### Text Positioning

```tsx
const textAlign = useDerivedValue(
  () =>
    y.value -
    (appState.value === "disconnected"
      ? lang?.startsWith("de") ? 24 : 16
      : 16),
  [y.value, appState.value],
);

const textAlignX = useDerivedValue(() => ballXV.value - 120, [ballXV.value]);
```

- **X position:** Centered on the ball (`ballXV - 120`, since layout width is 240: `center - 240/2 = center - 120`)
- **Y position:** Offset from the ball's Y center. When disconnected, German text gets an extra 8px downward offset (`-24` vs `-16`) to accommodate potentially longer German translations

The Paragraph is rendered at the Canvas level, above both balls:

```tsx
<Paragraph paragraph={paragraph} x={textAlignX} y={textAlign} width={240} />
```

### ballText Source

The `ballText` SharedValue is populated from i18n translations, split by pipe character:

```tsx
const ballText = useSharedValue(t("ui_state_swipe").split("|"));
```

Each state transition updates `ballText` with the appropriate translation:
- Disconnected: `t("ui_state_swipe").split("|")`
- Scanning: `t("ui_state_scanning").split("|")`
- Connecting: `t("ui_state_connecting").split("|")`
- Synced: `t("ui_state_synced").split("|")`
- Alarm: `[t("ui_state_alarm")]` (single line, no pipe split)

---

## 4. Image Loading Pipeline

### useImageAsTexture

All image assets are loaded via `useImageAsTexture` from `@shopify/react-native-skia`, which returns a Skia `SkImage` suitable for use in `ImageShader` or `SkiaImage` elements.

| Variable | Asset Path | Used By | Size |
|---|---|---|---|
| `redDot` | `@assets/images/dots/red-dot.png` | Ball ImageShader (red variant + alarm) | 430x516 |
| `blackDot` | `@assets/images/dots/black-dot.png` | Ball ImageShader (blue variant, normal) | 430x516 |
| `iconImage` | `@assets/images/down_arrow_swipe/down_arrow_swipe.png` | Ball swipe-down icon | 10x18 |
| `iconImageUp` | `@assets/images/arrow_up.png` | Arrow icon (up, normal) | 10x18 |
| `iconImageDown` | `@assets/images/arrow_down.png` | Arrow icon (down, normal) | 10x18 |
| `iconBlueImageUp` | `@assets/images/alarm_arrows/Frame16.png` | Arrow icon (up, alarm) | 10x18 |
| `iconBlueImageDown` | `@assets/images/alarm_arrows/Frame14.png` | Arrow icon (down, alarm) | 10x18 |

### useFonts

```tsx
const fontMgr = useFonts({
  BasicSans: [require("@assets/fonts/BasicSans-SemiBold.otf")],
});
```

A single font family is loaded — `BasicSans` in SemiBold weight. The `fontMgr` object is passed to `Skia.ParagraphBuilder.Make()` for text rendering. If the font fails to load, the paragraph returns `null` and no text is displayed.

### Asset Loading Timing

All `useImageAsTexture` and `useFonts` calls happen at component mount. Skia handles async GPU texture upload internally. During the brief loading window, textures may be null/placeholder — the Canvas renders with what's available.

---

## 5. The Dual Ball System

CAMDOM uses two Ball instances layered in the same Canvas:

### Red Ball (Static Background)

```tsx
<Ball
  cx={ballX}           // width / 2 (fixed horizontal center)
  cy={cy}              // height - height/3 + 20 (lower third of screen)
  r={useSharedValue(defaultBallRadius)}  // 86 or 96
  red={useSharedValue(true)}             // Uses red-dot.png texture
  border={useSharedValue(false)}         // No border stroke
  visible={showRedBall}                  // Visible only when disconnected
  appState={appState}
/>
```

- **Purpose:** Visual indicator of the "resting" state. Visible when the device is disconnected and the blue ball hasn't been swiped yet.
- **Texture:** `red-dot.png` (always, since `red=true`)
- **Position:** Lower third of screen (`height - height/3 + 20`)
- **Radius:** Static, no pulsing (the pulse animation has the 315ms delay, but since `r` is a fresh `useSharedValue` with `defaultBallRadius` and the pulsing formula returns `r` for disconnected state, it stays static)
- **No border:** `border=false`
- **Visibility:** Controlled by `showRedBall` — `true` when disconnected, `false` when scanning/connecting/synced

### Blue Ball (Interactive Foreground)

```tsx
<Group transform={derivedTransform} origin={derivedScaledOrigin}>
  <Ball
    cx={ballXV}         // Derived from ballPosition (width/2 when center)
    cy={y}              // Animated Y — moves with gesture
    r={ballRadius}      // Animated radius (86-360 depending on state)
    red={useSharedValue(false)}         // Uses black-dot.png texture
    icon={iconVisibility}              // Shows swipe-down icon when disconnected
    border={borderVisibility}           // Border visible in most states
    visible={useSharedValue(true)}      // Always visible
    appState={appState}
  />
</Group>
```

- **Purpose:** The main interactive element. The user drags this ball to initiate BLE connection.
- **Texture:** `black-dot.png` in normal states, switches to `red-dot.png` during alarm (via `imageSelected` derived value)
- **Position:** Animated via `y` SharedValue (driven by gesture and spring animations)
- **Radius:** Dynamically animated between 86-360 based on connection state
- **Visibility:** Always visible (the parent Group's `derivedTransform` handles visual scaling)

### Layering Order

1. Red Ball (bottom) — behind everything
2. Blue Ball (top) — wrapped in `derivedTransform` Group
3. Paragraph text — on top of both balls
4. Arrow icons — on top of everything

### Scale Transform on Blue Ball

The blue ball is wrapped in a Group with a derived scale transform:

```tsx
const derivedTransform = useDerivedValue(() => {
  const baseScale = 1;
  const scale = interpolate(ballRadius.value, [78, 360], [baseScale, 3]);
  return [{ scale: scale }];
}, [ballRadius.value]);

const derivedScaledOrigin = useDerivedValue(() => {
  return vec(ballXV.value, y.value);
}, [y.value, ballXV.value]);
```

This scales the **entire Ball component** (including its stroke circles and icon) based on the `ballRadius`:

| ballRadius | Scale | Effect |
|---|---|---|
| 78 | 1.0 | Base size |
| 219 | 2.0 | Medium |
| 360 | 3.0 | Maximum |

The origin is set to the ball's current position (`ballXV, y`), ensuring scaling happens from the ball's center rather than from the Canvas origin.

This creates a compounding effect: the inner `pulsingRadius` grows the Circle primitive, while the outer `derivedTransform` scales the entire component. The net visual result is the ball appearing to expand dramatically as it transitions from disconnected to synced to alarm.

### Gesture Interaction

The blue ball's Y position is driven by a Pan gesture:

```tsx
const gesture = Gesture.Pan()
  .onStart((e) => {
    shouldBlock.value = /* true if already connected/alarm/synced/scanning/connecting */;
    runOnJS(Haptics.impactAsync)(ImpactFeedbackStyle.Soft);
  })
  .onChange((e) => {
    if (shouldBlock.value) return;
    y.value += e.changeY;
    y.value = clamp(y.value, height / 4, redBallInitialY);
  })
  .onEnd((e) => {
    if (shouldBlock.value) return;
    if (e.absoluteY >= 500) {
      // Swipe down detected — start scanning
      y.value = withSpring(redBallInitialY, { damping: 100 }, () => {
        showRedBall.value = false;
        appState.value = "scanning";
      });
    } else {
      // Incomplete swipe — snap back
      showRedBall.value = true;
      y.value = withSpring(blueBallInitialY, { damping: 100 }, () => {
        appState.value = "disconnected";
      });
    }
  });
```

The gesture detector is a separate React Native `Animated.View` overlaid on top of the Canvas, positioned at the ball's current location:

```tsx
const animatedStyleForGestureHandler = useAnimatedStyle(() => ({
  position: "absolute",
  top: -gestureRadius,              // -100
  left: width / 2 - gestureRadius,  // centered horizontally
  width: gestureRadius * 2,         // 200px touch area
  height: gestureRadius * 2,        // 200px touch area
  borderRadius: gestureRadius,      // circular touch area
  zIndex: 1000,
  transform: [{ translateY: y.value }],  // tracks ball position
}));
```

This 200x200 circular touch area follows the ball's Y position and captures pan gestures. The transparent `Animated.View` sits above the Canvas but below the conditional buttons (also at zIndex 1000 but rendered later in the tree).

---

## 6. State Machine & Visual Transitions

### Complete State Table

| State | Ball Y | Ball Radius | Text | Background | Icons (normal) | Icons (alarm) | Border | Sound | Haptics |
|---|---|---|---|---|---|---|---|---|---|
| `disconnected` | `blueBallInitialY` (h/3+20) | default (86/96) | Swipe prompt | Teal | Swipe-down | — | Visible | — | — |
| `scanning` | redBallInitialY (h-h/3+20) | default | Scanning text | Teal | Hidden | — | Visible | "open" | — |
| `connecting` | — | default | Connecting text | Teal | Hidden | — | Visible | — | — |
| `migrating` | redBallInitialY | default | Connecting text | Teal | Hidden | — | Visible | "open" | — |
| `connected` | h/2 | default | Syncing text | Teal | Hidden | — | Visible | — | — |
| `synced` | h/2 | 300->360 oscillating | Synced text | Teal | Hidden | Visible (normal) | Visible | "pair" | — |
| `alarm` | h/2 | 310->360 pulsing | Alarm text | Dark | — | Visible (blue) | — | "alarm" | Error notification |

### Radius Animation per State

**Synced state:**
```tsx
ballRadius.value = 300;
ballRadius.value = withRepeat(
  withTiming(360, {
    easing: Easing.inOut(Easing.linear),
    duration: 1900,
  }),
  -1,   // infinite
  true, // reverse
);
```
Oscillates between 300 and 360 over 3.8 seconds (1.9s each direction), linear easing. Creates a slow "breathing" effect when the device is synced and communicating.

**Alarm state:**
```tsx
ballRadius.value = 310;
ballRadius.value = withRepeat(
  withSequence(
    withTiming(360, { duration: 300 }, () => {
      runOnJS(Haptics.notificationAsync)(NotificationFeedbackType.Error);
    }),
    withTiming(310, { duration: 500 }, () => {
      runOnJS(Haptics.notificationAsync)(NotificationFeedbackType.Error);
    }),
  ),
  -1,
  false,
);
```
Oscillates between 310 and 360 — faster expansion (300ms), slower contraction (500ms). Triggers error haptic feedback at each peak and trough. Does NOT reverse (resets to start each cycle), creating an asymmetric pulsing rhythm.

---

## 7. Arrow Icon Animation System

### Normal Arrows (White/Teal)

Two arrow icons are rendered at the Canvas level (outside the Ball component):

```tsx
{/* Down arrow — positioned above the blue ball */}
<SkiaImage
  opacity={iconsOpacity}
  image={iconImageDown}
  x={iconsAlignX}        // ballXV - 6
  y={iconsAlignYTop}     // y - 75 + bounce
  width={10}
  height={18}
/>

{/* Up arrow — positioned below the blue ball */}
<SkiaImage
  opacity={iconsOpacity}
  image={iconImageUp}
  x={iconsAlignX}        // ballXV - 6
  y={iconsAlignYBot}     // y + 55 + bounce
  width={10}
  height={18}
/>
```

### Alarm Arrows (Blue)

A separate set of arrows renders when `appState === "alarm"`:

```tsx
{/* Blue down arrow */}
<SkiaImage
  opacity={iconsBlueOpacity}   // 1 when alarm, 0 otherwise
  image={iconBlueImageDown}    // Frame14.png
  x={iconsAlignX}
  y={iconsAlignYTop}           // Same Y as normal arrows
  width={10}
  height={18}
/>

{/* Blue up arrow */}
<SkiaImage
  opacity={iconsBlueOpacity}
  image={iconBlueImageUp}      // Frame16.png
  x={iconsAlignX}
  y={iconsAlignYBot}
  width={10}
  height={18}
/>
```

### Arrow Positioning

```tsx
const iconsAlignX = useDerivedValue(() => ballXV.value - 6, [ballXV.value]);

const iconsAlignYBotAnimated = useSharedValue(0);
useEffect(() => {
  iconsAlignYBotAnimated.value = withRepeat(
    withTiming(4, { duration: 300 }),
    -1,
    true,
  );
}, []);

const iconsAlignYBot = useDerivedValue(
  () => y.value + 55 + iconsAlignYBotAnimated.value,
  [y.value, iconsAlignYBotAnimated],
);

const iconsAlignYTop = useDerivedValue(
  () => y.value - 75 + iconsAlignYBotAnimated.value * -1,
  [y.value, iconsAlignYBotAnimated],
);
```

- **X alignment:** 6px left of ball center
- **Bottom arrow:** 55px below ball center + bounce animation (0 to 4px)
- **Top arrow:** 75px above ball center + inverse bounce animation (0 to -4px)
- The bounce is synchronized but **inverted** between top and bottom arrows — when one moves down, the other moves up. Duration: 300ms, infinite ping-pong.

### Arrow Opacity Logic

```tsx
const iconsOpacity = useDerivedValue(
  () =>
    appState.value === "alarm"
      ? 0                                                          // Hide normal arrows during alarm
      : bottomAndTopIconVisibility?.value ? 1 : 0,                 // Show when synced
  [bottomAndTopIconVisibility.value, appState.value],
);

const iconsBlueOpacity = useDerivedValue(
  () => (appState.value === "alarm" ? 1 : 0),                     // Show blue arrows only during alarm
  [appState.value],
);
```

| State | Normal Arrows | Blue Arrows |
|---|---|---|
| Disconnected | Hidden | Hidden |
| Scanning/Connecting | Hidden | Hidden |
| Connected | Hidden | Hidden |
| Synced | Visible | Hidden |
| Alarm | Hidden | Visible |

---

## 8. NoisyBlurredBackground Component

A separate component (`components/NoisyBlurredBackground.tsx`) provides an animated blurred background effect, used outside the main Canvas.

### Architecture

The component renders its own `<Canvas>` with two `AnimatedBall` sub-components:

```tsx
<Canvas
  mode={"default"}
  style={{
    position: "absolute",
    top: 0, bottom: 0, left: 0, right: 0,
    zIndex: -2,
    backgroundColor: colors.background,
  }}
>
  <Group opacity={balls ? 1 : 0} transform={[{ translateY: -40 }]}>
    <AnimatedBall animationDuration={6000} size={120} red={true} ... />
    <Group transform={displace ? [{ translateX: 80 }] : undefined}>
      <AnimatedBall animationDuration={5000} size={124} red={false} ... />
    </Group>
  </Group>
</Canvas>
```

### AnimatedBall (Internal)

Each `AnimatedBall` uses the same `ImageShader` + `Blur` pattern as the main Ball component:

```tsx
<Circle cx={...} cy={...} r={r}>
  <ImageShader
    fit="cover"
    image={red ? redDot : blackDot}
    y={red ? height / 5 : -30}
    x={displaceX ? (red ? 200 : 130) : 0}
    width={300}
    height={900}
  />
  <Blur blur={blur ?? 40} />
</Circle>
```

Key differences from the main Ball:
- **Blur radius:** Default 40 (vs 22 in the main Ball) — more diffuse, creating ambient background texture
- **Image size:** 300x900 (vs 430x516 in main Ball) — taller texture for background effect
- **fit:** `"cover"` (vs `"fill"` in main Ball) — preserves aspect ratio, may crop edges
- **Animation:** Independent pulse between `size` and `size * 1.4` (vs the main Ball's state-driven pulse)
- **Red ball animation duration:** 6000ms (vs 2500ms for main Ball inner pulse)

### Props Interface

```tsx
interface AnimatedBallProps {
  red?: boolean;          // Red vs black texture
  size: number;           // Base radius
  animationDuration?: number;  // Pulse speed
  displaceX?: boolean;    // Horizontal offset
  displaceY?: boolean;    // Vertical offset
  blur?: number;          // Blur radius (default 40)
  opacity?: number;       // Group opacity
}
```

### Usage

```tsx
<NoisyBlurredBackground
  balls={boolean}   // Whether to show the animated balls
  size={number}     // Base size override
  displace={boolean} // Horizontal displacement
  darken={boolean}   // Dark mode flag (prop defined but not used in current code)
  opacity={number}   // Override opacity
  blur={number}      // Override blur radius
/>
```

This component is used on secondary screens (not the main Ball screen) to provide animated, atmospheric backgrounds with the same visual language as the main app.

---

## 9. Performance Architecture

### Why Skia Canvas Instead of React Native Views

The entire main screen is a single Skia Canvas rather than nested React Native Views. This provides:

1. **GPU-accelerated rendering:** Skia renders via Metal (iOS) or OpenGL/Vulkan (Android), keeping all drawing on the GPU
2. **No bridge crossings:** SharedValues update Skia draw parameters directly on the UI thread. A traditional React Native approach would require `Animated.Value` -> bridge -> layout recalculation -> render
3. **No Yoga layout:** Skia Canvas doesn't use Flexbox layout. Positioning is explicit (x, y coordinates), avoiding the overhead of Yoga layout calculations
4. **Compositing efficiency:** Multiple overlapping circles with blur and image shaders are composited in a single render pass by Skia's GPU backend, whereas multiple React Native Views with shadows, borders, and transforms would each create separate render layers

### SharedValue-Driven Pipeline

The rendering chain is entirely on the UI thread:

```
Reanimated SharedValue (appState, ballRadius, y, etc.)
  -> useDerivedValue (pulsingRadius, borderColor, paragraph, etc.)
    -> Skia Canvas primitives (Circle, ImageShader, Paragraph, SkiaImage)
      -> GPU rasterization
```

No step in this chain crosses the React Native bridge. The only bridge crossings are:
- Initial asset loading (`useImageAsTexture`, `useFonts`) — one-time
- `runOnJS` calls in animation callbacks (haptics, Snackbar, state management) — infrequent
- The Canvas `backgroundColor` style prop — changes only on alarm state transitions
- `setAppCurrentState` via `useAnimatedReaction` — bridges appState to React state for conditional rendering of buttons

### Touch Handling

The gesture detector is a separate `Animated.View` overlaid on the Canvas — this is necessary because Skia Canvas doesn't handle touch events natively in this configuration. The `Animated.View` is transparent and circular, positioned to track the ball's location. This is a minimal bridge crossing (gesture events -> `y.value` SharedValue -> Skia render), keeping the hot path on the UI thread.

### Memory Considerations

- 7 image textures loaded via `useImageAsTexture` (dot textures, arrow icons)
- 1 font family loaded via `useFonts`
- All textures persist for the lifetime of the component (no dynamic loading/unloading)
- The dot textures (430x516) are the largest assets; arrow icons are small (10x18 display size)

---

## 10. Animation Inventory

### SharedValue Animations in Ball Component

| SharedValue | Range | Duration | Easing | Repeat | Reverse | Delay | Purpose |
|---|---|---|---|---|---|---|---|
| `pulseFactor` | 1 -> 1.1 | 2500ms | inOut ease | infinite | yes | 315ms (red) | Inner ball pulse |
| `pulseFactorOutter` | 1 -> 1.3 | 1160ms | in ease | infinite | no | 315ms (red) | Outer stroke pulse |
| `iconAnimationValue` | -2 -> 2 | 1000ms | linear (default) | infinite | yes (via sequence) | none | Swipe icon bounce |

### SharedValue Animations in App Component

| SharedValue | Range | Duration | Easing | Repeat | Reverse | Purpose |
|---|---|---|---|---|---|---|
| `ballRadius` (synced) | 300 -> 360 | 1900ms | inOut linear | infinite | yes | Synced ball breathing |
| `ballRadius` (alarm) | 310 -> 360 | 300ms up / 500ms down | default | infinite | no (sequence) | Alarm pulse + haptics |
| `iconsAlignYBotAnimated` | 0 -> 4 | 300ms | linear (default) | infinite | yes | Arrow icon bounce |
| `y` (spring) | various | spring (damping: 100) | spring | once | — | Ball position transitions |

### Derived Values (Computed, Not Animated)

| Derived Value | Formula | Dependencies |
|---|---|---|
| `pulsingRadius` | State-dependent: `r`, `r*pulseFactor*2`, or `r*pulseFactor*5` | r, pulseFactor, appState |
| `pulsingRadiusOutter` | Same pattern with pulseFactorOutter | r, pulseFactor, appState |
| `innerStrokeRadius` | `pulsingRadius - 20` or `r - 20` | pulsingRadius, appState, r |
| `outterStrokeRadius` | `pulsingRadiusOutter - 20` | pulsingRadiusOutter |
| `outterStrokeOpacity` | `interpolate(pulseFactorOutter, [1, 1.2], [1, 0.2])` or `0` | appState, pulseFactorOutter |
| `borderColor` | `#BCD4DF` / `#889AA2` / `transparent` | border, appState |
| `imageSelected` | `red \|\| alarm` | red, appState |
| `isVisible` | `visible ? 1 : 0` | visible |
| `imageShaderX` | `cx - 215` | cx |
| `imageShaderY` | `cy - 258` | cy |
| `ballOrigin` | `vec(215, 258)` | — (constant) |
| `iconAlignX` | `cx - 5` | cx |
| `iconAlignY` | `cy + 30` | cy |
| `iconOpacity` | `icon ? 1 : 0` | icon |
| `iconAnimationY` | `iconAlignY + iconAnimationValue` | iconAlignY, iconAnimationValue |
| `derivedTransform` | `scale: interpolate(ballRadius, [78, 360], [1, 3])` | ballRadius |
| `derivedScaledOrigin` | `vec(ballXV, y)` | ballXV, y |
| `iconsAlignX` | `ballXV - 6` | ballXV |
| `iconsAlignYBot` | `y + 55 + bounce` | y, bounce |
| `iconsAlignYTop` | `y - 75 - bounce` | y, bounce |
| `iconsOpacity` | `alarm ? 0 : synced ? 1 : 0` | appState, bottomAndTopIconVisibility |
| `iconsBlueOpacity` | `alarm ? 1 : 0` | appState |
| `textAlignX` | `ballXV - 120` | ballXV |
| `textAlign` | `y - offset` (offset varies by state/language) | y, appState, lang |
| `paragraph` | Skia Paragraph (full text layout) | fontMgr, ballText, appState |
| `ballXV` | `width / 2` (when center) | ballPosition |
| `pulsingScale` | `[{ scale: 1 }]` | — (static, placeholder) |

---

## 11. Key Architectural Decisions

1. **No React Native Views for visual elements.** The entire visual layer is a single Skia Canvas. The only RN Views are gesture handlers and conditional buttons (which need touch event handling and accessibility that Skia doesn't provide).

2. **All state in SharedValues.** Even `ballText` (an array of strings) is a SharedValue, allowing the Paragraph builder to re-execute on the UI thread when text changes.

3. **Dual pulse system.** Two independent pulse animations with different speeds, easing curves, and reversal behaviors create organic, non-repetitive visual rhythms that feel alive rather than mechanical.

4. **Language-aware positioning.** The text Y-offset adjusts for German (`lang.startsWith("de")`) because German translations are typically 20-40% longer, requiring more vertical space.

5. **State-driven radius multiplication.** Rather than hard-coding different radius values for each state, the system uses a multiplier on the base radius (1x, 2x, 5x). This ensures proportional scaling across device sizes (the `defaultBallRadius` already adapts to breakpoints: 86 for small, 96 for medium+).

6. **Texture-based coloring.** Instead of using Skia fill colors, the ball uses ImageShader with pre-rendered dot textures. This allows for gradient-like coloring, noise patterns, and visual depth that would be complex to achieve with solid fills.

7. **Separate gesture overlay.** Touch handling is done via a transparent Animated.View rather than Skia's touch system, providing better compatibility with React Native Gesture Handler's gesture recognition (pan, long press, etc.).
