Juice with Fable

Amolith created

Change summary

AGENTS.md         |   1 
mise.toml         |   2 
src/c/main.c      | 359 ++++++++++++++++++++++++++++++++++++++++--------
src/pkjs/index.js | 163 +++++++++++++++++++++-
4 files changed, 452 insertions(+), 73 deletions(-)

Detailed changes

AGENTS.md ๐Ÿ”—

@@ -21,3 +21,4 @@ Use `mise run emu:up`, `emu:select`, `emu:down`, and `emu:back` for button input
 - The Plexus admin key belongs in phone-side PebbleKit JS storage only; never send it to the watch.
 - `mise run build` leaves `build/` and `.lock-waf_*_build`; they are ignored build artifacts.
 - PebbleKit JS here is written in ES5 style for Pebble compatibility: use `var` and functions, not modern syntax.
+- The watch row layout heights in `src/c/main.c` (`card_update_proc`: 42/28/50px rows, 3px separators) and the phone-side page packing in `src/pkjs/index.js` (`estimateRowHeightPx`, `PAGE_HEIGHT_BUDGET_*`) mirror each other; change them together.

mise.toml ๐Ÿ”—

@@ -70,6 +70,7 @@ run = "pebble analyze-size build/{{vars.platform}}/pebble-app.elf"
 
 [tasks.install]
 description = "Install the app in the Pebble emulator"
+depends = ["build"]
 run = "pebble install --emulator {{vars.platform}}"
 
 [tasks."emu:config"]
@@ -78,6 +79,7 @@ run = "pebble emu-app-config --emulator {{vars.platform}}"
 
 [tasks."install:cloudpebble"]
 description = "Install the app on a paired device through CloudPebble"
+depends = ["build"]
 run = "pebble install --cloudpebble"
 
 [tasks.screenshot]

src/c/main.c ๐Ÿ”—

@@ -2,7 +2,30 @@
 
 #define MAX_ROWS 4
 #define ANIMATION_INTERVAL_MS 33
-#define ANIMATION_FRAMES 12
+#define REFRESH_DOT_FRAMES 12
+#define SLIDE_OUT_FRAMES 4
+#define SLIDE_IN_FRAMES 5
+#define SLIDE_OUT_DISTANCE_PX 18
+#define SLIDE_IN_DISTANCE_PX 14
+#define BAR_FILL_FRAMES 10
+#define BAR_STAGGER_FRAMES 3
+#define BOUNCE_FRAMES 3
+#define BOUNCE_DISTANCE_PX 5
+#define STAMP_FRAMES 4
+
+// Page navigation choreography: the card content slides out in the travel
+// direction while we wait for the phone, the new card slides in from the
+// opposite side, then the quota bars pour in top-down. On failure the card
+// slides back into place. When the page order wraps around, the incoming
+// card overshoots and rubber-bands back to signal the seam in the loop.
+typedef enum {
+  MOTION_IDLE,
+  MOTION_SLIDE_OUT,
+  MOTION_WAITING,
+  MOTION_SLIDE_IN,
+  MOTION_BOUNCE,
+  MOTION_BARS,
+} MotionPhase;
 
 typedef struct {
   char label[24];
@@ -19,10 +42,16 @@ static AppTimer *s_animation_timer;
 static int s_page_index = 0;
 static int s_page_count = 0;
 static int s_row_count = 0;
-static int s_animation_frame = ANIMATION_FRAMES;
+static MotionPhase s_motion_phase = MOTION_IDLE;
+static int s_motion_frame = 0;
+static int s_slide_offset = 0;
+static int s_slide_from = 0;
+static int s_slide_to = 0;
+static int s_slide_direction = 1;
+static int s_bar_frame = BAR_FILL_FRAMES + BAR_STAGGER_FRAMES * (MAX_ROWS - 1);
 static int s_refresh_frame = 0;
-static int s_animation_direction = 1;
 static bool s_is_refreshing = false;
+static bool s_wrap_pending = false;
 
 static char s_title_text[40] = "Pebblexus";
 static char s_group_text[40] = "";
@@ -152,15 +181,58 @@ static GColor color_for_status_text(void) {
   return color_for_status(1);
 }
 
-static int current_slide_offset(void) { return 0; }
+static int ease_quad_in(int from, int to, int frame, int frame_count) {
+  if (frame <= 0)
+    return from;
+  if (frame >= frame_count)
+    return to;
+  int t = (frame * 100) / frame_count;
+  return from + ((to - from) * t * t) / 10000;
+}
+
+static int ease_quad_out(int from, int to, int frame, int frame_count) {
+  if (frame <= 0)
+    return from;
+  if (frame >= frame_count)
+    return to;
+  int t = (frame * 100) / frame_count;
+  return from - ((to - from) * t * (t - 200)) / 10000;
+}
+
+static int current_slide_offset(void) { return s_slide_offset; }
 
-static int current_bar_percent(int percent) {
+static int bar_cascade_total_frames(void) {
+  int rows = s_row_count > MAX_ROWS ? MAX_ROWS : s_row_count;
+  if (rows < 1)
+    rows = 1;
+  int cascade_frames = BAR_FILL_FRAMES + BAR_STAGGER_FRAMES * (rows - 1);
+  int stamp_end_frame = BAR_FILL_FRAMES + STAMP_FRAMES;
+  return cascade_frames > stamp_end_frame ? cascade_frames : stamp_end_frame;
+}
+
+static int current_bar_percent(int row_index, int percent) {
   int clamped = clamp_percent(percent);
-  if (s_animation_frame >= ANIMATION_FRAMES)
+  if (s_motion_phase == MOTION_SLIDE_IN || s_motion_phase == MOTION_BOUNCE)
+    return 0;
+  if (s_motion_phase != MOTION_BARS)
     return clamped;
-  int frame = s_animation_frame;
-  int eased = ANIMATION_FRAMES - (((ANIMATION_FRAMES - frame) * (ANIMATION_FRAMES - frame)) / ANIMATION_FRAMES);
-  return (clamped * eased) / ANIMATION_FRAMES;
+  int frame = s_bar_frame - (row_index * BAR_STAGGER_FRAMES);
+  return ease_quad_out(0, clamped, frame, BAR_FILL_FRAMES);
+}
+
+static bool status_is_alarming(void) {
+  return strcmp(s_status_text, "CRITICAL") == 0 || strcmp(s_status_text, "WARNING") == 0;
+}
+
+// The status pill stamps in as the worst quota bar lands: briefly oversized,
+// then settled. Only when the news warrants it.
+static int status_pill_inflation(void) {
+  if (s_motion_phase != MOTION_BARS || !status_is_alarming())
+    return 0;
+  int stamp_frame = s_bar_frame - BAR_FILL_FRAMES;
+  if (stamp_frame < 0 || stamp_frame >= STAMP_FRAMES)
+    return 0;
+  return stamp_frame < STAMP_FRAMES / 2 ? 2 : 1;
 }
 
 static void mark_card_dirty(void) {
@@ -168,29 +240,126 @@ static void mark_card_dirty(void) {
     layer_mark_dirty(s_card_layer);
 }
 
-static void animation_timer_callback(void *data) {
+static void motion_timer_callback(void *data) {
   s_animation_timer = NULL;
-  if (s_animation_frame < ANIMATION_FRAMES || s_is_refreshing) {
-    if (s_animation_frame < ANIMATION_FRAMES) {
-      s_animation_frame += 1;
+  bool needs_tick = false;
+
+  switch (s_motion_phase) {
+  case MOTION_SLIDE_OUT:
+    s_motion_frame += 1;
+    s_slide_offset = ease_quad_in(s_slide_from, s_slide_to, s_motion_frame, SLIDE_OUT_FRAMES);
+    if (s_motion_frame >= SLIDE_OUT_FRAMES) {
+      s_motion_phase = MOTION_WAITING;
+    } else {
+      needs_tick = true;
     }
-    s_refresh_frame = (s_refresh_frame + 1) % ANIMATION_FRAMES;
-    mark_card_dirty();
-    s_animation_timer = app_timer_register(ANIMATION_INTERVAL_MS, animation_timer_callback, NULL);
+    break;
+  case MOTION_SLIDE_IN:
+    s_motion_frame += 1;
+    s_slide_offset = ease_quad_out(s_slide_from, s_slide_to, s_motion_frame, SLIDE_IN_FRAMES);
+    if (s_motion_frame >= SLIDE_IN_FRAMES) {
+      if (s_slide_to != 0) {
+        s_motion_phase = MOTION_BOUNCE;
+        s_motion_frame = 0;
+        s_slide_from = s_slide_to;
+        s_slide_to = 0;
+      } else {
+        s_motion_phase = MOTION_BARS;
+        s_bar_frame = 0;
+      }
+    }
+    needs_tick = true;
+    break;
+  case MOTION_BOUNCE:
+    s_motion_frame += 1;
+    s_slide_offset = ease_quad_out(s_slide_from, 0, s_motion_frame, BOUNCE_FRAMES);
+    if (s_motion_frame >= BOUNCE_FRAMES) {
+      s_motion_phase = MOTION_BARS;
+      s_bar_frame = 0;
+    }
+    needs_tick = true;
+    break;
+  case MOTION_BARS:
+    s_bar_frame += 1;
+    if (s_bar_frame >= bar_cascade_total_frames()) {
+      s_motion_phase = MOTION_IDLE;
+    } else {
+      needs_tick = true;
+    }
+    break;
+  default:
+    break;
   }
-}
 
-static void start_card_animation(int direction) {
-  if (s_animation_timer != NULL) {
-    app_timer_cancel(s_animation_timer);
-    s_animation_timer = NULL;
+  if (s_is_refreshing) {
+    s_refresh_frame = (s_refresh_frame + 1) % REFRESH_DOT_FRAMES;
+    needs_tick = true;
   }
 
-  s_animation_direction = direction < 0 ? -1 : 1;
-  s_animation_frame = 0;
-  s_refresh_frame = 0;
   mark_card_dirty();
-  s_animation_timer = app_timer_register(ANIMATION_INTERVAL_MS, animation_timer_callback, NULL);
+  if (needs_tick)
+    s_animation_timer = app_timer_register(ANIMATION_INTERVAL_MS, motion_timer_callback, NULL);
+}
+
+static void ensure_motion_timer(void) {
+  if (s_animation_timer == NULL)
+    s_animation_timer = app_timer_register(ANIMATION_INTERVAL_MS, motion_timer_callback, NULL);
+}
+
+// Card departs in the travel direction and holds offset until the phone replies.
+static void start_slide_out(int direction) {
+  s_slide_direction = direction < 0 ? -1 : 1;
+  s_slide_from = s_slide_offset;
+  s_slide_to = -s_slide_direction * SLIDE_OUT_DISTANCE_PX;
+  s_motion_phase = MOTION_SLIDE_OUT;
+  s_motion_frame = 0;
+  mark_card_dirty();
+  ensure_motion_timer();
+}
+
+// New card arrives from the opposite side, settles, then bars pour in. On a
+// wraparound it overshoots past its resting position and rubber-bands back.
+static void start_slide_in(void) {
+  s_slide_from = s_slide_direction * SLIDE_IN_DISTANCE_PX;
+  s_slide_to = s_wrap_pending ? -s_slide_direction * BOUNCE_DISTANCE_PX : 0;
+  s_wrap_pending = false;
+  s_slide_offset = s_slide_from;
+  s_motion_phase = MOTION_SLIDE_IN;
+  s_motion_frame = 0;
+  mark_card_dirty();
+  ensure_motion_timer();
+}
+
+// The page never arrived: the card slides back into place.
+static void start_slide_back(void) {
+  s_wrap_pending = false;
+  if (s_motion_phase != MOTION_SLIDE_OUT && s_motion_phase != MOTION_WAITING)
+    return;
+  s_slide_from = s_slide_offset;
+  s_slide_to = 0;
+  s_motion_phase = MOTION_SLIDE_IN;
+  s_motion_frame = 0;
+  mark_card_dirty();
+  ensure_motion_timer();
+}
+
+// Data arrived in place (initial load or select refresh): cascade the bars only.
+static void start_bar_cascade(void) {
+  s_wrap_pending = false;
+  s_slide_offset = 0;
+  s_motion_phase = MOTION_BARS;
+  s_motion_frame = 0;
+  s_bar_frame = 0;
+  mark_card_dirty();
+  ensure_motion_timer();
+}
+
+static void snap_motion_idle(void) {
+  s_wrap_pending = false;
+  s_motion_phase = MOTION_IDLE;
+  s_motion_frame = 0;
+  s_slide_offset = 0;
+  s_bar_frame = bar_cascade_total_frames();
 }
 
 static void request_quota(void) {
@@ -210,7 +379,9 @@ static void request_quota(void) {
     snprintf(s_status_text, sizeof(s_status_text), "Refreshing");
     s_updated_text[0] = '\0';
     s_is_refreshing = true;
-    start_card_animation(1);
+    s_refresh_frame = 0;
+    mark_card_dirty();
+    ensure_motion_timer();
   } else {
     snprintf(s_status_text, sizeof(s_status_text), "Send fail");
     s_is_refreshing = false;
@@ -222,13 +393,16 @@ static void request_page(int page_index) {
   if (s_page_count <= 0)
     return;
   int direction = page_index >= s_page_index ? 1 : -1;
+  bool wrapped = false;
   if (page_index < 0) {
     page_index = s_page_count - 1;
     direction = -1;
+    wrapped = true;
   }
   if (page_index >= s_page_count) {
     page_index = 0;
     direction = 1;
+    wrapped = true;
   }
 
   DictionaryIterator *iter;
@@ -243,7 +417,8 @@ static void request_page(int page_index) {
   dict_write_int32(iter, MESSAGE_KEY_REQUEST_PAGE, page_index);
   result = app_message_outbox_send();
   if (result == APP_MSG_OK) {
-    start_card_animation(direction);
+    s_wrap_pending = wrapped;
+    start_slide_out(direction);
   } else {
     snprintf(s_status_text, sizeof(s_status_text), "Send fail");
     mark_card_dirty();
@@ -267,11 +442,17 @@ static void draw_text(GContext *ctx, const char *text, GRect box, GFont font, GC
   graphics_draw_text(ctx, text, font, box, GTextOverflowModeTrailingEllipsis, alignment, NULL);
 }
 
-static void draw_progress_bar(GContext *ctx, GRect frame, int percent, GColor color) {
+static int text_width(const char *text, GFont font) {
+  GSize size = graphics_text_layout_get_content_size(text, font, GRect(0, 0, 500, 30), GTextOverflowModeWordWrap,
+                                                     GTextAlignmentLeft);
+  return size.w;
+}
+
+static void draw_progress_bar(GContext *ctx, GRect frame, int row_index, int percent, GColor color) {
   graphics_context_set_fill_color(ctx, color_track());
   graphics_fill_rect(ctx, frame, 3, GCornersAll);
 
-  int fill_width = (frame.size.w * current_bar_percent(percent)) / 100;
+  int fill_width = (frame.size.w * current_bar_percent(row_index, percent)) / 100;
   if (fill_width < 2 && percent > 0)
     fill_width = 2;
   graphics_context_set_fill_color(ctx, color);
@@ -279,10 +460,13 @@ static void draw_progress_bar(GContext *ctx, GRect frame, int percent, GColor co
 }
 
 static void draw_status_pill(GContext *ctx, GRect frame) {
+  int inflation = status_pill_inflation();
+  GRect pill = GRect(frame.origin.x - inflation, frame.origin.y - inflation, frame.size.w + (inflation * 2),
+                     frame.size.h + (inflation * 2));
   graphics_context_set_fill_color(ctx, color_status_fill());
-  graphics_fill_rect(ctx, frame, 6, GCornersAll);
+  graphics_fill_rect(ctx, pill, 6, GCornersAll);
   graphics_context_set_stroke_color(ctx, color_for_status_text());
-  graphics_draw_rect(ctx, frame);
+  graphics_draw_rect(ctx, pill);
   draw_text(ctx, s_status_text, GRect(frame.origin.x, frame.origin.y - 4, frame.size.w, frame.size.h + 4),
             fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD), color_text_primary(), GTextAlignmentCenter);
 }
@@ -300,24 +484,25 @@ static void draw_refresh_dots(GContext *ctx, GRect bounds) {
   graphics_fill_circle(ctx, GPoint(center_x + 7, center_y), phase >= 6 ? 3 : 2);
 }
 
+// Vertical rail along the right edge: pages travel up/down, so does the rail.
 static void draw_page_dots(GContext *ctx, GRect frame) {
   if (s_page_count <= 1)
     return;
 
   int dots = s_page_count > 7 ? 7 : s_page_count;
-  int total_width = dots * 7 - 3;
-  int x = frame.origin.x + (frame.size.w - total_width) / 2;
-  int y = frame.origin.y + frame.size.h - 10;
+  int total_height = dots * 7 - 3;
+  int x = frame.origin.x + frame.size.w - 5;
+  int y = frame.origin.y + (frame.size.h - total_height) / 2;
 
   for (int i = 0; i < dots; i += 1) {
-    graphics_context_set_fill_color(ctx, i == s_page_index || (s_page_count > 7 && i == 6 && s_page_index >= 6)
-                                             ? color_card_highlight()
-                                             : color_text_muted());
     int active = i == s_page_index || (s_page_count > 7 && i == 6 && s_page_index >= 6);
+    graphics_context_set_fill_color(ctx, active ? color_card_highlight() : color_text_muted());
     int radius = active ? 2 : 1;
-    if (active && s_animation_frame < ANIMATION_FRAMES / 2)
+    bool card_in_flight = s_motion_phase == MOTION_SLIDE_OUT || s_motion_phase == MOTION_WAITING ||
+                          s_motion_phase == MOTION_SLIDE_IN || s_motion_phase == MOTION_BOUNCE;
+    if (active && card_in_flight)
       radius = 3;
-    graphics_fill_circle(ctx, GPoint(x + (i * 7), y), radius);
+    graphics_fill_circle(ctx, GPoint(x, y + (i * 7)), radius);
   }
 }
 
@@ -371,29 +556,71 @@ static void card_update_proc(Layer *layer, GContext *ctx) {
     draw_text(ctx, s_reset_text, GRect(content_x, y + 86, content_w, 46), fonts_get_system_font(FONT_KEY_GOTHIC_24),
               color_text_secondary(), GTextAlignmentCenter);
   } else {
+    // Row layout heights (42/28/50 + 3px separators) are mirrored by the
+    // phone-side page packing in src/pkjs/index.js; keep them in sync.
+    GFont row_font = fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD);
+    int row_bottom_limit = bounds.size.h - 34 + offset;
+
+    // One detail column for all bar rows keeps the bar tracks aligned.
+    int detail_column_w = 28;
+    for (int i = 0; i < s_row_count && i < MAX_ROWS; i += 1) {
+      if (!s_rows[i].has_bar)
+        continue;
+      int detail_w = text_width(s_rows[i].detail, row_font);
+      if (detail_w > detail_column_w)
+        detail_column_w = detail_w;
+    }
+    if (detail_column_w > 100)
+      detail_column_w = 100;
+
+    int rows_drawn = 0;
     for (int i = 0; i < s_row_count && i < MAX_ROWS; i += 1) {
       QuotaRow *row = &s_rows[i];
       GColor accent = color_for_status(row->status_rank);
-      int detail_width = 62;
-      draw_text(ctx, row->label, GRect(content_x, y, content_w - detail_width - 8, 22),
-                fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD), color_text_primary(), GTextAlignmentLeft);
-      draw_text(ctx, row->detail, GRect(content_x + content_w - detail_width, y, detail_width, 22),
-                fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD), row->has_bar ? color_text_soft() : color_text_primary(),
-                GTextAlignmentRight);
-      y += 22;
       if (row->has_bar) {
-        draw_progress_bar(ctx, GRect(content_x, y, content_w, 6), row->percent, accent);
-        y += 16;
+        if (y + 42 > row_bottom_limit)
+          break;
+        draw_text(ctx, row->label, GRect(content_x, y, content_w, 22), row_font, color_text_primary(),
+                  GTextAlignmentLeft);
+        y += 22;
+        int bar_w = content_w - detail_column_w - 8;
+        draw_text(ctx, row->detail, GRect(content_x + bar_w + 8, y - 3, detail_column_w, 22), row_font,
+                  color_text_soft(), GTextAlignmentRight);
+        draw_progress_bar(ctx, GRect(content_x, y + 6, bar_w, 6), i, row->percent, accent);
+        y += 20;
       } else {
-        y += 6;
+        int detail_w = text_width(row->detail, row_font);
+        if (detail_w > 100)
+          detail_w = 100;
+        int label_w = content_w - detail_w - 8;
+        bool two_line = text_width(row->label, row_font) > label_w;
+        if (y + (two_line ? 50 : 28) > row_bottom_limit)
+          break;
+        if (two_line) {
+          draw_text(ctx, row->label, GRect(content_x, y, content_w, 22), row_font, color_text_primary(),
+                    GTextAlignmentLeft);
+          y += 22;
+          draw_text(ctx, row->detail, GRect(content_x, y, content_w, 22), row_font, color_text_primary(),
+                    GTextAlignmentRight);
+          y += 28;
+        } else {
+          draw_text(ctx, row->label, GRect(content_x, y, label_w, 22), row_font, color_text_primary(),
+                    GTextAlignmentLeft);
+          draw_text(ctx, row->detail, GRect(content_x + label_w + 8, y, detail_w, 22), row_font, color_text_primary(),
+                    GTextAlignmentRight);
+          y += 28;
+        }
       }
+      rows_drawn += 1;
       if (i + 1 < s_row_count && i + 1 < MAX_ROWS) {
         graphics_context_set_stroke_color(ctx, color_card_outline());
         graphics_draw_line(ctx, GPoint(content_x, y), GPoint(content_x + content_w, y));
         y += 3;
       }
     }
-    if (s_row_count > MAX_ROWS) {
+
+    int rows_visible = s_row_count > MAX_ROWS ? MAX_ROWS : s_row_count;
+    if (rows_drawn < rows_visible || s_row_count > MAX_ROWS) {
       draw_text(ctx, "+ more", GRect(content_x, y, content_w, 22), fonts_get_system_font(FONT_KEY_GOTHIC_18),
                 color_text_muted(), GTextAlignmentCenter);
     }
@@ -401,19 +628,8 @@ static void card_update_proc(Layer *layer, GContext *ctx) {
 
   draw_page_dots(ctx, bounds);
 
-  draw_text(ctx, s_reset_text, GRect(content_x, bounds.size.h - 55, content_w, 24),
+  draw_text(ctx, s_reset_text, GRect(content_x, bounds.size.h - 32 + offset, content_w, 24),
             fonts_get_system_font(FONT_KEY_GOTHIC_18), color_text_muted(), GTextAlignmentCenter);
-
-  char footer[32];
-  if (s_page_count > 0 && s_status_text[0] != '\0') {
-    snprintf(footer, sizeof(footer), "%.10s %.12s", s_status_text, s_updated_text);
-  } else if (s_page_count > 0) {
-    snprintf(footer, sizeof(footer), "%.31s", s_updated_text);
-  } else {
-    snprintf(footer, sizeof(footer), "%.31s", s_status_text);
-  }
-  draw_text(ctx, footer, GRect(content_x, bounds.size.h - 33, content_w, 24), fonts_get_system_font(FONT_KEY_GOTHIC_18),
-            color_text_secondary(), GTextAlignmentCenter);
 }
 
 static void copy_string_tuple(DictionaryIterator *iterator, uint32_t key, char *buffer, size_t buffer_size) {
@@ -449,11 +665,14 @@ static void inbox_received_callback(DictionaryIterator *iterator, void *context)
     snprintf(s_reset_text, sizeof(s_reset_text), "%s", error_tuple->value->cstring);
     snprintf(s_status_text, sizeof(s_status_text), "Select retries");
     s_updated_text[0] = '\0';
-    s_animation_frame = ANIMATION_FRAMES;
+    snap_motion_idle();
     mark_card_dirty();
     return;
   }
 
+  static char s_last_data_status[16] = "";
+  bool was_refreshing = s_is_refreshing;
+
   s_is_refreshing = false;
   copy_string_tuple(iterator, MESSAGE_KEY_QUOTA_TITLE, s_title_text, sizeof(s_title_text));
   copy_string_tuple(iterator, MESSAGE_KEY_QUOTA_LABEL, s_group_text, sizeof(s_group_text));
@@ -461,6 +680,12 @@ static void inbox_received_callback(DictionaryIterator *iterator, void *context)
   copy_string_tuple(iterator, MESSAGE_KEY_QUOTA_STATUS, s_status_text, sizeof(s_status_text));
   copy_string_tuple(iterator, MESSAGE_KEY_UPDATED_AT, s_updated_text, sizeof(s_updated_text));
 
+  // Redundant haptic feedback only when a refresh reveals a newly critical
+  // page; browsing between known-critical pages stays silent.
+  if (was_refreshing && strcmp(s_status_text, "CRITICAL") == 0 && strcmp(s_last_data_status, "CRITICAL") != 0)
+    vibes_short_pulse();
+  snprintf(s_last_data_status, sizeof(s_last_data_status), "%s", s_status_text);
+
   Tuple *page_index_tuple = dict_find(iterator, MESSAGE_KEY_PAGE_INDEX);
   if (page_index_tuple != NULL)
     s_page_index = (int)page_index_tuple->value->int32;
@@ -491,18 +716,24 @@ static void inbox_received_callback(DictionaryIterator *iterator, void *context)
     read_row(iterator, i);
   }
 
-  start_card_animation(s_animation_direction);
+  if (s_motion_phase == MOTION_SLIDE_OUT || s_motion_phase == MOTION_WAITING) {
+    start_slide_in();
+  } else {
+    start_bar_cascade();
+  }
 }
 
 static void inbox_dropped_callback(AppMessageResult reason, void *context) {
   s_is_refreshing = false;
   snprintf(s_status_text, sizeof(s_status_text), "Dropped");
+  start_slide_back();
   mark_card_dirty();
 }
 
 static void outbox_failed_callback(DictionaryIterator *iterator, AppMessageResult reason, void *context) {
   s_is_refreshing = false;
   snprintf(s_status_text, sizeof(s_status_text), "No phone");
+  start_slide_back();
   mark_card_dirty();
 }
 

src/pkjs/index.js ๐Ÿ”—

@@ -129,17 +129,24 @@ function formatCompactNumber(value) {
   return formatNumber(number);
 }
 
+var UNIT_SUFFIXES = {
+  hypercredits: "hc",
+  requests: "req",
+  request: "req",
+  tokens: "tok",
+  token: "tok",
+  kwh: "kWh"
+};
+
 function formatValue(value, unit) {
   var number = asNumber(value);
   if (number === null) return "?";
   if (unit === "usd") return "$" + number.toFixed(2);
   if (unit === "percent" || unit === "percentage")
     return formatNumber(number) + "%";
-  if (unit === "hypercredits") return formatCompactNumber(number) + " hc";
-  if (unit === "requests") return formatCompactNumber(number) + " req";
-  if (unit === "kwh") return formatCompactNumber(number) + " kWh";
   if (!unit) return formatNumber(number);
-  return formatCompactNumber(number) + " " + String(unit).slice(0, 4);
+  var suffix = UNIT_SUFFIXES[String(unit).toLowerCase()] || String(unit);
+  return formatCompactNumber(number) + " " + suffix;
 }
 
 function formatMeterDetail(meter) {
@@ -156,6 +163,73 @@ function formatMeterDetail(meter) {
   return "โ€”";
 }
 
+var LABEL_ABBREVIATIONS = {
+  requests: "req",
+  request: "req",
+  tokens: "tok",
+  token: "tok",
+  messages: "msg",
+  message: "msg",
+  minutes: "min",
+  minute: "min",
+  seconds: "sec",
+  second: "sec",
+  hours: "hr",
+  hour: "hr"
+};
+
+var LABEL_CHAR_BUDGET = 14;
+
+// Drop label words the page title already shows ("OpenAI requests" on the
+// OpenAI page is just "requests").
+function stripTitleWords(label, title) {
+  var titleWords = {};
+  var titleTokens = String(title || "")
+    .toLowerCase()
+    .split(/[^a-z0-9]+/);
+  for (var i = 0; i < titleTokens.length; i += 1) {
+    if (titleTokens[i]) titleWords[titleTokens[i]] = true;
+  }
+
+  var words = String(label).split(/\s+/);
+  var kept = [];
+  for (var j = 0; j < words.length; j += 1) {
+    var bare = words[j].toLowerCase().replace(/[^a-z0-9]/g, "");
+    if (bare === "" || !titleWords[bare]) kept.push(words[j]);
+  }
+  if (kept.length === 0) return String(label);
+  return kept.join(" ");
+}
+
+function abbreviateLabel(label) {
+  var words = label.split(/\s+/);
+  var out = [];
+  for (var i = 0; i < words.length; i += 1) {
+    out.push(LABEL_ABBREVIATIONS[words[i].toLowerCase()] || words[i]);
+  }
+  return out.join(" ").replace(/(\S+) per (\S+)/gi, "$1/$2");
+}
+
+function displayLabelForMeter(meter, title) {
+  var label = stripTitleWords(meter.label, title);
+  if (label.length > LABEL_CHAR_BUDGET) label = abbreviateLabel(label);
+  return label.charAt(0).toUpperCase() + label.slice(1);
+}
+
+// Drop the detail's unit suffix when the label already names the unit
+// ("Req/min" plus "30 req" reads fine as "Req/min" plus "30").
+function displayDetailForMeter(meter, displayLabel) {
+  var detail = formatMeterDetail(meter);
+  var spaceIndex = detail.lastIndexOf(" ");
+  if (spaceIndex === -1) return detail;
+  var suffix = detail.slice(spaceIndex + 1).toLowerCase();
+  var label = String(displayLabel || "").toLowerCase();
+  var unit = String(meter.unit || "").toLowerCase();
+  if (label.indexOf(suffix) !== -1 || (unit !== "" && label.indexOf(unit) !== -1))
+    return detail.slice(0, spaceIndex);
+  return detail;
+}
+
 function shortResetForGroup(group) {
   var soonest = null;
   for (var i = 0; i < group.meters.length; i += 1) {
@@ -254,7 +328,13 @@ function flattenGroups(response, displayNames) {
   }
 
   for (var groupIndex = 0; groupIndex < groups.length; groupIndex += 1) {
-    groups[groupIndex].meters.sort(compareMeters);
+    var group = groups[groupIndex];
+    group.meters.sort(compareMeters);
+    for (var meterIndex = 0; meterIndex < group.meters.length; meterIndex += 1) {
+      var meter = group.meters[meterIndex];
+      meter.displayLabel = displayLabelForMeter(meter, group.title);
+      meter.displayDetail = displayDetailForMeter(meter, meter.displayLabel);
+    }
   }
   groups.sort(function (left, right) {
     var leftHasComparableMeter = groupHasComparableMeter(left);
@@ -269,6 +349,71 @@ function flattenGroups(response, displayNames) {
   return groups;
 }
 
+// Mirrors the watch row layout in src/c/main.c: bar rows are 42px, one-line
+// rows 28px, two-line rows 50px, with 3px separators between rows. Rows run
+// from y=60 (y=42 without the status pill row) down to the reset line at
+// height-34. Keep in sync.
+var PAGE_HEIGHT_BUDGET_WITH_STATUS_PX = 134;
+var PAGE_HEIGHT_BUDGET_WITHOUT_STATUS_PX = 152;
+var ROW_SEPARATOR_PX = 3;
+var TWO_LINE_LABEL_CHAR_ESTIMATE = 14;
+
+function estimateRowHeightPx(meter) {
+  if (meter.hasBar) return 42;
+  return String(meter.displayLabel).length > TWO_LINE_LABEL_CHAR_ESTIMATE
+    ? 50
+    : 28;
+}
+
+// Split each group into as many pages as its meters need, so nothing hides
+// behind "+ more". Meters are already sorted most-urgent-first, so part one
+// always carries the worst news.
+function paginateGroups(groups) {
+  var pages = [];
+  for (var g = 0; g < groups.length; g += 1) {
+    var group = groups[g];
+    var parts = [];
+    var current = null;
+    var budget = 0;
+    for (var m = 0; m < group.meters.length; m += 1) {
+      var meter = group.meters[m];
+      var rowHeight = estimateRowHeightPx(meter);
+      var fits =
+        current !== null &&
+        current.meters.length < MAX_ROWS_PER_GROUP &&
+        current.heightPx + ROW_SEPARATOR_PX + rowHeight <= budget;
+      if (fits) {
+        current.heightPx += ROW_SEPARATOR_PX + rowHeight;
+      } else {
+        budget = meter.hasBar
+          ? PAGE_HEIGHT_BUDGET_WITH_STATUS_PX
+          : PAGE_HEIGHT_BUDGET_WITHOUT_STATUS_PX;
+        current = {
+          id: group.id,
+          title: group.title,
+          meters: [],
+          heightPx: rowHeight
+        };
+        parts.push(current);
+      }
+      current.meters.push(meter);
+    }
+    for (var p = 0; p < parts.length; p += 1) {
+      parts[p].partIndex = p + 1;
+      parts[p].partCount = parts.length;
+      pages.push(parts[p]);
+    }
+  }
+  return pages;
+}
+
+function resetTextForPage(page) {
+  var reset = shortResetForGroup(page);
+  if (page.partCount > 1)
+    return page.partIndex + "/" + page.partCount + " ยท " + reset;
+  return reset;
+}
+
 function sendGroupPage(pageIndex) {
   if (quotaGroups.length === 0) {
     sendError("No quota groups found");
@@ -280,7 +425,7 @@ function sendGroupPage(pageIndex) {
   var dictionary = {
     QUOTA_TITLE: String(group.title).slice(0, 38),
     QUOTA_STATUS: "",
-    QUOTA_RESET: shortResetForGroup(group).slice(0, 38),
+    QUOTA_RESET: resetTextForPage(group).slice(0, 38),
     QUOTA_LABEL: "",
     ROW_COUNT: group.meters.length,
     PAGE_INDEX: currentPageIndex,
@@ -297,8 +442,8 @@ function sendGroupPage(pageIndex) {
     var rank = statusRank(meter.status);
     if (meter.hasBar) pageHasBar = true;
     worstStatusRank = Math.max(worstStatusRank, rank);
-    dictionary[keys.label] = String(meter.label).slice(0, 22);
-    dictionary[keys.detail] = formatMeterDetail(meter).slice(0, 26);
+    dictionary[keys.label] = String(meter.displayLabel).slice(0, 22);
+    dictionary[keys.detail] = String(meter.displayDetail).slice(0, 26);
     dictionary[keys.percent] = Math.max(
       0,
       Math.min(100, Math.round(asNumber(meter.utilizationPercent) || 0))
@@ -356,7 +501,7 @@ function refreshQuotaWithDisplayNames(
         return;
       }
       try {
-        quotaGroups = flattenGroups(responseText, displayNames);
+        quotaGroups = paginateGroups(flattenGroups(responseText, displayNames));
         sendGroupPage(pageIndex);
       } catch (e) {
         sendError("Bad quota response");