diff --git a/res/app_icon_18x28_64_color.png b/res/app_icon_18x28_64_color.png
new file mode 100644
index 0000000..5f6905a
Binary files /dev/null and b/res/app_icon_18x28_64_color.png differ
diff --git a/res/app_icon_50x50_64_color.png b/res/app_icon_50x50_64_color.png
new file mode 100644
index 0000000..f585d3c
Binary files /dev/null and b/res/app_icon_50x50_64_color.png differ
diff --git a/res/app_icon_64_gray.png b/res/app_icon_64_gray.png
deleted file mode 100644
index 935dba4..0000000
Binary files a/res/app_icon_64_gray.png and /dev/null differ
diff --git a/res/app_icon_color.xcf b/res/app_icon_color.xcf
new file mode 100644
index 0000000..18f2dcf
Binary files /dev/null and b/res/app_icon_color.xcf differ
diff --git a/res/app_icon.xcf b/res/app_icon_gray.xcf
similarity index 100%
rename from res/app_icon.xcf
rename to res/app_icon_gray.xcf
diff --git a/watch/package.json b/watch/package.json
index e6d721a..8a7325a 100644
--- a/watch/package.json
+++ b/watch/package.json
@@ -2,7 +2,9 @@
"name": "deck_in_a_dash_watch",
"author": "Matthias Ladkau",
"version": "1.0.0",
- "keywords": ["pebble-app"],
+ "keywords": [
+ "pebble-app"
+ ],
"private": true,
"dependencies": {},
"pebble": {
@@ -10,6 +12,9 @@
"uuid": "7f47ed93-f44e-4c1a-9998-7edcfa61218e",
"sdkVersion": "3",
"enableMultiJS": true,
+ "capabilities": [
+ "configurable"
+ ],
"targetPlatforms": [
"basalt",
"chalk",
@@ -37,28 +42,116 @@
],
"resources": {
"media": [
- { "type": "bitmap", "name": "IMG_CARD_FOOL", "file": "img/fool.png" },
- { "type": "bitmap", "name": "IMG_CARD_MAGICIAN", "file": "img/magician.png" },
- { "type": "bitmap", "name": "IMG_CARD_HIGH_PRIESTESS", "file": "img/high_priestess.png" },
- { "type": "bitmap", "name": "IMG_CARD_EMPRESS", "file": "img/empress.png" },
- { "type": "bitmap", "name": "IMG_CARD_EMPEROR", "file": "img/emperor.png" },
- { "type": "bitmap", "name": "IMG_CARD_HIEROPHANT", "file": "img/hierophant.png" },
- { "type": "bitmap", "name": "IMG_CARD_LOVERS", "file": "img/lovers.png" },
- { "type": "bitmap", "name": "IMG_CARD_CHARIOT", "file": "img/chariot.png" },
- { "type": "bitmap", "name": "IMG_CARD_STRENGTH", "file": "img/strength.png" },
- { "type": "bitmap", "name": "IMG_CARD_HERMIT", "file": "img/hermit.png" },
- { "type": "bitmap", "name": "IMG_CARD_WHEEL_OF_FORTUNE", "file": "img/wheel_of_fortune.png" },
- { "type": "bitmap", "name": "IMG_CARD_JUSTICE", "file": "img/justice.png" },
- { "type": "bitmap", "name": "IMG_CARD_HANGED_MAN", "file": "img/hanged_man.png" },
- { "type": "bitmap", "name": "IMG_CARD_DEATH", "file": "img/death.png" },
- { "type": "bitmap", "name": "IMG_CARD_TEMPERANCE", "file": "img/temperance.png" },
- { "type": "bitmap", "name": "IMG_CARD_DEVIL", "file": "img/devil.png" },
- { "type": "bitmap", "name": "IMG_CARD_TOWER", "file": "img/tower.png" },
- { "type": "bitmap", "name": "IMG_CARD_STAR", "file": "img/star.png" },
- { "type": "bitmap", "name": "IMG_CARD_MOON", "file": "img/moon.png" },
- { "type": "bitmap", "name": "IMG_CARD_SUN", "file": "img/sun.png" },
- { "type": "bitmap", "name": "IMG_CARD_JUDGEMENT", "file": "img/judgement.png" },
- { "type": "bitmap", "name": "IMG_CARD_WORLD", "file": "img/world.png" }
+ {
+ "type": "bitmap",
+ "name": "IMG_CARD_FOOL",
+ "file": "img/fool.png"
+ },
+ {
+ "type": "bitmap",
+ "name": "IMG_CARD_MAGICIAN",
+ "file": "img/magician.png"
+ },
+ {
+ "type": "bitmap",
+ "name": "IMG_CARD_HIGH_PRIESTESS",
+ "file": "img/high_priestess.png"
+ },
+ {
+ "type": "bitmap",
+ "name": "IMG_CARD_EMPRESS",
+ "file": "img/empress.png"
+ },
+ {
+ "type": "bitmap",
+ "name": "IMG_CARD_EMPEROR",
+ "file": "img/emperor.png"
+ },
+ {
+ "type": "bitmap",
+ "name": "IMG_CARD_HIEROPHANT",
+ "file": "img/hierophant.png"
+ },
+ {
+ "type": "bitmap",
+ "name": "IMG_CARD_LOVERS",
+ "file": "img/lovers.png"
+ },
+ {
+ "type": "bitmap",
+ "name": "IMG_CARD_CHARIOT",
+ "file": "img/chariot.png"
+ },
+ {
+ "type": "bitmap",
+ "name": "IMG_CARD_STRENGTH",
+ "file": "img/strength.png"
+ },
+ {
+ "type": "bitmap",
+ "name": "IMG_CARD_HERMIT",
+ "file": "img/hermit.png"
+ },
+ {
+ "type": "bitmap",
+ "name": "IMG_CARD_WHEEL_OF_FORTUNE",
+ "file": "img/wheel_of_fortune.png"
+ },
+ {
+ "type": "bitmap",
+ "name": "IMG_CARD_JUSTICE",
+ "file": "img/justice.png"
+ },
+ {
+ "type": "bitmap",
+ "name": "IMG_CARD_HANGED_MAN",
+ "file": "img/hanged_man.png"
+ },
+ {
+ "type": "bitmap",
+ "name": "IMG_CARD_DEATH",
+ "file": "img/death.png"
+ },
+ {
+ "type": "bitmap",
+ "name": "IMG_CARD_TEMPERANCE",
+ "file": "img/temperance.png"
+ },
+ {
+ "type": "bitmap",
+ "name": "IMG_CARD_DEVIL",
+ "file": "img/devil.png"
+ },
+ {
+ "type": "bitmap",
+ "name": "IMG_CARD_TOWER",
+ "file": "img/tower.png"
+ },
+ {
+ "type": "bitmap",
+ "name": "IMG_CARD_STAR",
+ "file": "img/star.png"
+ },
+ {
+ "type": "bitmap",
+ "name": "IMG_CARD_MOON",
+ "file": "img/moon.png"
+ },
+ {
+ "type": "bitmap",
+ "name": "IMG_CARD_SUN",
+ "file": "img/sun.png"
+ },
+ {
+ "type": "bitmap",
+ "name": "IMG_CARD_JUDGEMENT",
+ "file": "img/judgement.png"
+ },
+ {
+ "type": "bitmap",
+ "name": "IMG_CARD_WORLD",
+ "file": "img/world.png"
+ }
]
}
}
diff --git a/watch/scripts/gen_card_images.py b/watch/scripts/gen_card_images.py
index e89b412..7df80a0 100644
--- a/watch/scripts/gen_card_images.py
+++ b/watch/scripts/gen_card_images.py
@@ -3,21 +3,20 @@
The 22 Major Arcana JPEGs in res/img/ are ~1MB/~1086x1810px each -
suitable for the desktop HTML reading (an
tag pointed at the file
-as-is) but far too large for a Pebble app's own resource budget (256KB
-total on basalt-class platforms, shared with every other resource this
-app ships). This script resizes each to CARD_WIDTH px wide (preserving
-aspect ratio) and writes a PNG per card, named after tarot_data.c's own
-k_card_slug table so watch/package.json's "resources.media" entries
-(IMG_CARD_, file img/.png) line up without a
-separate mapping table anywhere.
+as-is) but far too large for a Pebble app's own resource budget. This
+script resizes each to CARD_WIDTH px wide (preserving aspect ratio) and
+writes a PNG per card, named after tarot_data.c's own k_card_slug table
+so watch/package.json's "resources.media" entries (IMG_CARD_, file img/.png) line up without a separate mapping
+table anywhere.
-CARD_WIDTH=72 (-> 72x120) was picked empirically: a real `pebble build`
-of two sample cards at this size measured ~5KB/card on color platforms
-(basalt/chalk/emery/gabbro) and ~2.9KB/card on the black-and-white
-diorite/flint - so all 22 cards together cost roughly 111KB/64KB
-respectively, comfortably inside the 256KB resource budget with room
-left for other future resources (fonts, icons), while still rendering
-at close to a full basalt-class screen's height (120px of 168px).
+CARD_WIDTH=72 (-> 72x120) is a hardware-verified ceiling, not a design
+choice: full-display-width images (144-260px depending on platform) were
+tried first and reliably crash real hardware with "PNG memory allocation
+failed" - a runtime PNG-decoder memory constraint independent of the
+app's own heap budget (lazy-loading only one image at a time didn't help
+either). 72px is the size the original per-card screens used
+successfully all session before that experiment.
Generated output, like i18n_tables.auto.c - not committed (see
.gitignore), regenerate by running this script directly or via the root
diff --git a/watch/src/c/app_message.c b/watch/src/c/app_message.c
index e14acb8..c36fc90 100644
--- a/watch/src/c/app_message.c
+++ b/watch/src/c/app_message.c
@@ -60,6 +60,7 @@ static void inbox_received_handler(DictionaryIterator *iter, void *context) {
* of the settings page) must not un-configure an already-set-up watch. */
if (got_birth) cfg.configured = true;
+ config_log(&cfg); /* confirms what was actually received/parsed */
config_save(&cfg);
if (s_callback) s_callback(&cfg);
}
diff --git a/watch/src/c/config.c b/watch/src/c/config.c
index 6a5c8b0..27f2995 100644
--- a/watch/src/c/config.c
+++ b/watch/src/c/config.c
@@ -68,3 +68,19 @@ void config_save(const WatchConfig *cfg) {
persist_write_int(PKEY_NOTIFY_HOUR, cfg->notify_hour);
persist_write_int(PKEY_NOTIFY_MINUTE, cfg->notify_minute);
}
+
+/* Cast to int/long rather than %f - Pebble's APP_LOG doesn't support
+ * floating-point format specifiers. */
+void config_log(const WatchConfig *cfg) {
+ if (!cfg->configured) {
+ APP_LOG(APP_LOG_LEVEL_INFO, "config: not configured yet");
+ return;
+ }
+ APP_LOG(APP_LOG_LEVEL_INFO, "config: birth %04d-%02d-%02d %02d:%02d UTC offset %dmin",
+ cfg->birth.year, cfg->birth.month, cfg->birth.day, cfg->birth.hour, cfg->birth.minute,
+ (int)(cfg->birth.utc_offset_hours * 60));
+ APP_LOG(APP_LOG_LEVEL_INFO, "config: lat=%ld/1e6 lon=%ld/1e6 lang=%s",
+ (long)(cfg->birth.latitude * 1000000), (long)(cfg->birth.longitude * 1000000), cfg->lang);
+ APP_LOG(APP_LOG_LEVEL_INFO, "config: notify_enabled=%d at %02d:%02d",
+ cfg->notify_enabled, cfg->notify_hour, cfg->notify_minute);
+}
diff --git a/watch/src/c/config.h b/watch/src/c/config.h
index 2894fa1..0bfc87f 100644
--- a/watch/src/c/config.h
+++ b/watch/src/c/config.h
@@ -28,4 +28,10 @@ void config_load(WatchConfig *out);
void config_save(const WatchConfig *cfg);
+/* Prints `cfg` via APP_LOG - the same fields a reading is computed from,
+ * so callers can confirm on-device what values a shown reading is
+ * actually based on (there's no way to read them back from the watch
+ * otherwise). Prints "not configured yet" instead if !cfg->configured. */
+void config_log(const WatchConfig *cfg);
+
#endif
diff --git a/watch/src/c/main.c b/watch/src/c/main.c
index c2657a9..51732ee 100644
--- a/watch/src/c/main.c
+++ b/watch/src/c/main.c
@@ -5,7 +5,7 @@
#include "config.h"
#include "i18n_tables.h"
#include "reading_state.h"
-#include "ui_menu_window.h"
+#include "ui_report_window.h"
#include "ui_text_window.h"
static WatchConfig s_config;
@@ -24,11 +24,12 @@ static void on_config_updated(const WatchConfig *cfg) {
if (!s_config.configured) return;
+ config_log(&s_config); /* the values this newly-recomputed reading is based on */
reading_state_recompute(&s_config.birth);
if (was_configured) {
- menu_window_reload();
+ report_window_reload();
} else {
- menu_window_push();
+ report_window_push();
}
}
@@ -37,14 +38,16 @@ int main(void) {
watch_i18n_load(s_config.lang);
app_message_init(on_config_updated);
+ config_log(&s_config); /* the values this session's reading (if any) is based on */
+
if (s_config.configured) {
reading_state_recompute(&s_config.birth);
- menu_window_push();
+ report_window_push();
} else {
show_setup_required();
}
app_event_loop();
- menu_window_deinit();
+ report_window_deinit();
}
diff --git a/watch/src/c/resource_map.c b/watch/src/c/resource_map.c
index 3442a09..1ec9916 100644
--- a/watch/src/c/resource_map.c
+++ b/watch/src/c/resource_map.c
@@ -29,3 +29,7 @@ uint32_t card_resource_id(TarotCard card) {
}
return RESOURCE_ID_IMG_CARD_FOOL; /* unreachable for a valid TarotCard */
}
+
+int16_t card_image_height_for_width(int16_t width) {
+ return (int16_t)((width * 1810 + 543) / 1086); /* +543 = round, not truncate */
+}
diff --git a/watch/src/c/resource_map.h b/watch/src/c/resource_map.h
index 7ab9b77..82edf21 100644
--- a/watch/src/c/resource_map.h
+++ b/watch/src/c/resource_map.h
@@ -15,4 +15,10 @@
* place in the app that switches over. */
uint32_t card_resource_id(TarotCard card);
+/* Height in pixels of a card image resized to `width` wide, preserving
+ * the source art's fixed 1086x1810 aspect ratio - matches
+ * gen_card_images.py's own resize math, so this stays correct for
+ * whichever per-platform width variant package.json resolves to. */
+int16_t card_image_height_for_width(int16_t width);
+
#endif
diff --git a/watch/src/c/ui_card_window.c b/watch/src/c/ui_card_window.c
deleted file mode 100644
index c4c4905..0000000
--- a/watch/src/c/ui_card_window.c
+++ /dev/null
@@ -1,78 +0,0 @@
-#include "ui_card_window.h"
-
-#include
-
-#include "resource_map.h"
-
-/* Matches the fixed size gen_card_images.py resizes every card to. */
-#define IMAGE_WIDTH 72
-#define IMAGE_HEIGHT 120
-
-typedef struct {
- TarotCard card;
- char *text; /* title + "\n" + body, combined into one scrollable block */
- GBitmap *bitmap;
- BitmapLayer *bitmap_layer;
- ScrollLayer *scroll_layer;
- TextLayer *text_layer;
-} CardWindowState;
-
-static void window_load(Window *window) {
- CardWindowState *state = window_get_user_data(window);
- Layer *window_layer = window_get_root_layer(window);
- GRect bounds = layer_get_bounds(window_layer);
-
- state->bitmap = gbitmap_create_with_resource(card_resource_id(state->card));
- int image_x = (bounds.size.w - IMAGE_WIDTH) / 2;
- state->bitmap_layer = bitmap_layer_create(GRect(image_x, 0, IMAGE_WIDTH, IMAGE_HEIGHT));
- bitmap_layer_set_bitmap(state->bitmap_layer, state->bitmap);
- bitmap_layer_set_alignment(state->bitmap_layer, GAlignCenter);
- layer_add_child(window_layer, bitmap_layer_get_layer(state->bitmap_layer));
-
- GRect scroll_bounds = GRect(0, IMAGE_HEIGHT, bounds.size.w, bounds.size.h - IMAGE_HEIGHT);
- state->scroll_layer = scroll_layer_create(scroll_bounds);
- scroll_layer_set_click_config_onto_window(state->scroll_layer, window);
-
- state->text_layer = text_layer_create(GRect(0, 0, scroll_bounds.size.w, 2000));
- text_layer_set_font(state->text_layer, fonts_get_system_font(FONT_KEY_GOTHIC_18));
- text_layer_set_text(state->text_layer, state->text);
- text_layer_set_overflow_mode(state->text_layer, GTextOverflowModeWordWrap);
-
- GSize content_size = text_layer_get_content_size(state->text_layer);
- content_size.w = scroll_bounds.size.w;
- content_size.h += 4;
- text_layer_set_size(state->text_layer, content_size);
- scroll_layer_set_content_size(state->scroll_layer, content_size);
-
- scroll_layer_add_child(state->scroll_layer, text_layer_get_layer(state->text_layer));
- layer_add_child(window_layer, scroll_layer_get_layer(state->scroll_layer));
-}
-
-static void window_unload(Window *window) {
- CardWindowState *state = window_get_user_data(window);
- bitmap_layer_destroy(state->bitmap_layer);
- gbitmap_destroy(state->bitmap);
- text_layer_destroy(state->text_layer);
- scroll_layer_destroy(state->scroll_layer);
- free(state->text);
- free(state);
- window_destroy(window);
-}
-
-void card_window_push(TarotCard card, const char *title, const char *body) {
- CardWindowState *state = malloc(sizeof(CardWindowState));
- memset(state, 0, sizeof *state);
- state->card = card;
-
- size_t len = strlen(title) + 1 + strlen(body) + 1;
- state->text = malloc(len);
- snprintf(state->text, len, "%s\n%s", title, body);
-
- Window *window = window_create();
- window_set_user_data(window, state);
- window_set_window_handlers(window, (WindowHandlers){
- .load = window_load,
- .unload = window_unload,
- });
- window_stack_push(window, true);
-}
diff --git a/watch/src/c/ui_card_window.h b/watch/src/c/ui_card_window.h
deleted file mode 100644
index 5d45e63..0000000
--- a/watch/src/c/ui_card_window.h
+++ /dev/null
@@ -1,12 +0,0 @@
-#ifndef WATCH_UI_CARD_WINDOW_H
-#define WATCH_UI_CARD_WINDOW_H
-
-#include "../../../engine/src/tarot.h"
-
-/* Pushes a window showing `card`'s art (via resource_map.h) pinned at
- * the top, with `title` and `body` below it as scrollable text (e.g. the
- * Celtic Cross position name + orientation, and the position description
- * + card meaning). Copies both strings, same as text_window_push(). */
-void card_window_push(TarotCard card, const char *title, const char *body);
-
-#endif
diff --git a/watch/src/c/ui_menu_window.c b/watch/src/c/ui_menu_window.c
deleted file mode 100644
index 4ec70ee..0000000
--- a/watch/src/c/ui_menu_window.c
+++ /dev/null
@@ -1,191 +0,0 @@
-#include "ui_menu_window.h"
-
-#include
-
-#include "../../../engine/src/i18n.h"
-#include "../../../interpreter/src/guidance.h"
-#include "../../../interpreter/src/narrative.h"
-#include "reading_state.h"
-#include "ui_card_window.h"
-#include "ui_text_window.h"
-
-typedef enum {
- SECTION_TODAY = 0,
- SECTION_TRANSITS,
- SECTION_CELTIC_CROSS,
- NUM_SECTIONS
-} MenuSection;
-
-static Window *s_window;
-static MenuLayer *s_menu_layer;
-
-/* Small local day-level name table, same duplication policy as
- * interpreter/src/main.c's own day_level_name() - see significance.c's
- * comment on why each independently-testable/linkable module keeps its
- * own copy rather than sharing one across translation units that aren't
- * otherwise coupled. */
-static const char *day_level_name(DaySignificance level) {
- switch (level) {
- case DAY_QUIET: return i18n_get("interp.day_level.quiet", "Quiet");
- case DAY_NOTABLE: return i18n_get("interp.day_level.notable", "Notable");
- case DAY_SIGNIFICANT: return i18n_get("interp.day_level.significant", "Significant");
- case DAY_MAJOR: return i18n_get("interp.day_level.major", "Major");
- default: return "";
- }
-}
-
-static const char *reversed_marker(bool reversed) {
- return reversed ? i18n_get("ui.reversed", "(Reversed)") : "";
-}
-
-static uint16_t get_num_sections_callback(MenuLayer *menu_layer, void *data) {
- return NUM_SECTIONS;
-}
-
-static uint16_t get_num_rows_callback(MenuLayer *menu_layer, uint16_t section_index, void *data) {
- const DailyInterpretation *interp = reading_state_get_interpretation();
- switch (section_index) {
- case SECTION_TODAY: return 1;
- case SECTION_TRANSITS: return interp->top_item_count > 0 ? interp->top_item_count : 1;
- case SECTION_CELTIC_CROSS: return TAROT_SPREAD_SIZE;
- default: return 0;
- }
-}
-
-static int16_t get_header_height_callback(MenuLayer *menu_layer, uint16_t section_index, void *data) {
- return MENU_CELL_BASIC_HEADER_HEIGHT;
-}
-
-static void draw_header_callback(GContext *ctx, const Layer *cell_layer, uint16_t section_index, void *data) {
- const char *title = "";
- switch (section_index) {
- case SECTION_TODAY: title = i18n_get("interp.heading_day_significance", "Day Significance"); break;
- case SECTION_TRANSITS: title = i18n_get("interp.heading_transits", "Significant Transits"); break;
- case SECTION_CELTIC_CROSS: title = i18n_get("ui.celtic_cross", "Celtic Cross"); break;
- }
- menu_cell_basic_header_draw(ctx, cell_layer, title);
-}
-
-static void draw_row_callback(GContext *ctx, const Layer *cell_layer, MenuIndex *cell_index, void *data) {
- const DailyReading *reading = reading_state_get_reading();
- const DailyInterpretation *interp = reading_state_get_interpretation();
-
- char title[48];
- char subtitle[64];
-
- switch (cell_index->section) {
- case SECTION_TODAY:
- snprintf(title, sizeof title, "%s (%d/%d)", day_level_name(interp->day_level),
- interp->day_level + 1, DAY_SIGNIFICANCE_COUNT);
- menu_cell_basic_draw(ctx, cell_layer, title, NULL, NULL);
- break;
-
- case SECTION_TRANSITS:
- if (interp->top_item_count == 0) {
- menu_cell_basic_draw(ctx, cell_layer,
- i18n_get("interp.no_notable_transits", "No notable transits today."),
- NULL, NULL);
- break;
- }
- {
- const Aspect *a = &interp->top_items[cell_index->row].aspect;
- snprintf(title, sizeof title, "%s %s", i18n_get("ui.transiting", "Transiting"),
- astro_body_name(a->transiting_planet));
- snprintf(subtitle, sizeof subtitle, "%s %s %s", astro_aspect_name(a->type),
- i18n_get("ui.natal", "natal"), astro_body_name(a->natal_planet));
- menu_cell_basic_draw(ctx, cell_layer, title, subtitle, NULL);
- }
- break;
-
- case SECTION_CELTIC_CROSS: {
- const TarotDraw *draw = &reading->spread.positions[cell_index->row];
- snprintf(title, sizeof title, "%s", tarot_position_name((CelticCrossPosition)cell_index->row));
- snprintf(subtitle, sizeof subtitle, "%s %s", tarot_card_name(draw->card),
- reversed_marker(draw->reversed));
- menu_cell_basic_draw(ctx, cell_layer, title, subtitle, NULL);
- break;
- }
- }
-}
-
-static void select_callback(MenuLayer *menu_layer, MenuIndex *cell_index, void *data) {
- const DailyReading *reading = reading_state_get_reading();
- const DailyInterpretation *interp = reading_state_get_interpretation();
-
- switch (cell_index->section) {
- case SECTION_TODAY: {
- char body[GUIDANCE_TEXT_MAX];
- guidance_write(body, sizeof body, "", interp, &reading->spread);
- text_window_push(i18n_get("interp.heading_day_significance", "Day Significance"), body);
- break;
- }
-
- case SECTION_TRANSITS: {
- if (interp->top_item_count == 0) break;
- const Aspect *a = &interp->top_items[cell_index->row].aspect;
- char title[48];
- snprintf(title, sizeof title, "%s %s", i18n_get("ui.transiting", "Transiting"),
- astro_body_name(a->transiting_planet));
- char body[NARRATIVE_TEXT_MAX];
- narrative_write(body, sizeof body, "", a, reading->transits.bodies[a->transiting_planet].house);
- text_window_push(title, body);
- break;
- }
-
- case SECTION_CELTIC_CROSS: {
- const TarotDraw *draw = &reading->spread.positions[cell_index->row];
- char title[64];
- snprintf(title, sizeof title, "%s: %s %s",
- tarot_position_name((CelticCrossPosition)cell_index->row),
- tarot_card_name(draw->card), reversed_marker(draw->reversed));
- char body[600];
- snprintf(body, sizeof body, "%s\n\n%s",
- tarot_position_description((CelticCrossPosition)cell_index->row),
- tarot_card_meaning(draw->card, draw->reversed));
- card_window_push(draw->card, title, body);
- break;
- }
- }
-}
-
-static void window_load(Window *window) {
- Layer *window_layer = window_get_root_layer(window);
- GRect bounds = layer_get_bounds(window_layer);
-
- s_menu_layer = menu_layer_create(bounds);
- menu_layer_set_callbacks(s_menu_layer, NULL, (MenuLayerCallbacks){
- .get_num_sections = get_num_sections_callback,
- .get_num_rows = get_num_rows_callback,
- .get_header_height = get_header_height_callback,
- .draw_header = draw_header_callback,
- .draw_row = draw_row_callback,
- .select_click = select_callback,
- });
- menu_layer_set_click_config_onto_window(s_menu_layer, window);
- layer_add_child(window_layer, menu_layer_get_layer(s_menu_layer));
-}
-
-static void window_unload(Window *window) {
- menu_layer_destroy(s_menu_layer);
- s_menu_layer = NULL;
-}
-
-void menu_window_push(void) {
- if (!s_window) {
- s_window = window_create();
- window_set_window_handlers(s_window, (WindowHandlers){
- .load = window_load,
- .unload = window_unload,
- });
- }
- window_stack_push(s_window, true);
-}
-
-void menu_window_reload(void) {
- if (s_menu_layer) menu_layer_reload_data(s_menu_layer);
-}
-
-void menu_window_deinit(void) {
- if (s_window) window_destroy(s_window);
- s_window = NULL;
-}
diff --git a/watch/src/c/ui_menu_window.h b/watch/src/c/ui_menu_window.h
deleted file mode 100644
index 5ece5da..0000000
--- a/watch/src/c/ui_menu_window.h
+++ /dev/null
@@ -1,19 +0,0 @@
-#ifndef WATCH_UI_MENU_WINDOW_H
-#define WATCH_UI_MENU_WINDOW_H
-
-/* The app's root screen: a 3-section MenuLayer (Day Significance /
- * Significant Transits / Celtic Cross) reading directly from
- * reading_state.h's current reading - see reading.h's own doc comment
- * ("walks the returned struct directly to lay out its own screens").
- * Selecting a row pushes a detail screen (ui_text_window.h for prose,
- * ui_card_window.h for a Celtic Cross position). A persistent singleton -
- * created once and left at the bottom of the window stack; call
- * menu_window_push() once at startup (and again if the day rolls over
- * and the underlying reading changes - the MenuLayer re-reads
- * reading_state.h on every redraw, so no explicit refresh call is
- * needed beyond menu_layer_reload_data()). */
-void menu_window_push(void);
-void menu_window_reload(void);
-void menu_window_deinit(void);
-
-#endif
diff --git a/watch/src/c/ui_report_window.c b/watch/src/c/ui_report_window.c
new file mode 100644
index 0000000..1deb4e7
--- /dev/null
+++ b/watch/src/c/ui_report_window.c
@@ -0,0 +1,243 @@
+#include "ui_report_window.h"
+
+#include
+
+#include "../../../engine/src/i18n.h"
+#include "../../../interpreter/src/guidance.h"
+#include "../../../interpreter/src/narrative.h"
+#include "reading_state.h"
+#include "resource_map.h"
+
+#define MARGIN 6
+
+/* Matches gen_card_images.py's own CARD_WIDTH - a hardware-verified
+ * ceiling, not a design choice: full-display-width images reliably
+ * crashed the real PNG decoder ("PNG memory allocation failed", a
+ * firmware-level constraint independent of this app's own heap budget,
+ * confirmed unaffected by lazy-loading only one image at a time). */
+#define IMAGE_WIDTH 72
+
+/* fonts_get_system_font() needs Pebble's runtime initialized, so these
+ * are resolved once in window_load() rather than at static-init time. */
+static GFont s_font_section;
+static GFont s_font_subhead;
+static GFont s_font_body;
+
+/* Static buffers for every dynamically-built string this window shows -
+ * tarot_position_description()/tarot_card_meaning()/i18n_get() fallback
+ * text all point straight at flash/ROM data (see i18n.h's own comment on
+ * i18n_load_table()), so only the pieces actually assembled at runtime
+ * (via snprintf/narrative_write/guidance_write) need their own storage.
+ * Static rather than malloc'd - fixed, known-in-advance sizes, and it
+ * keeps report_window_reload() from having to reason about freeing the
+ * previous run's buffers before reusing them. */
+static char s_day_title[64];
+static char s_transit_titles[MAX_SIGNIFICANT_ITEMS][96];
+static char s_narratives[MAX_SIGNIFICANT_ITEMS][NARRATIVE_TEXT_MAX];
+static char s_position_titles[TAROT_SPREAD_SIZE][96];
+static char s_guidance[GUIDANCE_TEXT_MAX];
+
+/* Loads/frees each position's GBitmap as scroll_offset_changed() sees it
+ * enter/leave the visible viewport, rather than decoding all ten Celtic
+ * Cross images up front - a defensive habit left over from an earlier,
+ * larger IMAGE_WIDTH (see that constant's own comment); at 72px this
+ * struct's images are small enough that it's no longer strictly
+ * necessary, but it's harmless to keep. */
+typedef struct {
+ TarotCard card;
+ int16_t y;
+ int16_t height;
+ BitmapLayer *bitmap_layer;
+ GBitmap *bitmap;
+} ImageSlot;
+
+static ImageSlot s_slots[TAROT_SPREAD_SIZE];
+
+#define MAX_TEXT_LAYERS 50
+static TextLayer *s_text_layers[MAX_TEXT_LAYERS];
+static int s_text_layer_count;
+
+static Window *s_window;
+static ScrollLayer *s_scroll_layer;
+static int16_t s_screen_height;
+
+static const char *day_level_name(DaySignificance level) {
+ switch (level) {
+ case DAY_QUIET: return i18n_get("interp.day_level.quiet", "Quiet");
+ case DAY_NOTABLE: return i18n_get("interp.day_level.notable", "Notable");
+ case DAY_SIGNIFICANT: return i18n_get("interp.day_level.significant", "Significant");
+ case DAY_MAJOR: return i18n_get("interp.day_level.major", "Major");
+ default: return "";
+ }
+}
+
+static const char *reversed_marker(bool reversed) {
+ return reversed ? i18n_get("ui.reversed", "(Reversed)") : "";
+}
+
+static int16_t add_text(int16_t width, int16_t y, const char *text, GFont font) {
+ TextLayer *tl = text_layer_create(GRect(0, y, width, 4000));
+ text_layer_set_font(tl, font);
+ text_layer_set_text(tl, text);
+ text_layer_set_overflow_mode(tl, GTextOverflowModeWordWrap);
+
+ GSize size = text_layer_get_content_size(tl);
+ size.w = width;
+ size.h += 4;
+ text_layer_set_size(tl, size);
+
+ scroll_layer_add_child(s_scroll_layer, text_layer_get_layer(tl));
+ s_text_layers[s_text_layer_count++] = tl;
+ return y + size.h + MARGIN;
+}
+
+static void teardown_content(void) {
+ for (int i = 0; i < s_text_layer_count; i++) {
+ text_layer_destroy(s_text_layers[i]);
+ }
+ s_text_layer_count = 0;
+
+ for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
+ if (s_slots[i].bitmap) {
+ gbitmap_destroy(s_slots[i].bitmap);
+ s_slots[i].bitmap = NULL;
+ }
+ if (s_slots[i].bitmap_layer) {
+ bitmap_layer_destroy(s_slots[i].bitmap_layer);
+ s_slots[i].bitmap_layer = NULL;
+ }
+ }
+}
+
+static void update_visible_images(void) {
+ GPoint offset = scroll_layer_get_content_offset(s_scroll_layer);
+ int16_t visible_top = -offset.y;
+ int16_t visible_bottom = visible_top + s_screen_height;
+
+ for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
+ ImageSlot *slot = &s_slots[i];
+ bool visible = slot->y < visible_bottom && slot->y + slot->height > visible_top;
+
+ if (visible && !slot->bitmap) {
+ slot->bitmap = gbitmap_create_with_resource(card_resource_id(slot->card));
+ bitmap_layer_set_bitmap(slot->bitmap_layer, slot->bitmap);
+ } else if (!visible && slot->bitmap) {
+ bitmap_layer_set_bitmap(slot->bitmap_layer, NULL);
+ gbitmap_destroy(slot->bitmap);
+ slot->bitmap = NULL;
+ }
+ }
+}
+
+static void scroll_offset_changed(ScrollLayer *scroll_layer, void *context) {
+ update_visible_images();
+}
+
+static void build_content(int16_t width) {
+ const DailyReading *reading = reading_state_get_reading();
+ const DailyInterpretation *interp = reading_state_get_interpretation();
+
+ int16_t y = MARGIN;
+
+ y = add_text(width, y, i18n_get("interp.heading_day_significance", "Day Significance"), s_font_section);
+ snprintf(s_day_title, sizeof s_day_title, "%s (%d/%d)", day_level_name(interp->day_level),
+ interp->day_level + 1, DAY_SIGNIFICANCE_COUNT);
+ y = add_text(width, y, s_day_title, s_font_body);
+
+ y = add_text(width, y, i18n_get("interp.heading_transits", "Significant Transits"), s_font_section);
+ if (interp->top_item_count == 0) {
+ y = add_text(width, y, i18n_get("interp.no_notable_transits", "No notable transits today."), s_font_body);
+ } else {
+ for (int i = 0; i < interp->top_item_count; i++) {
+ const Aspect *a = &interp->top_items[i].aspect;
+ snprintf(s_transit_titles[i], sizeof s_transit_titles[i], "%s %s %s %s %s",
+ i18n_get("ui.transiting", "Transiting"), astro_body_name(a->transiting_planet),
+ astro_aspect_name(a->type), i18n_get("ui.natal", "natal"),
+ astro_body_name(a->natal_planet));
+ y = add_text(width, y, s_transit_titles[i], s_font_subhead);
+
+ narrative_write(s_narratives[i], sizeof s_narratives[i], "", a,
+ reading->transits.bodies[a->transiting_planet].house);
+ y = add_text(width, y, s_narratives[i], s_font_body);
+ }
+ }
+
+ y = add_text(width, y, i18n_get("ui.celtic_cross", "Celtic Cross"), s_font_section);
+ for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
+ const TarotDraw *draw = &reading->spread.positions[i];
+
+ snprintf(s_position_titles[i], sizeof s_position_titles[i], "%s: %s %s",
+ tarot_position_name((CelticCrossPosition)i), tarot_card_name(draw->card),
+ reversed_marker(draw->reversed));
+ y = add_text(width, y, s_position_titles[i], s_font_subhead);
+
+ s_slots[i].card = draw->card;
+ s_slots[i].y = y;
+ s_slots[i].height = card_image_height_for_width(IMAGE_WIDTH);
+ int16_t image_x = (width - IMAGE_WIDTH) / 2;
+ s_slots[i].bitmap_layer = bitmap_layer_create(GRect(image_x, y, IMAGE_WIDTH, s_slots[i].height));
+ bitmap_layer_set_alignment(s_slots[i].bitmap_layer, GAlignCenter);
+ scroll_layer_add_child(s_scroll_layer, bitmap_layer_get_layer(s_slots[i].bitmap_layer));
+ y += s_slots[i].height + MARGIN;
+
+ y = add_text(width, y, tarot_position_description((CelticCrossPosition)i), s_font_body);
+ y = add_text(width, y, tarot_card_meaning(draw->card, draw->reversed), s_font_body);
+ }
+
+ y = add_text(width, y, i18n_get("interp.heading_guidance", "Guidance"), s_font_section);
+ guidance_write(s_guidance, sizeof s_guidance, "", interp, &reading->spread);
+ y = add_text(width, y, s_guidance, s_font_body);
+
+ scroll_layer_set_content_size(s_scroll_layer, GSize(width, y));
+}
+
+static void window_load(Window *window) {
+ s_font_section = fonts_get_system_font(FONT_KEY_GOTHIC_28_BOLD);
+ s_font_subhead = fonts_get_system_font(FONT_KEY_GOTHIC_24_BOLD);
+ s_font_body = fonts_get_system_font(FONT_KEY_GOTHIC_24_BOLD);
+
+ Layer *window_layer = window_get_root_layer(window);
+ GRect bounds = layer_get_bounds(window_layer);
+ s_screen_height = bounds.size.h;
+
+ s_scroll_layer = scroll_layer_create(bounds);
+ scroll_layer_set_click_config_onto_window(s_scroll_layer, window);
+ scroll_layer_set_callbacks(s_scroll_layer, (ScrollLayerCallbacks){
+ .content_offset_changed_handler = scroll_offset_changed,
+ });
+ layer_add_child(window_layer, scroll_layer_get_layer(s_scroll_layer));
+
+ build_content(bounds.size.w);
+ update_visible_images();
+}
+
+static void window_unload(Window *window) {
+ teardown_content();
+ scroll_layer_destroy(s_scroll_layer);
+ s_scroll_layer = NULL;
+}
+
+void report_window_push(void) {
+ if (!s_window) {
+ s_window = window_create();
+ window_set_window_handlers(s_window, (WindowHandlers){
+ .load = window_load,
+ .unload = window_unload,
+ });
+ }
+ window_stack_push(s_window, true);
+}
+
+void report_window_reload(void) {
+ if (!s_window || !s_scroll_layer) return;
+ Layer *window_layer = window_get_root_layer(s_window);
+ int16_t width = layer_get_bounds(window_layer).size.w;
+ teardown_content();
+ build_content(width);
+ update_visible_images();
+}
+
+void report_window_deinit(void) {
+ if (s_window) window_destroy(s_window);
+ s_window = NULL;
+}
diff --git a/watch/src/c/ui_report_window.h b/watch/src/c/ui_report_window.h
new file mode 100644
index 0000000..4ba602e
--- /dev/null
+++ b/watch/src/c/ui_report_window.h
@@ -0,0 +1,23 @@
+#ifndef WATCH_UI_REPORT_WINDOW_H
+#define WATCH_UI_REPORT_WINDOW_H
+
+/* The app's only real screen: one continuously scrollable page mirroring
+ * the interpreter's own --format text/html report - day significance,
+ * each significant transit's narrative, the full Celtic Cross spread
+ * (each position's card art at the watch's own display width, name,
+ * description, and meaning), and the guidance paragraph last - see
+ * reading_state.h for where all of this data comes from.
+ *
+ * Card art is loaded lazily as it scrolls into view and freed once it
+ * scrolls back out - a full-display-width card image is too large to
+ * keep all ten resident at once on this app's RAM budget (see
+ * ui_report_window.c's own comment) - so unlike a plain ScrollLayer of
+ * static content, this needs report_window_push()'s window to actually
+ * be on-screen to drive that loading; call report_window_reload() (not
+ * push() again) to refresh already-built content after the day rolls
+ * over or new birth data is submitted. */
+void report_window_push(void);
+void report_window_reload(void);
+void report_window_deinit(void);
+
+#endif
diff --git a/watch/src/c/ui_text_window.c b/watch/src/c/ui_text_window.c
index 6a5afa1..f38cc95 100644
--- a/watch/src/c/ui_text_window.c
+++ b/watch/src/c/ui_text_window.c
@@ -2,7 +2,7 @@
#include
-#define TITLE_HEIGHT 24
+#define TITLE_HEIGHT 30
typedef struct {
char *title;
@@ -18,7 +18,7 @@ static void window_load(Window *window) {
GRect bounds = layer_get_bounds(window_layer);
state->title_layer = text_layer_create(GRect(0, 0, bounds.size.w, TITLE_HEIGHT));
- text_layer_set_font(state->title_layer, fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD));
+ text_layer_set_font(state->title_layer, fonts_get_system_font(FONT_KEY_GOTHIC_24_BOLD));
text_layer_set_text(state->title_layer, state->title);
text_layer_set_overflow_mode(state->title_layer, GTextOverflowModeTrailingEllipsis);
layer_add_child(window_layer, text_layer_get_layer(state->title_layer));
@@ -31,7 +31,7 @@ static void window_load(Window *window) {
* measure the real wrapped height - standard Pebble ScrollLayer
* pattern (a TextLayer taller than its content just clips nothing). */
state->body_layer = text_layer_create(GRect(0, 0, scroll_bounds.size.w, 2000));
- text_layer_set_font(state->body_layer, fonts_get_system_font(FONT_KEY_GOTHIC_18));
+ text_layer_set_font(state->body_layer, fonts_get_system_font(FONT_KEY_GOTHIC_24_BOLD));
text_layer_set_text(state->body_layer, state->body);
text_layer_set_overflow_mode(state->body_layer, GTextOverflowModeWordWrap);
diff --git a/watch/src/pkjs/index.js b/watch/src/pkjs/index.js
index 09d4264..18c5b7b 100644
--- a/watch/src/pkjs/index.js
+++ b/watch/src/pkjs/index.js
@@ -1,5 +1,220 @@
-// Skeleton only - the real config webview (birth data/language/
-// notification settings) is built out in a later task, see CLAUDE.md/
-// TaskList. No companion app, no server: this file's only future job is
-// showConfiguration/webviewclosed handling for a self-contained `data:`
-// URI settings form.
+// Self-contained Pebble config page - no server, no companion app. The
+// form itself is a `data:` URI built entirely here in pkjs and opened
+// via Pebble.openURL(); app_message.c's inbox_received_handler is the
+// receiving side this pairs with (see its own comment for the wire
+// format - lat/lon/UTC offset cross as scaled int32s since AppMessage
+// has no float tuple type).
+//
+// The webview's own storage can't be relied on to remember values
+// between openings (Pebble treats each `data:` URI load as a fresh,
+// effectively unique origin), so previously-submitted values are kept
+// in *pkjs's* own localStorage (persists across app launches on the
+// phone) and baked into the generated HTML each time the page opens.
+//
+// Plain ES5 throughout (var, string concatenation, no arrow functions/
+// template literals) - the webview runs in whatever WebView the Pebble
+// mobile app itself embeds, not a guaranteed-modern engine.
+
+var STORAGE_KEY = 'deckInADashConfig';
+var LANGUAGES = [
+ { code: 'en', label: 'English' },
+ { code: 'de', label: 'Deutsch' }
+];
+
+function loadSavedConfig() {
+ try {
+ var raw = window.localStorage.getItem(STORAGE_KEY);
+ if (raw) return JSON.parse(raw);
+ } catch (e) {
+ // Corrupt/inaccessible storage - fall through to defaults below.
+ }
+ return {};
+}
+
+function saveConfig(cfg) {
+ try {
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg));
+ } catch (e) {
+ // Best-effort only - the watch already has the real submitted
+ // values via AppMessage regardless of whether this cache succeeds.
+ }
+}
+
+function escapeHtml(value) {
+ var s = value === null || value === undefined ? '' : String(value);
+ return s.replace(/[&<>"']/g, function (c) {
+ return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
+ });
+}
+
+function buildLanguageOptions(selected) {
+ var html = '';
+ for (var i = 0; i < LANGUAGES.length; i++) {
+ var lang = LANGUAGES[i];
+ var isSelected = lang.code === selected ? ' selected' : '';
+ html += '';
+ }
+ return html;
+}
+
+// Runs *inside* the webview (embedded as a ' +
+ '