Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,16 @@ Three singletons manage core state. All run on the main thread. Access via `Clas
- **Security**: bearer tokens are never logged in Sentry breadcrumbs; manifests must not contain absolute paths/usernames. Upload requires an explicit user action in the GUI; CLI uses `--no-confirm` for CI. API base override: `QTMESH_API_BASE` (tests + self-hosted).
- **Limits**: `qtmesh cloud limits` / MCP `cloud_limits` read server caps from `GET /v1/auth/me` when exposed; scan reports are capped at 5 MB client-side.

### Gamification / Progress Sync (epic #796, cloud epic qtmesh-cloud#79)

- **GamificationManager** (`src/GamificationManager.h/cpp`): singleton orchestrator (also a QML singleton under both `WelcomeScreen 1.0` and `PropertiesPanel 1.0`). Instrumentation is two static one-liners: `GamificationManager::noteFeature("<feature_key>", Surface::Gui|Cli|Mcp)` at controller entry points (E-P2 #798) and `noteOperation("<op>", {{"tris_before", n}, …})` where before/after metrics exist (E-P3 #799). `noteOperation` also counts the matching feature cluster, so op sites need only one call. Both are thread-safe (marshal to the main thread) and no-ops until the user consents.
- **Privacy invariants (E-P6 #802)**: default OFF — nothing is queued before the one-time non-blocking consent prompt (shown on first would-be event while signed in) is answered or "Sync my QtMesh progress" is enabled in Preferences → General. Metrics are filtered to numeric values only (`numericMetricsOnly`) so asset content/file names can never leak. Zero network when logged out or opted out. `DELETE /v1/me/gamification` + local queue/cache purge via the Preferences "Delete my gamification data" button. CLI honours `--no-telemetry`.
- **Queue + flush (E-P1 #797)**: `GamificationEventQueue` (`src/GamificationEventQueue.h/cpp`) is a persistent, cross-process-safe (QLockFile) JSON queue in `<AppData>/gamification/queue.json`; every event carries a client UUID idempotency id (the server dedup key), capacity 500 with logged FIFO eviction. GUI flushes on a debounce + 90s heartbeat + graceful-shutdown, with exponential backoff (max 30 min); CLI enqueues during the subcommand and calls `flushBlocking()` before `_exit`. Batches go to `POST /v1/events/editor` / `POST /v1/events/operations` (max 200/batch); newly-earned achievements come back in the response.
- **Cloud contract**: feature/op keys are `[a-z0-9_]+`; the 25 discovery cluster keys live in `Gamification::featureCatalog()` (`src/GamificationTypes.h/cpp`) and MUST match qtmesh-cloud's `DISCOVERY_FEATURES`. `GET /v1/me/stats` parses into `Gamification::StatsSnapshot` (NB mixed casing on the wire: `stats`/`featureUsage` snake_case, `progress`/`achievements` camelCase); cached to `<AppData>/gamification/stats_cache.json` for offline rendering. Milestone progress ("nearest unlockable") is computed client-side from `milestoneCatalog()` + counters.
- **Status surface (E-P4 #800)**: level + XP bar, streak and nearest-unlockable progress render in the `CloudAccountMenuButton` menu (refreshes stats opportunistically on menu open); "View My Achievements…" opens `https://qtmesh.dev/u/<slug>` (profile is private by default — `setProfilePublic` PUTs `/v1/me/gamification/prefs`). Unlocks show ONE coalesced `GamificationToast` (no animation, auto-hide, click-to-dismiss).
- **Discovery nudges (E-P5 #801)**: the welcome screen shows at most one dismissible "try this next" card from `GamificationManager.suggestion` — personalized to unused clusters when stats exist, generic rotation when logged out; rotation cursor + dismissals persist in QSettings (`Gamification/*` keys, see `AppSettingsKeys.h`).
- **Ops with metrics instrumented**: retopo (GUI+CLI), decimate/LOD (GUI+CLI), optimize (CLI), uv_unwrap (GUI), auto_rig (GUI sync/async/marker + CLI), skin_weights (GUI+CLI), fix (CLI), texture_atlas pack (GUI), isometric_sprites (GUI), vat_bake (GUI), vertex_color_bake (GUI), morph (GUI), motion_inbetween (GUI), pbr_synth (AIAssistManager). MCP tools map to clusters in `MCPServer::callTool`; CLI subcommands map in `CLIPipeline::run`.

### MCP Server

- **MCPServer** (`src/MCPServer.h/cpp`): JSON-RPC 2.0 over stdio + HTTP REST API on configurable port.
Expand Down
188 changes: 188 additions & 0 deletions qml/PreferencesDialog.qml
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,194 @@ Rectangle {
}
Text { text: "Show welcome screen on startup"; font.pixelSize: 12; color: textColor; anchors.verticalCenter: parent.verticalCenter }
}

// ---- Progress Sync (gamification, #796 E-P6) ----
Rectangle { width: parent.width; height: 1; color: borderColor }

Text {
text: "Progress Sync (QtMesh Cloud)"
font.pixelSize: 12; font.bold: true; color: textColor
}

Text {
text: "Tracks which tools you discover and your editing milestones "
+ "(e.g. \u201cretopo: 42,180 \u2192 8,004 tris\u201d) on your QtMesh Cloud "
+ "profile. Only feature names, counts and numeric before/after "
+ "metrics are shared \u2014 never your models, textures or file names."
font.pixelSize: 11; font.italic: true; color: dimTextColor
wrapMode: Text.WordWrap; width: parent.width
}

// Master toggle
Row {
spacing: 6
width: parent.width
Rectangle {
width: 14; height: 14; anchors.verticalCenter: parent.verticalCenter
border.color: borderColor; border.width: 1; radius: 2
color: GamificationManager.syncEnabled ? highlightColor : "transparent"
Text { anchors.centerIn: parent; text: GamificationManager.syncEnabled ? "\u2713" : ""; color: "white"; font.pixelSize: 10 }
MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor
onClicked: GamificationManager.syncEnabled = !GamificationManager.syncEnabled
}
}
Text { text: "Sync my QtMesh progress"; font.pixelSize: 12; color: textColor; anchors.verticalCenter: parent.verticalCenter }
}

// Per-stream sub-toggles
Column {
width: parent.width
spacing: 6
leftPadding: 20
enabled: GamificationManager.syncEnabled
opacity: GamificationManager.syncEnabled ? 1.0 : 0.45

Row {
spacing: 6
Rectangle {
width: 14; height: 14; anchors.verticalCenter: parent.verticalCenter
border.color: borderColor; border.width: 1; radius: 2
color: GamificationManager.usageEnabled ? highlightColor : "transparent"
Text { anchors.centerIn: parent; text: GamificationManager.usageEnabled ? "\u2713" : ""; color: "white"; font.pixelSize: 10 }
MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor
onClicked: GamificationManager.usageEnabled = !GamificationManager.usageEnabled
}
}
Text { text: "Feature usage (tool discovery)"; font.pixelSize: 12; color: textColor; anchors.verticalCenter: parent.verticalCenter }
}

Row {
spacing: 6
Rectangle {
width: 14; height: 14; anchors.verticalCenter: parent.verticalCenter
border.color: borderColor; border.width: 1; radius: 2
color: GamificationManager.opsEnabled ? highlightColor : "transparent"
Text { anchors.centerIn: parent; text: GamificationManager.opsEnabled ? "\u2713" : ""; color: "white"; font.pixelSize: 10 }
MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor
onClicked: GamificationManager.opsEnabled = !GamificationManager.opsEnabled
}
}
Text { text: "Operations history (numeric before/after metrics)"; font.pixelSize: 12; color: textColor; anchors.verticalCenter: parent.verticalCenter }
}
}

// Nudges toggle
Row {
spacing: 6
width: parent.width
Rectangle {
width: 14; height: 14; anchors.verticalCenter: parent.verticalCenter
border.color: borderColor; border.width: 1; radius: 2
color: GamificationManager.nudgesEnabled ? highlightColor : "transparent"
Text { anchors.centerIn: parent; text: GamificationManager.nudgesEnabled ? "\u2713" : ""; color: "white"; font.pixelSize: 10 }
MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor
onClicked: GamificationManager.nudgesEnabled = !GamificationManager.nudgesEnabled
}
}
Text { text: "Show \u201ctry this next\u201d suggestions on the welcome screen"; font.pixelSize: 12; color: textColor; anchors.verticalCenter: parent.verticalCenter }
}

// Public profile (account-level; needs a cloud session
// AND sync enabled — no cloud traffic when opted out)
Row {
spacing: 6
width: parent.width
visible: GamificationManager.signedIn && GamificationManager.syncEnabled
onVisibleChanged: if (visible) GamificationManager.refreshCloudPrefs()
Component.onCompleted: if (visible) GamificationManager.refreshCloudPrefs()

Rectangle {
width: 14; height: 14; anchors.verticalCenter: parent.verticalCenter
border.color: borderColor; border.width: 1; radius: 2
color: GamificationManager.profilePublic ? highlightColor : "transparent"
Text { anchors.centerIn: parent; text: GamificationManager.profilePublic ? "✓" : ""; color: "white"; font.pixelSize: 10 }
MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor
onClicked: GamificationManager.setProfilePublic(!GamificationManager.profilePublic)
}
}
Text { text: "Public achievement profile (qtmesh.dev/u/…)"; font.pixelSize: 12; color: textColor; anchors.verticalCenter: parent.verticalCenter }
}

// What is shared? (expandable example payload)
Column {
width: parent.width
spacing: 4
property bool expanded: false

Text {
text: (parent.expanded ? "\u25be" : "\u25b8") + " What exactly is shared?"
font.pixelSize: 11; color: highlightColor
MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor
onClicked: parent.parent.expanded = !parent.parent.expanded }
}

Rectangle {
visible: parent.expanded
width: parent.width
height: exampleText.implicitHeight + 16
color: Qt.darker(panelColor, 1.1)
border.color: borderColor; border.width: 1; radius: 3

Text {
id: exampleText
anchors { fill: parent; margins: 8 }
text: GamificationManager.examplePayload()
font.family: "Menlo, Consolas, monospace"
font.pixelSize: 10
color: dimTextColor
wrapMode: Text.WrapAnywhere
}
}
}

// Delete gamification data (two-click confirm)
Row {
spacing: 8
width: parent.width
property bool confirming: false

Rectangle {
width: 220; height: 26; radius: 3
color: parent.confirming ? "#a33" : Qt.darker(panelColor, 1.1)
border.color: parent.confirming ? "#c55" : borderColor
border.width: 1

Text {
anchors.centerIn: parent
text: parent.parent.confirming
? "Click again to permanently delete"
: "Delete my gamification data\u2026"
font.pixelSize: 11
color: parent.parent.confirming ? "white" : textColor
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
if (parent.parent.confirming) {
parent.parent.confirming = false
GamificationManager.deleteCloudData()
} else {
parent.parent.confirming = true
confirmResetTimer.restart()
}
}
}
Timer {
id: confirmResetTimer
interval: 4000
onTriggered: parent.parent.confirming = false
}
}

Text {
text: "Removes achievements, XP, streaks and operations history from your account, and clears the local queue."
font.pixelSize: 10; font.italic: true; color: dimTextColor
wrapMode: Text.WordWrap
width: parent.width - 236
anchors.verticalCenter: parent.verticalCenter
}
}
}

// --- Appearance Tab ---
Expand Down
76 changes: 76 additions & 0 deletions qml/WelcomeScreen.qml
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,82 @@ Rectangle {
}
}

// ---- Gamification: "try this next" discovery nudge (E-P5) ----
Rectangle {
id: suggestionCard
property var suggestion: GamificationManager.suggestion
visible: suggestion !== undefined && suggestion.featureKey !== undefined
Layout.fillWidth: true
implicitHeight: suggestionCol.implicitHeight + 20
radius: 6
color: PropertiesPanelController.headerColor
border.color: PropertiesPanelController.highlightColor
border.width: 1

ColumnLayout {
id: suggestionCol
anchors {
left: parent.left; right: parent.right
top: parent.top
margins: 10
}
spacing: 3

RowLayout {
Layout.fillWidth: true
spacing: 6

Text {
text: (suggestionCard.suggestion && suggestionCard.suggestion.personalized
? "✨ New to you: " : "✨ Try this: ")
+ (suggestionCard.suggestion ? suggestionCard.suggestion.title : "")
color: PropertiesPanelController.textColor
font.pixelSize: 12
font.bold: true
elide: Text.ElideRight
Layout.fillWidth: true
}

// Rotate to another suggestion
Text {
text: "↻"
color: Qt.darker(PropertiesPanelController.textColor, 1.4)
font.pixelSize: 13
MouseArea {
anchors.fill: parent
anchors.margins: -4
cursorShape: Qt.PointingHandCursor
onClicked: GamificationManager.advanceSuggestion()
}
}

// Dismiss this suggestion permanently
Text {
text: "✕"
color: Qt.darker(PropertiesPanelController.textColor, 1.4)
font.pixelSize: 12
MouseArea {
anchors.fill: parent
anchors.margins: -4
cursorShape: Qt.PointingHandCursor
onClicked: {
if (suggestionCard.suggestion)
GamificationManager.dismissSuggestion(suggestionCard.suggestion.featureKey)
}
}
}
}

Text {
text: suggestionCard.suggestion ? suggestionCard.suggestion.blurb : ""
color: Qt.darker(PropertiesPanelController.textColor, 1.4)
font.pixelSize: 10
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
}
}

// ---- Separator ----
Rectangle {
Layout.fillWidth: true
Expand Down
Loading
Loading