| name | hc-new-widget |
| description | Add widgets and features to HamClock-Next using REGISTER_WIDGET pattern. Uses hamclock-bridge MCP for code scaffolding, templates, and checklists. Guides design → code → verify → document workflow. |
Add Feature to HamClock-Next
Workflow for new widgets, providers, data stores, and services. Delegates code generation to MCP tools to avoid duplication.
Design Interview
Before writing code, understand the feature scope:
What are you building?
- Widget (renders data, inherits from Widget)
- Provider (fetches data, runs async)
- Data store (plain struct, thread-safe access)
- Service (lifecycle component, singleton)
- API endpoint (WebServer REST route)
Scope & Complexity Check
- 1-2 sentence description
- Component count? (Provider + Panel? New data struct?)
- External data fetch? Auth needed?
- Map/graph rendering? Modal dialogs?
If complexity high (6+ components, 3+ async dependencies, graph overlay):
- Suggest splitting into phases or simpler variant
- Example: "Start with static display (no external data). Once working, add real-time provider."
If acceptable → proceed to Scaffolding.
Base Class Selection
Before scaffolding, choose your base class:
- Widget: Custom layouts (maps, charts, lists from scratch, space weather displays). Standard override set:
update(), render(), plus optional onMouseDown, onMouseUp, onRightClick, onMouseMove, onKeyDown, onTextInput, onMouseWheel, onResize.
- ListPanel: Tabular/list-style data (LoTWSyncPanel, AlertsPanel, log viewers). Constructor:
ListPanel(x, y, w, h, fontMgr, title_string, headers_vector). Required override: populateRows(std::vector<ListPanel::Row>&).
Both require: update(), render(SDL_Renderer*), getName(), getDisplayName(), typeId(). Optional: isScrollable(), requiresConfigKey(), getDebugData().
Scaffolding
Step 1: Get Boilerplate Template
Call MCP to fetch C++ boilerplate for your feature type:
Step 2: Generate & Write Files
Call MCP to create files in the repository:
Step 3: REGISTER_WIDGET Integration
Add to your widget's .cpp file:
REGISTER_WIDGET("{snake_case}", "{Display Name}", true, false, {
auto p = std::make_unique<{Name}Panel>(0, 0, 0, 0, deps...);
return p;
})
Key points:
REGISTER_WIDGET macro handles self-registration at startup
- Pass
deps struct containing FontManager, stores, providers, etc. (see src/ui/WidgetDeps.h for all 50+ fields)
- Post-creation callbacks remain in DashboardContext (unchanged)
- No manual if/else chain in DashboardContext—registry handles creation
- CRITICAL: Both CMakeLists.txt AND REGISTER_WIDGET macro required — missing either causes silent failure
Step 4: CMakeLists Integration
# Add to HAMCLOCK_SOURCES in CMakeLists.txt
list(APPEND HAMCLOCK_SOURCES
src/ui/{Name}Panel.cpp
src/services/{Name}Provider.cpp # if fetching data
)
Step 5: Build & Verify
cmake --build build --target hamclock-next -j10
✅ Clean build? Continue.
❌ Errors? Fix before next step.
Standards Verification
C++ Safety (cpp-safety-audit patterns)
Raw Pointers & Lifetime
- ❌ Don't capture raw
this in async callbacks
- ✅ Use
shared_ptr<AsyncState> or alive_ flag
Example safe async pattern:
auto alive = alive_;
NetworkManager::getInstance().fetchAsync(url,
[this, alive](std::string response) {
if (!alive->load(std::memory_order_acquire)) return;
});
Thread Safety
- ✅ Use
std::atomic<> for shared state
- ✅ Mutex-protect map/vector access
- ❌ No raw pointers to heap objects shared between threads
Resource Leaks
- ✅ SDL_Texture →
SDL_DestroyTexture in dtor
- ✅ All heap → smart pointers (
shared_ptr, unique_ptr)
Theming (No Hardcoded Colors)
void YourPanel::render(SDL_Renderer* renderer) {
ThemeColors themes = getThemeColors(theme_);
SDL_SetRenderDrawColor(renderer, themes.bg.r, themes.bg.g, themes.bg.b, 255);
SDL_Rect rect = {...};
SDL_RenderFillRect(renderer, &rect);
}
Available tokens: bg, text, textDim, accent, success, warning, danger, border, rowStripe1, rowStripe2.
Config Persistence
If state survives restart:
- Add field to
AppConfig struct (src/core/ConfigManager.h)
- Load from JSON in
ConfigManager::load()
- Save to JSON in
ConfigManager::save()
- Widget load/save via
ConfigManager::getInstance()
Example:
AppConfig cfg = ConfigManager::getInstance().getConfig();
textInput_.setValue(cfg.widgetNotes);
cfg.widgetNotes = textInput_.getValue();
ConfigManager::getInstance().save(cfg);
TextBox Support (if text input)
Required methods in Panel class:
bool onKeyDown(SDL_Keycode key, Uint16 mod) override {
if (activeTextBox_) {
if (key == SDLK_RETURN) { submitInput(); return true; }
if (key == SDLK_ESCAPE) { cancelInput(); return true; }
}
return false;
}
bool onTextInput(const char* text) override {
if (activeTextBox_) { appendChar(text); return true; }
return false;
}
Error Handling (External APIs)
void {Name}Provider::fetch() {
auto alive = alive_;
NetworkManager::getInstance().fetchAsync(url,
[this, alive](std::string response) {
if (!alive->load(std::memory_order_acquire)) return;
if (response.empty()) {
LOG_W("{Name}Provider", "Empty response");
data_.fetched = true;
data_.valid = false;
return;
}
try {
auto j = nlohmann::json::parse(response);
data_.valid = true;
data_.fetched = true;
} catch (const std::exception& e) {
LOG_E("{Name}Provider", "Parse: %s", e.what());
data_.valid = false;
data_.fetched = true;
}
});
}
Widget render logic:
void {Name}Panel::render(SDL_Renderer *renderer) {
auto data = store_->get();
if (!data.fetched) {
cat->drawText(renderer, "Loading...", x_, y_, themes.textDim, FontStyle::Micro);
} else if (!data.valid) {
cat->drawText(renderer, "API unavailable", x_, y_, themes.warning, FontStyle::Micro);
} else {
renderData(renderer, data);
}
}
Store Class (Thread-Safe Data Access)
class {Name}Store {
public:
static std::shared_ptr<{Name}Store> instance();
{Name}Data get() const;
void update(const {Name}Data& data);
private:
mutable std::mutex mutex_;
{Name}Data data_;
};
std::shared_ptr<{Name}Store> {Name}Store::instance() {
static auto inst = std::make_shared<{Name}Store>();
return inst;
}
{Name}Data {Name}Store::get() const {
std::lock_guard<std::mutex> lock(mutex_);
return data_;
}
void {Name}Store::update(const {Name}Data& data) {
std::lock_guard<std::mutex> lock(mutex_);
data_ = data;
}
Undo/Redo (if text editing)
private:
std::vector<std::string> undoStack_, redoStack_;
static constexpr int MAX_UNDO = 20;
void pushUndo() {
if (undoStack_.size() >= MAX_UNDO) undoStack_.erase(undoStack_.begin());
undoStack_.push_back(noteInput_.getValue());
redoStack_.clear();
}
if (ctrl && key == SDLK_z && !undoStack_.empty()) {
redoStack_.push_back(noteInput_.getValue());
noteInput_.setValue(undoStack_.back());
undoStack_.pop_back();
return true;
}
if (ctrl && key == SDLK_y && !redoStack_.empty()) {
undoStack_.push_back(noteInput_.getValue());
noteInput_.setValue(redoStack_.back());
redoStack_.pop_back();
return true;
}
Modal Dialogs
if (modalActive_) renderModal(renderer);
void {Name}Panel::renderModal(SDL_Renderer *renderer) {
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 128);
SDL_RenderFillRect(renderer, &overlay);
SDL_SetRenderDrawColor(renderer, themes.bg.r, themes.bg.g, themes.bg.b, 255);
SDL_RenderFillRect(renderer, &modalBox);
auto *cat = fontMgr_.catalog();
cat->drawText(renderer, "Title", x, y, themes.accent, FontStyle::SmallBold);
okRect_ = {...};
cancelRect_ = {...};
}
bool {Name}Panel::onMouseUp(int mx, int my, Uint16 mod, int clicks) {
if (modalActive_) {
if (point_in_rect(mx, my, okRect_)) { submitModal(); return true; }
if (point_in_rect(mx, my, cancelRect_)) { cancelModal(); return true; }
}
return false;
}
bool {Name}Panel::onKeyDown(SDL_Keycode key, Uint16 mod) {
if (modalActive_) {
if (key == SDLK_RETURN) { submitModal(); return true; }
if (key == SDLK_ESCAPE) { cancelModal(); return true; }
return modalInput_.onKeyDown(key, mod);
}
return false;
}
Map Overlay Integration
public:
void renderMapOverlay(SDL_Renderer *renderer, const MapContext& ctx);
bool isMapOverlayActive() const { return overlayActive_; }
if (spaceWxPanel && spaceWxPanel->isMapOverlayActive()) {
spaceWxPanel->renderMapOverlay(renderer, mapCtx);
}
WASM Build Guards
#ifndef __EMSCRIPTEN__
NetworkManager::getInstance().fetchAsync(url, callback);
#else
emscripten_fetch_attr_t attr;
emscripten_fetch_attr_init(&attr);
emscripten_fetch(&attr, url.c_str());
#endif
Rate Limiting & Backoff
For providers that hit rate-limited APIs:
time_t now = time(nullptr);
if (now < nextFetch_) return;
bool success = fetch(...);
if (!success) {
backoffSeconds_ = std::min(600, backoffSeconds_ * 2);
nextFetch_ = now + backoffSeconds_;
} else {
backoffSeconds_ = 30;
nextFetch_ = now + 300;
}
Debugging Patterns
Common failure modes and diagnostics:
Widget doesn't appear in dashboard:
- Check
/debug/widgets: curl http://localhost:8080/debug/widgets | jq '.widgets[] | .typeId'
- Missing? REGISTER_WIDGET not reached (check CMakeLists.txt or macro syntax)
- Wrong name? Check typeId string matches config
Widget appears but stays blank (no data):
- Is provider running? Check provider::update() called (add LOG_I("Fetching..."))
- Is store populated? Call getDebugData() to inspect latest() data
- Is render() reading from store? Verify store.mutex lock + getLatest() call
Async callback crashes:
- Did you capture raw
this? Use alive_ flag check before touching members
- Did you forget thread-safe access? Use store->mutex() or atomic
Memory leak on exit:
- Check SDL_Texture cleanup in dtor (use MemoryMonitor::destroyTexture)
- Check event data passed via SDL_PushEvent is freed in handler
- Check shared_ptrs not stored in global state
Texture constantly recreates (perf issue):
- Use StatusCache pattern: cache texture, invalidate only on data change
- Example: LoTWSyncPanel caches row textures per fetch generation
Final Checklist
Call MCP to get feature-specific checklist:
Returns: Exact registration steps for your feature type.
Manual verification:
Documentation Updates
1. Commit Message
Write to working_docs/commit-message.txt:
feat(widgets): Add {Feature} widget with {capability}
- Widget displays {what it shows}
- Provider fetches from {data source}
- Integrates with {related component}
- REGISTER_WIDGET pattern, fully themed
2. PROJECT_STATUS.md
Add to working_docs/PROJECT_STATUS.md:
[YYYY-MM-DD] Implemented {Feature}: {description}
3. CHANGELOG.md
Prepend:
## [v1.7.0] — YYYY-MM-DD
### Added
- **{Feature} Widget** — {one-sentence description}
- Sub-item 1
- Sub-item 2
4. RELEASE_NOTES_vX.X.md
If v1.X exists, update working_docs/RELEASE_NOTES_v1.X.md:
## {Feature} Widget
**What it does:**
{Brief description}
**How to use:**
{User-facing instructions}
**Technical notes:**
{API/config details if relevant}
References
- WidgetDeps struct:
src/ui/WidgetDeps.h — all 50+ dependency fields available to factory lambdas
- Widget base class:
src/ui/Widget.h — virtual methods, optional overrides
- ListPanel for tables:
src/ui/ListPanel.h — when to use, constructor pattern
- MCP Code Generation:
scaffold_feature, get_scaffolding_template, new_feature_checklist
- C++ Safety: Run
/cpp-safety-audit to scan for lifetime bugs
- Code Review: Run
/code-reviewer for design audit
- Project Context:
/mcp__hamclock-bridge__project_context for architecture overview
- Widget Examples:
src/ui/DXClusterPanel.cpp, src/ui/SpaceWeatherPanel.cpp, src/ui/LoTWSyncPanel.cpp (ListPanel example)
- Theming:
src/core/Theme.h — all color tokens
- Debugging:
/debug/widgets endpoint shows all registered widgets; getDebugData() in your panel for state inspection