diff --git a/common/config/src/main/resources/gui.conf b/common/config/src/main/resources/gui.conf
index d58d94ac7b9..f0742d17968 100644
--- a/common/config/src/main/resources/gui.conf
+++ b/common/config/src/main/resources/gui.conf
@@ -112,6 +112,12 @@ gui {
copilot-enabled = false
copilot-enabled = ${?GUI_WORKFLOW_WORKSPACE_COPILOT_ENABLED}
+ # whether the guided tutorial / Argus chat is enabled. Disable to remove
+ # the trophy launcher button, the welcome popover, and the in-tour Argus
+ # chat from the workspace entirely.
+ tutorial-enabled = true
+ tutorial-enabled = ${?GUI_WORKFLOW_WORKSPACE_TUTORIAL_ENABLED}
+
# the limit of columns to be displayed in the result table
limit-columns = 15
limit-columns = ${?GUI_WORKFLOW_WORKSPACE_LIMIT_COLUMNS}
diff --git a/common/config/src/main/scala/org/apache/texera/config/GuiConfig.scala b/common/config/src/main/scala/org/apache/texera/config/GuiConfig.scala
index adc789c9843..d5725420366 100644
--- a/common/config/src/main/scala/org/apache/texera/config/GuiConfig.scala
+++ b/common/config/src/main/scala/org/apache/texera/config/GuiConfig.scala
@@ -71,6 +71,8 @@ object GuiConfig {
conf.getInt("gui.workflow-workspace.active-time-in-minutes")
val guiWorkflowWorkspaceCopilotEnabled: Boolean =
conf.getBoolean("gui.workflow-workspace.copilot-enabled")
+ val guiWorkflowWorkspaceTutorialEnabled: Boolean =
+ conf.getBoolean("gui.workflow-workspace.tutorial-enabled")
val guiWorkflowWorkspaceLimitColumns: Int =
conf.getInt("gui.workflow-workspace.limit-columns")
}
diff --git a/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala b/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala
index b7517d81eb7..55c6de5ed4b 100644
--- a/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala
+++ b/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala
@@ -57,6 +57,7 @@ class ConfigResource {
),
"activeTimeInMinutes" -> GuiConfig.guiWorkflowWorkspaceActiveTimeInMinutes,
"copilotEnabled" -> GuiConfig.guiWorkflowWorkspaceCopilotEnabled,
+ "tutorialEnabled" -> GuiConfig.guiWorkflowWorkspaceTutorialEnabled,
"limitColumns" -> GuiConfig.guiWorkflowWorkspaceLimitColumns,
// flags from the auth.conf if needed
"expirationTimeInMinutes" -> AuthConfig.jwtExpirationMinutes
diff --git a/frontend/angular.json b/frontend/angular.json
index b9e9961d027..8a7243ef354 100644
--- a/frontend/angular.json
+++ b/frontend/angular.json
@@ -29,6 +29,7 @@
"node_modules/jointjs/css/themes/default.css",
"node_modules/ng-zorro-antd/ng-zorro-antd.min.css",
"node_modules/ng-zorro-antd/resizable/style/index.min.css",
+ "node_modules/driver.js/dist/driver.css",
"src/styles.scss"
],
"scripts": ["./node_modules/marked/lib/marked.umd.js"],
diff --git a/frontend/package.json b/frontend/package.json
index 08b298260e3..58314ac54c7 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -43,9 +43,11 @@
"@ngx-formly/ng-zorro-antd": "6.3.12",
"ai": "5.0.93",
"ajv": "8.10.0",
+ "canvas-confetti": "^1.9.4",
"concaveman": "2.0.0",
"d3-shape": "2.1.0",
"dagre": "0.8.5",
+ "driver.js": "^1.4.0",
"file-saver": "2.0.5",
"fuse.js": "6.5.3",
"html2canvas": "1.4.1",
@@ -101,6 +103,7 @@
"@nx/angular": "22.7.0",
"@schematics/angular": "21.2.8",
"@types/backbone": "1.4.15",
+ "@types/canvas-confetti": "^1.9.0",
"@types/concaveman": "1.1.6",
"@types/d3-shape": "2.1.2",
"@types/dagre": "0.7.47",
diff --git a/frontend/src/_tutorial-driver-overrides.scss b/frontend/src/_tutorial-driver-overrides.scss
new file mode 100644
index 00000000000..26538e51caa
--- /dev/null
+++ b/frontend/src/_tutorial-driver-overrides.scss
@@ -0,0 +1,470 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/*
+ * Global styles for the tutorial mode (driver.js popovers, body.driver-active
+ * overrides, sparkle-burst animation injected into
, etc.).
+ *
+ * Every rule here MUST live at the global root rather than inside a component
+ * stylesheet because the elements they target sit outside the Angular
+ * component tree:
+ * - driver.js appends its `.driver-popover` and `.driver-overlay` to
+ * - ng-zorro / CDK portal nz-select dropdowns into `.cdk-overlay-container`,
+ * also at level
+ * - the sparkle burst is created via `document.body.appendChild(...)`
+ * in tutorial.service.ts
+ *
+ * Angular's emulated view encapsulation rewrites component-scoped selectors
+ * with `[_ngcontent-xyz]` attributes, which match nothing on those
+ * outside-the-tree elements — so component styles cannot reach them.
+ *
+ * All rules are gated by either `body.driver-active` (only active during a
+ * tour) or the `.tutorial-*` / `.driver-popover.tutorial-*` namespace.
+ * When the tutorial flag is off and no tour is running, none of these rules
+ * apply to anything.
+ */
+
+/* driver.js — purely cosmetic. DO NOT override z-index here; driver.js
+ uses z-index: 1000000000 by design so the spotlight always wins. */
+.driver-popover {
+ max-width: 360px;
+}
+.driver-popover-title {
+ font-weight: 600;
+}
+.driver-popover-description b {
+ color: #1890ff;
+}
+
+/* Welcome popover — a centered, fancier variant of the standard driver.js
+ popover used for the first-time greeting. Wider, with a soft gradient and
+ a primary-coloured CTA so "Start building your first workflow" reads as
+ the obvious next step. */
+.driver-popover.tutorial-welcome-popover {
+ max-width: 460px;
+ background: linear-gradient(135deg, #ffffff 0%, #f0f5ff 100%);
+ border: 2px solid #1890ff;
+ box-shadow: 0 16px 48px rgba(24, 144, 255, 0.32);
+ animation: tutorial-welcome-pop 0.5s cubic-bezier(0.18, 1.25, 0.4, 1);
+
+ .driver-popover-title {
+ font-size: 20px;
+ color: #1890ff;
+ text-align: center;
+ }
+
+ .driver-popover-footer button.driver-popover-next-btn {
+ background: #1890ff;
+ border-color: #1890ff;
+ color: #fff;
+ font-weight: 600;
+ padding: 6px 18px;
+ border-radius: 8px;
+ box-shadow: 0 4px 14px rgba(24, 144, 255, 0.45);
+ text-shadow: none;
+ transition:
+ transform 0.15s ease,
+ box-shadow 0.15s ease;
+
+ &:hover {
+ background: #40a9ff;
+ border-color: #40a9ff;
+ transform: translateY(-1px);
+ box-shadow: 0 6px 18px rgba(24, 144, 255, 0.6);
+ }
+ }
+
+ .tutorial-welcome-emoji {
+ font-size: 54px;
+ margin-bottom: 8px;
+ display: inline-block;
+ animation: tutorial-welcome-wave 1.4s ease-in-out infinite;
+ transform-origin: 70% 80%;
+ }
+}
+
+@keyframes tutorial-welcome-pop {
+ 0% {
+ opacity: 0;
+ transform: scale(0.85);
+ }
+ 60% {
+ opacity: 1;
+ transform: scale(1.04);
+ }
+ 100% {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
+@keyframes tutorial-welcome-wave {
+ 0%,
+ 100% {
+ transform: rotate(-14deg);
+ }
+ 50% {
+ transform: rotate(14deg);
+ }
+}
+
+/* Floating sparkle burst (fired on each step advance). Anchored in the
+ top-right corner so the celebration never overlaps the spotlight content
+ the user is reading. Sparkles fly down-left into the workspace; the XP
+ chip rises a few pixels and fades. */
+.tutorial-sparkle-burst {
+ position: fixed;
+ top: 56px;
+ right: 56px;
+ width: 0;
+ height: 0;
+ z-index: 1000000001; /* above driver.js's overlay z-index */
+ pointer-events: none;
+
+ .sparkle-particle {
+ position: absolute;
+ left: 0;
+ top: 0;
+ font-size: 12px;
+ color: #f5c542;
+ text-shadow: 0 0 6px rgba(245, 197, 66, 0.9);
+ transform: translate(-50%, -50%);
+ animation: tutorial-sparkle-fly 0.9s ease-out forwards;
+ }
+
+ .xp-float {
+ position: absolute;
+ left: 0;
+ top: 0;
+ background: linear-gradient(135deg, #faad14, #f5c542);
+ color: #fff;
+ font-weight: 700;
+ font-size: 10px;
+ letter-spacing: 0.3px;
+ padding: 2px 7px;
+ border-radius: 10px;
+ box-shadow: 0 3px 8px rgba(250, 173, 20, 0.5);
+ transform: translate(-50%, -50%);
+ white-space: nowrap;
+ animation: tutorial-xp-rise 1.1s ease-out forwards;
+ }
+}
+
+@keyframes tutorial-sparkle-fly {
+ 0% {
+ opacity: 0;
+ transform: translate(-50%, -50%) scale(0.4) rotate(0deg);
+ }
+ 30% {
+ opacity: 1;
+ transform: translate(calc(-50% + var(--dx, 0px)), calc(-50% + var(--dy, 0px))) scale(1.3) rotate(180deg);
+ }
+ 100% {
+ opacity: 0;
+ transform: translate(calc(-50% + var(--dx, 0px) * 1.6), calc(-50% + var(--dy, 0px) * 1.6)) scale(0.5) rotate(360deg);
+ }
+}
+
+@keyframes tutorial-xp-rise {
+ 0% {
+ opacity: 0;
+ transform: translate(-50%, -50%) scale(0.7);
+ }
+ 20% {
+ opacity: 1;
+ transform: translate(-50%, calc(-50% - 12px)) scale(1.08);
+ }
+ 85% {
+ opacity: 1;
+ transform: translate(-50%, calc(-50% - 40px)) scale(1);
+ }
+ 100% {
+ opacity: 0;
+ transform: translate(-50%, calc(-50% - 56px)) scale(1);
+ }
+}
+
+/* Flow-picker popover — bigger than the standard welcome popover, hosts
+ selectable flow cards. */
+.driver-popover.tutorial-picker-popover {
+ max-width: 520px;
+
+ .tutorial-picker {
+ .picker-intro {
+ margin-bottom: 14px;
+ }
+ .picker-tagline {
+ margin: 0 0 10px;
+ color: #333;
+ font-size: 13px;
+ line-height: 1.55;
+ }
+ .picker-cta {
+ margin: 0 0 4px;
+ color: #1890ff;
+ font-size: 13px;
+ font-weight: 600;
+ }
+ .picker-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-bottom: 12px;
+ }
+ .picker-flow-card {
+ display: block;
+ width: 100%;
+ text-align: left;
+ padding: 10px 14px;
+ border-radius: 10px;
+ border: 1.5px solid rgba(24, 144, 255, 0.2);
+ background: #fff;
+ cursor: pointer;
+ transition: all 0.15s ease;
+
+ &:hover:not(.disabled):not(:disabled) {
+ transform: translateY(-1px);
+ border-color: #1890ff;
+ box-shadow: 0 6px 16px rgba(24, 144, 255, 0.25);
+ }
+ &.disabled,
+ &:disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+ background: repeating-linear-gradient(45deg, #fff, #fff 6px, #fafafa 6px, #fafafa 12px);
+ }
+ }
+ .picker-flow-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-weight: 600;
+ font-size: 14px;
+ color: #1f1f1f;
+ margin-bottom: 4px;
+ }
+ .picker-flow-emoji {
+ font-size: 18px;
+ }
+ .picker-flow-name {
+ flex: 1;
+ }
+ .picker-flow-desc {
+ font-size: 12px;
+ color: #666;
+ line-height: 1.4;
+ }
+ .picker-flow-meta {
+ margin-top: 4px;
+ font-size: 11px;
+ color: #999;
+ }
+ .picker-ribbon {
+ font-size: 10px;
+ padding: 1px 6px;
+ border-radius: 6px;
+ letter-spacing: 0.5px;
+ text-transform: uppercase;
+ font-weight: 700;
+
+ &.done {
+ background: #d9f7be;
+ color: #389e0d;
+ }
+ &.soon {
+ background: #fff7e6;
+ color: #d4a017;
+ }
+ }
+ .picker-skip {
+ display: block;
+ margin: 6px auto 0;
+ background: transparent;
+ border: none;
+ color: #888;
+ font-size: 12px;
+ cursor: pointer;
+ text-decoration: underline;
+
+ &:hover {
+ color: #555;
+ }
+ }
+ }
+}
+
+/* Resume popover — narrower, friendly "welcome back" prompt. */
+.driver-popover.tutorial-resume-popover {
+ max-width: 420px;
+
+ .tutorial-resume p {
+ margin: 0 0 6px;
+ font-size: 14px;
+ }
+ .resume-actions {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+ .resume-btn {
+ padding: 8px 14px;
+ border-radius: 8px;
+ border: 1.5px solid rgba(24, 144, 255, 0.25);
+ background: #fff;
+ color: #1f1f1f;
+ font-weight: 600;
+ font-size: 13px;
+ cursor: pointer;
+ transition: all 0.15s ease;
+
+ &:hover {
+ border-color: #1890ff;
+ transform: translateY(-1px);
+ }
+ &.primary {
+ background: #1890ff;
+ border-color: #1890ff;
+ color: #fff;
+ box-shadow: 0 4px 12px rgba(24, 144, 255, 0.45);
+
+ &:hover {
+ background: #40a9ff;
+ }
+ }
+ &.ghost {
+ background: transparent;
+ border-color: transparent;
+ color: #888;
+ &:hover {
+ color: #555;
+ transform: none;
+ }
+ }
+ }
+}
+
+/* Hide the progress text on popovers tagged with this class. driver.js's
+ per-step `showProgress: false` is resolved via an OR-chain
+ (`a = w || s("showProgress") || false`) which means a per-step `false`
+ value gets overridden by a global `true` — leaving us no real per-step
+ off switch. This CSS hook is the workaround: detail steps that should
+ not be counted in the main spine declare popoverClass:
+ "tutorial-no-progress" and the counter element disappears. */
+.driver-popover.tutorial-no-progress .driver-popover-progress-text {
+ display: none !important;
+}
+
+/* Wide variant for popovers that hover above wider spotlight targets
+ (e.g. the middle toolbar group, which spans most of the viewport).
+ driver.js's default popover maxes at 300px which looks dwarfed next to a
+ wide stage; bump to 440px on this step so the popover reads naturally. */
+.driver-popover.tutorial-wide-popover {
+ max-width: 440px;
+}
+
+/* "Tell me more" opt-in deep-dive button injected into overview-step
+ popovers (workspace overview tour). Visually identical shape to the
+ default Next button, just gray-on-gray instead of accented — keeps Next
+ as the obvious primary action while the deeper path stays available. */
+.driver-popover .driver-popover-navigation-btns .tutorial-show-details-btn {
+ all: unset;
+ box-sizing: border-box;
+ display: inline-block;
+ padding: 3px 7px;
+ line-height: 1.3;
+ font: 12px / normal sans-serif;
+ color: #555;
+ background: #f5f5f5;
+ border: 1px solid #d9d9d9;
+ border-radius: 3px;
+ cursor: pointer;
+ margin-right: 4px;
+ transition:
+ background 0.15s ease,
+ border-color 0.15s ease;
+}
+.driver-popover .driver-popover-navigation-btns .tutorial-show-details-btn:hover {
+ background: #ebebeb;
+ border-color: #b3b3b3;
+}
+
+/* driver.js by default sets pointer-events:none on every element outside the
+ highlighted one. That breaks drag-and-drop tutorial steps (user can grab the
+ operator label but the canvas refuses the drop). Re-enable interaction
+ everywhere while the tour is active — the dim overlay still gives the visual
+ cue, but the page stays fully usable. */
+body.driver-active *:not(.driver-overlay) {
+ pointer-events: auto !important;
+}
+body.driver-active .driver-overlay {
+ pointer-events: none !important;
+}
+
+/* Suppress ng-zorro tooltips/popovers while a tour step is active. Their
+ CDK-overlay positioning re-measures whenever driver.js mutates the active
+ element's inline styles, producing visible tooltip jitter on every step.
+ The driver.js popover already fills the "tooltip" role during the tour. */
+body.driver-active .ant-tooltip,
+body.driver-active .ant-popover {
+ display: none !important;
+}
+
+/* driver.js's default CSS forces `overflow: hidden !important` on the active
+ element's direct parent (so the user can't scroll past the spotlight).
+ That breaks two things in the property editor:
+ 1. The form's nz-select dropdowns get clipped / unclickable.
+ 2. The ancestor scroll container (#content) can't scroll because the
+ forced-hidden node in between intercepts wheel events.
+ We restore the parent's overflow to visible — its original default — so
+ the form behaves normally and scroll events bubble up to #content. */
+body.driver-active :not(body):has(> .driver-active-element) {
+ overflow: visible !important;
+}
+
+/* ng-zorro renders nz-select / nz-autocomplete dropdowns via CDK Overlay
+ portaled to a -level container at default z-index 1000. driver.js's
+ dim overlay sits at z-index ~100000+, which means open dropdowns get
+ visually buried under the overlay — they "open" but the user can't see
+ them. Lift the CDK overlay container above driver.js's popover so
+ dropdowns inside the spotlighted form remain visible and clickable. */
+body.driver-active .cdk-overlay-container {
+ z-index: 1000000001 !important;
+}
+
+/* Freeze hover / focus / wave reactions on small clickable spotlights
+ (icons, menu items, buttons) so cursor wobble at the stage cutout edge
+ doesn't flicker them. Scoped to li / button / nz-icon hosts only — the
+ form-area spotlights (.property-editor-form, .driver-active-element with
+ nested ng-zorro components) MUST keep their transitions, otherwise
+ nz-select / formly-form get stuck in mid-transition state and the form
+ becomes unresponsive after the first selection. */
+body.driver-active li.driver-active-element,
+body.driver-active button.driver-active-element,
+body.driver-active .driver-active-element[nz-icon],
+body.driver-active li.driver-active-element *,
+body.driver-active button.driver-active-element * {
+ transition: none !important;
+ animation: none !important;
+}
+body.driver-active li.driver-active-element .ant-wave,
+body.driver-active button.driver-active-element .ant-wave,
+body.driver-active li.driver-active-element [ant-click-animating-without-extra-node],
+body.driver-active button.driver-active-element [ant-click-animating-without-extra-node] {
+ display: none !important;
+}
diff --git a/frontend/src/app/common/service/gui-config.service.mock.ts b/frontend/src/app/common/service/gui-config.service.mock.ts
index 179259c5a97..608766dd879 100644
--- a/frontend/src/app/common/service/gui-config.service.mock.ts
+++ b/frontend/src/app/common/service/gui-config.service.mock.ts
@@ -51,6 +51,7 @@ export class MockGuiConfigService {
expirationTimeInMinutes: 2880,
activeTimeInMinutes: 15,
copilotEnabled: false,
+ tutorialEnabled: false,
limitColumns: 15,
};
diff --git a/frontend/src/app/common/type/gui-config.ts b/frontend/src/app/common/type/gui-config.ts
index 3b7554e8d4c..f81ea781f95 100644
--- a/frontend/src/app/common/type/gui-config.ts
+++ b/frontend/src/app/common/type/gui-config.ts
@@ -42,6 +42,7 @@ export interface GuiConfig {
expirationTimeInMinutes: number;
activeTimeInMinutes: number;
copilotEnabled: boolean;
+ tutorialEnabled: boolean;
limitColumns: number;
}
diff --git a/frontend/src/app/workspace/component/dataset-file-selector/dataset-file-selector.component.html b/frontend/src/app/workspace/component/dataset-file-selector/dataset-file-selector.component.html
index 9fbc1ddffdc..0a8a2c57f7a 100644
--- a/frontend/src/app/workspace/component/dataset-file-selector/dataset-file-selector.component.html
+++ b/frontend/src/app/workspace/component/dataset-file-selector/dataset-file-selector.component.html
@@ -26,6 +26,7 @@
*ngIf="isFileSelectionEnabled"
nz-button
nzSize="small"
+ data-tutorial="file-select-button"
(click)="isFileSelectionEnabled && onClickOpenFileSelectionModal()">
Select File
diff --git a/frontend/src/app/workspace/component/left-panel/operator-menu/operator-menu.component.html b/frontend/src/app/workspace/component/left-panel/operator-menu/operator-menu.component.html
index ff8bb97a296..60e1500ffec 100644
--- a/frontend/src/app/workspace/component/left-panel/operator-menu/operator-menu.component.html
+++ b/frontend/src/app/workspace/component/left-panel/operator-menu/operator-menu.component.html
@@ -54,13 +54,15 @@
*ngFor="let groupname of groupNames"
[nzHeader]="groupname.groupName"
class="operator-group"
- [attr.data-depth]="depth">
+ [attr.data-depth]="depth"
+ [attr.data-group-name]="groupname.groupName">
+ class="operator-label"
+ [attr.data-operator-type]="operatorSchema.operatorType">
diff --git a/frontend/src/app/workspace/component/menu/menu.component.html b/frontend/src/app/workspace/component/menu/menu.component.html
index a21e4d56429..008a6d2ec13 100644
--- a/frontend/src/app/workspace/component/menu/menu.component.html
+++ b/frontend/src/app/workspace/component/menu/menu.component.html
@@ -326,7 +326,8 @@
+ nz-button
+ title="undo">
@@ -334,7 +335,8 @@
+ nz-button
+ title="redo">
diff --git a/frontend/src/app/workspace/component/property-editor/property-editor.component.html b/frontend/src/app/workspace/component/property-editor/property-editor.component.html
index 7ca4a328a87..952f32962ee 100644
--- a/frontend/src/app/workspace/component/property-editor/property-editor.component.html
+++ b/frontend/src/app/workspace/component/property-editor/property-editor.component.html
@@ -34,7 +34,8 @@
nz-menu-item
(click)="openPanel()"
*ngIf="!width"
- nz-tooltip="Property Editor">
+ nz-tooltip="Property Editor"
+ data-tutorial="open-property-panel">
diff --git a/frontend/src/app/workspace/component/result-panel/result-panel.component.html b/frontend/src/app/workspace/component/result-panel/result-panel.component.html
index d2c6a535493..03c98a499ec 100644
--- a/frontend/src/app/workspace/component/result-panel/result-panel.component.html
+++ b/frontend/src/app/workspace/component/result-panel/result-panel.component.html
@@ -36,6 +36,7 @@
id="divider">
diff --git a/frontend/src/app/workspace/component/tutorial/argus/argus.component.html b/frontend/src/app/workspace/component/tutorial/argus/argus.component.html
new file mode 100644
index 00000000000..3c7a7f5c630
--- /dev/null
+++ b/frontend/src/app/workspace/component/tutorial/argus/argus.component.html
@@ -0,0 +1,152 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ✦
+ ✧
+ ✦
+ ✧
+
+
diff --git a/frontend/src/app/workspace/component/tutorial/argus/argus.component.scss b/frontend/src/app/workspace/component/tutorial/argus/argus.component.scss
new file mode 100644
index 00000000000..c2fc26b4bbd
--- /dev/null
+++ b/frontend/src/app/workspace/component/tutorial/argus/argus.component.scss
@@ -0,0 +1,206 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+:host {
+ display: inline-block;
+ position: relative;
+ line-height: 0;
+}
+
+.argus-wrapper {
+ position: relative;
+ display: inline-block;
+ filter: drop-shadow(0 6px 14px rgba(20, 130, 150, 0.4));
+}
+
+.argus-svg {
+ display: block;
+ position: relative;
+ z-index: 2;
+}
+
+.argus-body {
+ transform-origin: 32px 32px;
+}
+
+.argus-pupil {
+ transform-origin: 32px 32px;
+ transition: transform 0.4s cubic-bezier(0.2, 0.8, 0.3, 1.1);
+}
+
+.argus-eyelid {
+ transform-origin: 32px 32px;
+ transform: scaleY(0);
+ transform-box: fill-box;
+ transition: transform 80ms linear;
+}
+
+/* sparkles */
+.argus-sparkles {
+ position: absolute;
+ inset: -10px;
+ pointer-events: none;
+ z-index: 3;
+}
+
+.sparkle {
+ position: absolute;
+ font-size: 14px;
+ color: #f5c542;
+ text-shadow: 0 0 6px rgba(245, 197, 66, 0.85);
+ opacity: 0;
+}
+
+.sparkle-1 {
+ top: 4px;
+ left: 50%;
+}
+.sparkle-2 {
+ top: 50%;
+ right: 0;
+}
+.sparkle-3 {
+ bottom: 4px;
+ left: 50%;
+}
+.sparkle-4 {
+ top: 50%;
+ left: 0;
+}
+
+/* ===== STATES ===== */
+
+/* idle — gentle vertical bob, periodic blink */
+.argus-state-idle .argus-body {
+ animation: argus-bob 3.2s ease-in-out infinite;
+}
+
+.argus-state-idle .argus-eyelid {
+ animation: argus-blink 5s ease-in-out infinite;
+}
+
+/* thinking — pupil rolled up, body tilts */
+.argus-state-thinking .argus-body {
+ animation: argus-think 1.6s ease-in-out infinite;
+}
+
+.argus-state-thinking .argus-pupil {
+ transform: translateY(-5px) !important;
+}
+
+/* cheer — quick bounce, sparkles flash */
+.argus-state-cheer .argus-body {
+ animation: argus-cheer 0.6s ease-out;
+}
+
+.argus-state-cheer .sparkle {
+ animation: argus-sparkle 0.8s ease-out;
+}
+
+.argus-state-cheer .sparkle-1 {
+ animation-delay: 0ms;
+}
+.argus-state-cheer .sparkle-2 {
+ animation-delay: 80ms;
+}
+.argus-state-cheer .sparkle-3 {
+ animation-delay: 160ms;
+}
+.argus-state-cheer .sparkle-4 {
+ animation-delay: 240ms;
+}
+
+/* wave — side-to-side rock, used on welcome */
+.argus-state-wave .argus-body {
+ animation: argus-wave 1.4s ease-in-out infinite;
+}
+
+/* ===== KEYFRAMES ===== */
+
+@keyframes argus-bob {
+ 0%,
+ 100% {
+ transform: translateY(0);
+ }
+ 50% {
+ transform: translateY(-3px);
+ }
+}
+
+@keyframes argus-blink {
+ 0%,
+ 92%,
+ 100% {
+ transform: scaleY(0);
+ }
+ 94%,
+ 97% {
+ transform: scaleY(1);
+ }
+}
+
+@keyframes argus-think {
+ 0%,
+ 100% {
+ transform: rotate(-3deg) translateY(0);
+ }
+ 50% {
+ transform: rotate(3deg) translateY(-2px);
+ }
+}
+
+@keyframes argus-cheer {
+ 0% {
+ transform: translateY(0) scale(1);
+ }
+ 30% {
+ transform: translateY(-8px) scale(1.08);
+ }
+ 60% {
+ transform: translateY(-2px) scale(0.96);
+ }
+ 100% {
+ transform: translateY(0) scale(1);
+ }
+}
+
+@keyframes argus-wave {
+ 0%,
+ 100% {
+ transform: rotate(-8deg);
+ }
+ 50% {
+ transform: rotate(8deg);
+ }
+}
+
+@keyframes argus-sparkle {
+ 0% {
+ opacity: 0;
+ transform: scale(0.4) rotate(0deg);
+ }
+ 40% {
+ opacity: 1;
+ transform: scale(1.2) rotate(180deg);
+ }
+ 100% {
+ opacity: 0;
+ transform: scale(0.6) rotate(360deg);
+ }
+}
diff --git a/frontend/src/app/workspace/component/tutorial/argus/argus.component.ts b/frontend/src/app/workspace/component/tutorial/argus/argus.component.ts
new file mode 100644
index 00000000000..c7c10b6a7dc
--- /dev/null
+++ b/frontend/src/app/workspace/component/tutorial/argus/argus.component.ts
@@ -0,0 +1,40 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Component, Input } from "@angular/core";
+import { NgClass, NgIf } from "@angular/common";
+
+export type ArgusState = "idle" | "thinking" | "cheer" | "wave";
+
+/**
+ * Argus — the peacock-feather eye mascot that hosts the tutorial. Inspired by
+ * Argus Panoptes, whose hundred eyes were placed on the peacock's tail by Hera.
+ *
+ * Pure SVG, CSS-driven animations only.
+ */
+@Component({
+ selector: "texera-argus",
+ templateUrl: "argus.component.html",
+ styleUrls: ["argus.component.scss"],
+ imports: [NgClass, NgIf],
+})
+export class ArgusComponent {
+ @Input() state: ArgusState = "idle";
+ @Input() size = 64;
+}
diff --git a/frontend/src/app/workspace/component/tutorial/badge-unlocked/badge-unlocked.component.html b/frontend/src/app/workspace/component/tutorial/badge-unlocked/badge-unlocked.component.html
new file mode 100644
index 00000000000..c9d4b50c4d4
--- /dev/null
+++ b/frontend/src/app/workspace/component/tutorial/badge-unlocked/badge-unlocked.component.html
@@ -0,0 +1,60 @@
+
+
+
+
+
+
Badge Unlocked!
+
+ {{ badge.emoji }}
+
+
{{ badge.name }}
+
{{ badge.description }}
+
+ Earned during {{ flowName }}
+
+
+
+
+ Continue exploring
+
+
+ View trophy shelf 🏆
+
+
+
+
diff --git a/frontend/src/app/workspace/component/tutorial/badge-unlocked/badge-unlocked.component.scss b/frontend/src/app/workspace/component/tutorial/badge-unlocked/badge-unlocked.component.scss
new file mode 100644
index 00000000000..aa4a5c7bf7e
--- /dev/null
+++ b/frontend/src/app/workspace/component/tutorial/badge-unlocked/badge-unlocked.component.scss
@@ -0,0 +1,135 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+.badge-unlocked-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(15, 23, 42, 0.55);
+ backdrop-filter: blur(2px);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 10002;
+ animation: badge-fade-in 0.25s ease;
+}
+
+.badge-unlocked-card {
+ background: linear-gradient(160deg, #ffffff 0%, #fff7d6 100%);
+ border: 2px solid #faad14;
+ border-radius: 16px;
+ padding: 24px 32px 22px;
+ width: min(420px, 90vw);
+ text-align: center;
+ box-shadow: 0 24px 60px rgba(250, 173, 20, 0.4);
+ animation: badge-pop-in 0.55s cubic-bezier(0.18, 1.25, 0.4, 1);
+}
+
+.badge-argus {
+ display: inline-block;
+}
+
+.badge-unlocked-label {
+ margin-top: 6px;
+ font-size: 12px;
+ letter-spacing: 2px;
+ text-transform: uppercase;
+ color: #faad14;
+ font-weight: 700;
+}
+
+.badge-icon {
+ margin: 14px auto 10px;
+ width: 96px;
+ height: 96px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow:
+ inset 0 -6px 14px rgba(0, 0, 0, 0.12),
+ 0 8px 24px rgba(0, 0, 0, 0.12);
+ animation: badge-spin-in 0.8s cubic-bezier(0.18, 1.25, 0.4, 1);
+}
+
+.badge-emoji {
+ font-size: 56px;
+ filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
+}
+
+.badge-name {
+ font-size: 22px;
+ font-weight: 700;
+ color: #1f1f1f;
+ margin-bottom: 6px;
+}
+
+.badge-desc {
+ font-size: 14px;
+ color: #555;
+ line-height: 1.5;
+}
+
+.badge-flow-context {
+ margin-top: 10px;
+ font-size: 12px;
+ color: #888;
+}
+
+.badge-actions {
+ margin-top: 18px;
+ display: flex;
+ gap: 10px;
+ justify-content: center;
+ flex-wrap: wrap;
+}
+
+@keyframes badge-fade-in {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes badge-pop-in {
+ 0% {
+ opacity: 0;
+ transform: scale(0.7) translateY(20px);
+ }
+ 60% {
+ opacity: 1;
+ transform: scale(1.04) translateY(-4px);
+ }
+ 100% {
+ opacity: 1;
+ transform: scale(1) translateY(0);
+ }
+}
+
+@keyframes badge-spin-in {
+ 0% {
+ transform: rotate(-180deg) scale(0.3);
+ opacity: 0;
+ }
+ 100% {
+ transform: rotate(0deg) scale(1);
+ opacity: 1;
+ }
+}
diff --git a/frontend/src/app/workspace/component/tutorial/badge-unlocked/badge-unlocked.component.ts b/frontend/src/app/workspace/component/tutorial/badge-unlocked/badge-unlocked.component.ts
new file mode 100644
index 00000000000..0582bab53ac
--- /dev/null
+++ b/frontend/src/app/workspace/component/tutorial/badge-unlocked/badge-unlocked.component.ts
@@ -0,0 +1,47 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Component, EventEmitter, Input, Output } from "@angular/core";
+import { NgIf } from "@angular/common";
+import { NzButtonComponent } from "ng-zorro-antd/button";
+import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch";
+import { NzWaveDirective } from "ng-zorro-antd/core/wave";
+import { ArgusComponent } from "../argus/argus.component";
+import type { BadgeDef } from "../../../service/tutorial/flows";
+
+@Component({
+ selector: "texera-badge-unlocked",
+ templateUrl: "badge-unlocked.component.html",
+ styleUrls: ["badge-unlocked.component.scss"],
+ imports: [NgIf, NzButtonComponent, NzWaveDirective, ɵNzTransitionPatchDirective, ArgusComponent],
+})
+export class BadgeUnlockedComponent {
+ @Input() badge: BadgeDef | null = null;
+ @Input() flowName: string = "";
+ @Output() dismiss = new EventEmitter();
+ @Output() viewShelf = new EventEmitter();
+
+ close(): void {
+ this.dismiss.emit();
+ }
+
+ openShelf(): void {
+ this.viewShelf.emit();
+ }
+}
diff --git a/frontend/src/app/workspace/component/tutorial/trophy-shelf/trophy-shelf.component.html b/frontend/src/app/workspace/component/tutorial/trophy-shelf/trophy-shelf.component.html
new file mode 100644
index 00000000000..990a2bac69b
--- /dev/null
+++ b/frontend/src/app/workspace/component/tutorial/trophy-shelf/trophy-shelf.component.html
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ row.earned ? row.badge.emoji : '🔒' }}
+
+
{{ row.badge.name }}
+
+ Earned {{ row.earnedAt | date:'mediumDate' }}
+
+
Tap to flip
+
+
+
{{ row.badge.name }}
+
{{ row.badge.description }}
+
Tap to flip back
+
+
+
+
+
+
+
Start a tour
+
+ {{ flow.name }}
+
+ {{ flow.estimatedMinutes }} min · {{ flow.difficulty }}
+ coming soon
+
+
+
+
diff --git a/frontend/src/app/workspace/component/tutorial/trophy-shelf/trophy-shelf.component.scss b/frontend/src/app/workspace/component/tutorial/trophy-shelf/trophy-shelf.component.scss
new file mode 100644
index 00000000000..d33a7b44e38
--- /dev/null
+++ b/frontend/src/app/workspace/component/tutorial/trophy-shelf/trophy-shelf.component.scss
@@ -0,0 +1,267 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+.trophy-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(15, 23, 42, 0.4);
+ z-index: 10002;
+ animation: trophy-fade-in 0.2s ease;
+}
+
+.trophy-drawer {
+ position: fixed;
+ top: 0;
+ right: 0;
+ height: 100vh;
+ width: 380px;
+ max-width: 92vw;
+ background: linear-gradient(180deg, #ffffff 0%, #fff9ec 100%);
+ border-left: 2px solid #faad14;
+ box-shadow: -16px 0 40px rgba(0, 0, 0, 0.18);
+ z-index: 10003;
+ transform: translateX(100%);
+ transition: transform 0.35s cubic-bezier(0.2, 0.8, 0.3, 1);
+ display: flex;
+ flex-direction: column;
+
+ &.open {
+ transform: translateX(0);
+ }
+}
+
+.trophy-header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 16px 20px;
+ border-bottom: 1px solid rgba(250, 173, 20, 0.3);
+}
+
+.trophy-title-block {
+ flex: 1;
+}
+
+.trophy-title {
+ margin: 0;
+ font-size: 18px;
+ color: #1f1f1f;
+}
+
+.trophy-subtitle {
+ font-size: 12px;
+ color: #888;
+}
+
+.trophy-close {
+ font-size: 16px;
+}
+
+.trophy-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 12px;
+ padding: 16px 20px;
+ overflow-y: auto;
+}
+
+.trophy-tile {
+ perspective: 800px;
+ cursor: pointer;
+ min-height: 150px;
+
+ &.locked .tile-front {
+ filter: grayscale(60%);
+ opacity: 0.7;
+ }
+}
+
+.tile-inner {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ min-height: 150px;
+ transform-style: preserve-3d;
+ transition: transform 0.55s cubic-bezier(0.4, 0.2, 0.2, 1);
+
+ .trophy-tile.is-flipped & {
+ transform: rotateY(180deg);
+ }
+}
+
+.tile-face {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ padding: 14px 8px;
+ border-radius: 12px;
+ background: #fff;
+ border: 1.5px solid rgba(0, 0, 0, 0.06);
+ backface-visibility: hidden;
+ -webkit-backface-visibility: hidden;
+ transition: box-shadow 0.2s ease;
+
+ .trophy-tile.earned & {
+ border-color: #faad14;
+ box-shadow: 0 6px 16px rgba(250, 173, 20, 0.18);
+ }
+
+ .trophy-tile.earned:hover & {
+ box-shadow: 0 10px 22px rgba(250, 173, 20, 0.3);
+ }
+}
+
+.tile-back {
+ transform: rotateY(180deg);
+ background: linear-gradient(160deg, #fff9ec 0%, #fff 100%);
+ justify-content: flex-start;
+ gap: 6px;
+ padding: 16px 12px;
+}
+
+.tile-icon {
+ margin: 0 auto 8px;
+ width: 56px;
+ height: 56px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.tile-emoji {
+ font-size: 30px;
+}
+
+.tile-name {
+ font-size: 13px;
+ font-weight: 600;
+ color: #1f1f1f;
+ margin-bottom: 2px;
+}
+
+.tile-back-name {
+ font-size: 13px;
+ font-weight: 700;
+ color: #1f1f1f;
+ margin-bottom: 4px;
+}
+
+.tile-back-desc {
+ font-size: 11px;
+ color: #555;
+ line-height: 1.45;
+ flex: 1;
+ overflow-y: auto;
+}
+
+.tile-meta {
+ margin-top: 6px;
+ font-size: 10px;
+ color: #aaa;
+}
+
+.tile-flip-hint {
+ margin-top: auto;
+ font-size: 9px;
+ letter-spacing: 0.5px;
+ text-transform: uppercase;
+ color: #c0c0c0;
+ user-select: none;
+}
+
+.trophy-flow-list {
+ border-top: 1px solid rgba(250, 173, 20, 0.3);
+ padding: 14px 20px 20px;
+
+ h4 {
+ margin: 0 0 10px;
+ font-size: 14px;
+ color: #1f1f1f;
+ }
+}
+
+.trophy-flow-btn {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ width: 100%;
+ margin-bottom: 8px;
+ padding: 10px 12px;
+ border-radius: 10px;
+ background: #fff;
+ border: 1.5px solid rgba(24, 144, 255, 0.18);
+ cursor: pointer;
+ transition: all 0.2s ease;
+ text-align: left;
+
+ &:hover:not(:disabled) {
+ border-color: #1890ff;
+ box-shadow: 0 4px 12px rgba(24, 144, 255, 0.22);
+ transform: translateY(-1px);
+ }
+
+ &:disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+ }
+}
+
+.flow-name {
+ font-size: 14px;
+ font-weight: 600;
+ color: #1f1f1f;
+}
+
+.flow-meta {
+ font-size: 11px;
+ color: #888;
+ margin-top: 2px;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.flow-stub {
+ background: rgba(250, 173, 20, 0.15);
+ color: #d4a017;
+ font-weight: 600;
+ padding: 1px 6px;
+ border-radius: 6px;
+ font-size: 10px;
+ letter-spacing: 0.5px;
+ text-transform: uppercase;
+}
+
+.coming-soon {
+ background: repeating-linear-gradient(45deg, #fff, #fff 6px, #fff9ec 6px, #fff9ec 12px);
+}
+
+@keyframes trophy-fade-in {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
diff --git a/frontend/src/app/workspace/component/tutorial/trophy-shelf/trophy-shelf.component.ts b/frontend/src/app/workspace/component/tutorial/trophy-shelf/trophy-shelf.component.ts
new file mode 100644
index 00000000000..d9842975eff
--- /dev/null
+++ b/frontend/src/app/workspace/component/tutorial/trophy-shelf/trophy-shelf.component.ts
@@ -0,0 +1,111 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Component, EventEmitter, Input, Output, OnChanges } from "@angular/core";
+import { DatePipe, NgClass, NgFor, NgIf } from "@angular/common";
+import { NzButtonComponent } from "ng-zorro-antd/button";
+import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch";
+import { NzWaveDirective } from "ng-zorro-antd/core/wave";
+import { ArgusComponent } from "../argus/argus.component";
+import { BONUS_BADGES, BadgeDef, FLOWS, TutorialFlow } from "../../../service/tutorial/flows";
+import type { TutorialProgress } from "../../../service/tutorial/tutorial-progress";
+
+interface BadgeRow {
+ badge: BadgeDef;
+ earned: boolean;
+ earnedAt?: number;
+}
+
+@Component({
+ selector: "texera-trophy-shelf",
+ templateUrl: "trophy-shelf.component.html",
+ styleUrls: ["trophy-shelf.component.scss"],
+ imports: [
+ NgIf,
+ NgFor,
+ NgClass,
+ DatePipe,
+ NzButtonComponent,
+ NzWaveDirective,
+ ɵNzTransitionPatchDirective,
+ ArgusComponent,
+ ],
+})
+export class TrophyShelfComponent implements OnChanges {
+ @Input() open = false;
+ @Input() progress: TutorialProgress | null = null;
+ @Input() flows: TutorialFlow[] = FLOWS;
+ @Output() closed = new EventEmitter();
+ @Output() startFlow = new EventEmitter();
+
+ rows: BadgeRow[] = [];
+ /** Badge ids whose tile is currently flipped to show the back. */
+ flipped = new Set();
+
+ ngOnChanges(): void {
+ this.rows = this.buildRows();
+ }
+
+ toggleFlip(badgeId: string): void {
+ if (this.flipped.has(badgeId)) {
+ this.flipped.delete(badgeId);
+ } else {
+ this.flipped.add(badgeId);
+ }
+ }
+
+ get earnedCount(): number {
+ return this.rows.filter(r => r.earned).length;
+ }
+
+ get totalCount(): number {
+ return this.rows.length;
+ }
+
+ get xp(): number {
+ return this.progress?.xp ?? 0;
+ }
+
+ onClose(): void {
+ this.closed.emit();
+ }
+
+ onStartFlow(flowId: string): void {
+ this.startFlow.emit(flowId);
+ }
+
+ private buildRows(): BadgeRow[] {
+ const earned = new Set(this.progress?.earnedBadges ?? []);
+ const completed = this.progress?.completed ?? {};
+ const rows: BadgeRow[] = [];
+
+ for (const flow of this.flows) {
+ const completion = completed[flow.id];
+ rows.push({
+ badge: flow.badge,
+ earned: earned.has(flow.badge.id),
+ earnedAt: completion?.completedAt,
+ });
+ }
+ for (const bonus of BONUS_BADGES) {
+ rows.push({ badge: bonus, earned: earned.has(bonus.id) });
+ }
+ return rows;
+ }
+}
diff --git a/frontend/src/app/workspace/component/tutorial/tutorial-chat/tutorial-chat.component.html b/frontend/src/app/workspace/component/tutorial/tutorial-chat/tutorial-chat.component.html
new file mode 100644
index 00000000000..7c6cf3df19a
--- /dev/null
+++ b/frontend/src/app/workspace/component/tutorial/tutorial-chat/tutorial-chat.component.html
@@ -0,0 +1,113 @@
+
+
+
+
+
+ I'm your tutor — ask me anything!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
🦚
+
{{ msg.content }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/app/workspace/component/tutorial/tutorial-chat/tutorial-chat.component.scss b/frontend/src/app/workspace/component/tutorial/tutorial-chat/tutorial-chat.component.scss
new file mode 100644
index 00000000000..ea675bf90a1
--- /dev/null
+++ b/frontend/src/app/workspace/component/tutorial/tutorial-chat/tutorial-chat.component.scss
@@ -0,0 +1,353 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Floating Argus = the clickable agent avatar.
+//
+// Position: bottom-right (per user preference). Compact 48px disc so it
+// doesn't loom over Texera's minimap or the property editor.
+//
+// Z-index: driver.js's spotlight overlay sits at z-index ~1_000_000_000, so
+// the button has to stack above it. 1_000_000_010 keeps Argus clickable on
+// top of any tour state.
+// One-time "ask me anything" bubble next to Argus. Same z-index as the
+// button so it sits above driver.js's tour overlay. Arrow on the right
+// points at the button; clicking the bubble itself also opens the chat.
+.argus-nudge {
+ position: fixed;
+ bottom: 113px;
+ right: 84px;
+ max-width: 220px;
+ padding: 8px 12px;
+ background: linear-gradient(135deg, #ffffff 0%, #f0fdfa 100%);
+ color: #0d6f8c;
+ font-size: 12px;
+ font-weight: 600;
+ line-height: 1.35;
+ border: 1.5px solid #1aa3c4;
+ border-radius: 12px;
+ box-shadow: 0 4px 14px rgba(20, 130, 150, 0.25);
+ cursor: pointer;
+ z-index: 1000000010;
+ animation: argus-nudge-pop 0.45s cubic-bezier(0.34, 1.56, 0.64, 1) 0.6s both;
+ transform-origin: right center;
+
+ &::after {
+ content: "";
+ position: absolute;
+ right: -9px;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 0;
+ height: 0;
+ border-left: 9px solid #1aa3c4;
+ border-top: 7px solid transparent;
+ border-bottom: 7px solid transparent;
+ }
+
+ &::before {
+ content: "";
+ position: absolute;
+ right: -6px;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 0;
+ height: 0;
+ border-left: 7px solid #f0fdfa;
+ border-top: 5px solid transparent;
+ border-bottom: 5px solid transparent;
+ z-index: 1;
+ }
+
+ &:hover {
+ background: linear-gradient(135deg, #ffffff 0%, #e0f9f5 100%);
+ }
+}
+
+@keyframes argus-nudge-pop {
+ 0% {
+ opacity: 0;
+ transform: translateX(8px) scale(0.85);
+ }
+ 100% {
+ opacity: 1;
+ transform: translateX(0) scale(1);
+ }
+}
+
+.argus-toggle-btn {
+ position: fixed;
+ bottom: 100px;
+ right: 24px;
+ z-index: 1000000010;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 48px;
+ height: 48px;
+ padding: 0;
+ border-radius: 50%;
+ background: linear-gradient(135deg, #ffffff 0%, #f0fdfa 100%);
+ border: 2px solid #1aa3c4;
+ box-shadow: 0 6px 18px rgba(20, 130, 150, 0.35);
+ cursor: pointer;
+ pointer-events: auto;
+ transition:
+ transform 0.18s ease,
+ box-shadow 0.18s ease;
+ animation: argus-toggle-pulse 2.4s ease-in-out infinite;
+
+ /* Argus inside the button is decorative. Let clicks fall through to the
+ button so the entire disc is one big click target. */
+ texera-argus {
+ pointer-events: none;
+ }
+
+ &:hover {
+ transform: translateY(-1px) scale(1.04);
+ box-shadow: 0 10px 24px rgba(20, 130, 150, 0.5);
+ }
+
+ &:active {
+ transform: translateY(0) scale(0.97);
+ }
+}
+
+// Label removed in the new compact disc layout. Keep the rule so it doesn't
+// regress if added back inadvertently.
+.argus-toggle-label {
+ display: none;
+}
+
+@keyframes argus-toggle-pulse {
+ 0%,
+ 100% {
+ box-shadow: 0 8px 24px rgba(20, 130, 150, 0.35);
+ }
+ 50% {
+ box-shadow: 0 8px 32px rgba(26, 163, 196, 0.6);
+ }
+}
+
+.argus-toggle-label {
+ font-size: 13px;
+ font-weight: 700;
+ color: #0d6f8c;
+ letter-spacing: 0.3px;
+ padding-right: 4px;
+ white-space: nowrap;
+}
+
+.chat-header-argus {
+ flex-shrink: 0;
+ /* The header has a deep gradient background; Argus's own drop-shadow looks
+ muddy on it, so suppress the filter and let the SVG sit cleanly. */
+ filter: none !important;
+}
+
+// Slide-in chat panel — anchored above the bottom-right Argus button so it
+// expands upward.
+.chat-panel {
+ position: fixed;
+ bottom: 160px;
+ right: 24px;
+ z-index: 1000000009; // just below the Argus button so the button stays clickable on top
+ width: 340px;
+ height: 460px;
+ background: #fff;
+ border-radius: 16px;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ border: 1px solid #e8e8e8;
+
+ // Slide up from the Argus button at bottom-right
+ transform: translateY(12px) scale(0.95);
+ opacity: 0;
+ pointer-events: none;
+ transition:
+ transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1),
+ opacity 0.2s ease;
+ transform-origin: bottom right;
+
+ &.open {
+ transform: translateY(0) scale(1);
+ opacity: 1;
+ pointer-events: all;
+ }
+}
+
+// Header — peacock palette so Argus visually belongs.
+.chat-header {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 12px 14px;
+ background: linear-gradient(135deg, #0d6f8c 0%, #1aa3c4 60%, #1ab37e 100%);
+ color: #fff;
+ flex-shrink: 0;
+}
+
+.chat-header-title {
+ font-weight: 600;
+ font-size: 14px;
+ flex: 1;
+}
+
+.chat-header-subtitle {
+ font-size: 11px;
+ opacity: 0.8;
+ display: none;
+}
+
+.chat-close-btn {
+ color: rgba(255, 255, 255, 0.8) !important;
+ margin-left: auto;
+
+ &:hover {
+ color: #fff !important;
+ }
+}
+
+// Message list
+.chat-messages {
+ flex: 1;
+ overflow-y: auto;
+ padding: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ background: #f9fafb;
+}
+
+.chat-loading {
+ display: flex;
+ justify-content: center;
+ padding: 20px;
+}
+
+.chat-message {
+ display: flex;
+ max-width: 90%;
+
+ &.user-message {
+ align-self: flex-end;
+
+ .message-bubble {
+ background: #1890ff;
+ color: #fff;
+ border-radius: 16px 16px 4px 16px;
+ }
+ }
+
+ &.agent-message {
+ align-self: flex-start;
+
+ .message-bubble {
+ background: #fff;
+ color: #262626;
+ border: 1px solid #e8e8e8;
+ border-radius: 16px 16px 16px 4px;
+ }
+ }
+}
+
+.message-bubble {
+ padding: 8px 12px;
+ display: flex;
+ align-items: flex-start;
+ gap: 6px;
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
+}
+
+.message-icon {
+ font-size: 14px;
+ flex-shrink: 0;
+ margin-top: 1px;
+}
+
+.message-content {
+ font-size: 13px;
+ line-height: 1.5;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+// Typing indicator animation
+.typing-indicator {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 2px 0;
+
+ span {
+ width: 6px;
+ height: 6px;
+ background: #bfbfbf;
+ border-radius: 50%;
+ animation: bounce 1.2s infinite;
+
+ &:nth-child(2) {
+ animation-delay: 0.2s;
+ }
+
+ &:nth-child(3) {
+ animation-delay: 0.4s;
+ }
+ }
+}
+
+@keyframes bounce {
+ 0%,
+ 60%,
+ 100% {
+ transform: translateY(0);
+ }
+ 30% {
+ transform: translateY(-5px);
+ }
+}
+
+// Input area
+.chat-input-area {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 12px;
+ border-top: 1px solid #f0f0f0;
+ background: #fff;
+ flex-shrink: 0;
+}
+
+.chat-input {
+ flex: 1;
+ border-radius: 20px;
+ font-size: 13px;
+}
+
+.chat-send-btn {
+ border-radius: 50%;
+ width: 32px;
+ height: 32px;
+ padding: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
diff --git a/frontend/src/app/workspace/component/tutorial/tutorial-chat/tutorial-chat.component.ts b/frontend/src/app/workspace/component/tutorial/tutorial-chat/tutorial-chat.component.ts
new file mode 100644
index 00000000000..da62dc003e9
--- /dev/null
+++ b/frontend/src/app/workspace/component/tutorial/tutorial-chat/tutorial-chat.component.ts
@@ -0,0 +1,354 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { AfterViewChecked, Component, ElementRef, OnDestroy, OnInit, ViewChild } from "@angular/core";
+import { NgClass, NgFor, NgIf } from "@angular/common";
+import { FormsModule } from "@angular/forms";
+import { firstValueFrom, Subject, Subscription } from "rxjs";
+import { takeUntil } from "rxjs/operators";
+import { NzButtonComponent } from "ng-zorro-antd/button";
+import { NzIconDirective } from "ng-zorro-antd/icon";
+import { NzInputDirective } from "ng-zorro-antd/input";
+import { NzTooltipDirective } from "ng-zorro-antd/tooltip";
+import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch";
+import { NzWaveDirective } from "ng-zorro-antd/core/wave";
+import { TutorialService } from "../../../service/tutorial/tutorial.service";
+import { ArgusComponent, ArgusState } from "../argus/argus.component";
+import { WorkflowActionService } from "../../../service/workflow-graph/model/workflow-action.service";
+import { WorkflowUtilService } from "../../../service/workflow-graph/util/workflow-util.service";
+import { Point } from "../../../types/workflow-common.interface";
+import { AgentService } from "../../../service/agent/agent.service";
+
+interface ChatMessage {
+ role: "user" | "assistant" | "system";
+ content: string;
+ timestamp?: Date;
+}
+
+const SYSTEM_PROMPT = `You are Argus, a friendly AI tutor for Texera, a visual workflow editor for big-data analysis.
+Workflows are built by dragging operators (CSVScan, Filter, KeywordSearch, ...) onto a canvas and connecting them via edges from output ports to input ports.
+
+Style rules (important):
+- Keep answers SHORT: 2-3 sentences max.
+- Plain, clear English. Skip emojis entirely unless the user uses one first.
+- Do NOT use em dashes (—) or en dashes (–). Use a period or comma instead.
+- No exclamation marks unless genuinely celebrating completion.
+- If the user is stuck on a step, give a direct hint without being condescending.
+- If asked about an operator, briefly explain what it does in one or two sentences.
+
+Tool calls (use sparingly):
+You can perform an action on the user's canvas by appending ONE directive on the very last line of your reply, then stopping. The line MUST match this exact format:
+ [[ACTION:add_operator {"type":""}]]
+- Only emit an action when the user explicitly asks you to do something on the canvas (e.g. "add a Filter for me", "drop a Bar Chart in").
+- For general questions, EXPLANATIONS, or "how do I..." prompts, DO NOT emit an action. Just answer with text.
+- Examples of valid OperatorType: CSVFileScan, Filter, Limit, KeywordSearch, Sort, Unnest, Projection, BarChart, DotPlot, LineChart, PieChart, WordCloud, Histogram, ScatterPlot, SklearnLogisticRegression.
+- Briefly mention what you did in the text BEFORE the directive ("Adding a Filter on the canvas now."). Do not write anything AFTER the directive.`;
+
+const ACTION_REGEX = /\[\[ACTION:(\w+)\s+(\{[\s\S]*?\})\]\]/;
+/**
+ * Argus picks a canvas position for new operators by stacking them to the
+ * right of whatever it last placed. The seed point is chosen to land in the
+ * middle of the default-zoom viewport, with vertical wiggle so successive
+ * adds don't all overlap on the same row when the canvas is otherwise empty.
+ */
+const ARGUS_PLACEMENT_SEED: Point = { x: 600, y: 260 };
+const ARGUS_PLACEMENT_STEP_X = 180;
+
+@Component({
+ selector: "texera-tutorial-chat",
+ templateUrl: "tutorial-chat.component.html",
+ styleUrls: ["tutorial-chat.component.scss"],
+ imports: [
+ NgIf,
+ NgFor,
+ NgClass,
+ FormsModule,
+ NzButtonComponent,
+ NzWaveDirective,
+ ɵNzTransitionPatchDirective,
+ NzIconDirective,
+ NzInputDirective,
+ NzTooltipDirective,
+ ArgusComponent,
+ ],
+})
+export class TutorialChatComponent implements OnInit, OnDestroy, AfterViewChecked {
+ @ViewChild("messageContainer") private messageContainer?: ElementRef;
+
+ private readonly destroy$ = new Subject();
+
+ public isOpen = false;
+ public isTutorialActive = false;
+ /** Latches true the first time the user opens the chat — used to hide
+ * the one-time "ask me anything" nudge bubble. */
+ public hasOpenedChatOnce = false;
+ public visibleMessages: ChatMessage[] = []; // shown to user (no system msg)
+ public inputText = "";
+ public isSending = false;
+ public initialized = false;
+ /** Argus state mirrors what the agent is doing — `thinking` while a chat
+ * request is in flight, `wave` while idle (inviting the user to click). */
+ public argusState: ArgusState = "wave";
+ private shouldScrollToBottom = false;
+
+ private agentId: string | null = null;
+ private agentInitPromise: Promise | null = null;
+ private agentStepsSub: Subscription | null = null;
+ private processedMessageIds = new Set();
+
+ constructor(
+ private tutorialService: TutorialService,
+ private workflowActionService: WorkflowActionService,
+ private workflowUtilService: WorkflowUtilService,
+ private agentService: AgentService
+ ) {}
+
+ ngOnInit(): void {
+ this.tutorialService.isActive$.pipe(takeUntil(this.destroy$)).subscribe(active => {
+ this.isTutorialActive = active;
+ if (!active) this.isOpen = false;
+ });
+ }
+
+ ngAfterViewChecked(): void {
+ if (this.shouldScrollToBottom) {
+ this.scrollToBottom();
+ this.shouldScrollToBottom = false;
+ }
+ }
+
+ togglePanel(): void {
+ this.isOpen = !this.isOpen;
+ if (this.isOpen) this.hasOpenedChatOnce = true;
+ this.argusState = this.isOpen ? "idle" : "wave";
+ if (this.isOpen && !this.initialized) {
+ this.visibleMessages.push({
+ role: "assistant",
+ content:
+ "👋 Hey! I'm Argus — your Texera tutor. Stuck on a step? Curious what an operator does? Type a question below — I'll keep it short and useful.",
+ timestamp: new Date(),
+ });
+ this.shouldScrollToBottom = true;
+ this.initialized = true;
+ }
+ }
+
+ closePanel(): void {
+ this.isOpen = false;
+ this.argusState = "wave";
+ }
+
+ async sendMessage(): Promise {
+ const text = this.inputText.trim();
+ if (!text || this.isSending) return;
+
+ this.visibleMessages.push({ role: "user", content: text, timestamp: new Date() });
+ this.shouldScrollToBottom = true;
+ this.inputText = "";
+ this.isSending = true;
+ this.argusState = "thinking";
+
+ const id = await this.ensureAgent();
+ if (!id) {
+ this.isSending = false;
+ this.argusState = "idle";
+ return;
+ }
+
+ const contextLines = this.buildWorkflowContext();
+ const fullPrompt = [
+ SYSTEM_PROMPT,
+ "",
+ ...contextLines,
+ "",
+ `User: ${text}`,
+ "",
+ "Reply now as Argus, following the style rules.",
+ ].join("\n");
+
+ this.agentService.sendMessage(id, fullPrompt, "chat");
+ }
+
+ /**
+ * Lazily create + activate a dedicated agent-service agent on first use.
+ * Prefers claude-haiku-4.5 (per bin/litellm-config.yaml), falls back to
+ * the first model the gateway exposes.
+ */
+ private ensureAgent(): Promise {
+ if (this.agentId) return Promise.resolve(this.agentId);
+ if (this.agentInitPromise) return this.agentInitPromise;
+
+ this.agentInitPromise = (async () => {
+ try {
+ const models = await firstValueFrom(this.agentService.fetchModelTypes());
+ if (!models.length) {
+ this.pushSystem("⚠️ No models available. Is LiteLLM + agent-service running?");
+ return null;
+ }
+ const preferred = models.find(m => /haiku/i.test(m.id)) ?? models[0];
+ const workflowId = this.workflowActionService.getWorkflowMetadata()?.wid;
+ const info = await firstValueFrom(this.agentService.createAgent(preferred.id, "Argus (tutorial)", workflowId));
+ this.agentId = info.id;
+ this.agentService.activateAgent(info.id);
+ this.agentStepsSub = this.agentService
+ .getReActStepsObservable(info.id)
+ .pipe(takeUntil(this.destroy$))
+ .subscribe(steps => this.handleAgentSteps(steps));
+ // Give the WebSocket time to finish CONNECTING → init handshake.
+ await new Promise(resolve => setTimeout(resolve, 800));
+ return info.id;
+ } catch (err: any) {
+ this.pushSystem(`⚠️ Couldn't start Argus: ${err?.message ?? "agent-service unreachable"}`);
+ return null;
+ }
+ })();
+ return this.agentInitPromise;
+ }
+
+ private handleAgentSteps(steps: any[]): void {
+ if (!steps?.length) return;
+ for (const step of steps) {
+ if (step.role !== "agent" || !step.isEnd) continue;
+ if (this.processedMessageIds.has(step.messageId)) continue;
+ this.processedMessageIds.add(step.messageId);
+
+ const parsed = this.parseReply(step.content ?? "");
+ this.visibleMessages.push({ role: "assistant", content: parsed.text, timestamp: new Date() });
+ this.shouldScrollToBottom = true;
+ if (parsed.action) {
+ const note = this.executeAction(parsed.action.name, parsed.action.params);
+ if (note) this.pushSystem(note);
+ }
+ this.isSending = false;
+ this.argusState = "cheer";
+ setTimeout(() => (this.argusState = "idle"), 800);
+ }
+ }
+
+ onKeyEnter(event: KeyboardEvent): void {
+ if (event.key === "Enter" && !event.shiftKey) {
+ event.preventDefault();
+ this.sendMessage();
+ }
+ }
+
+ private pushSystem(content: string): void {
+ this.visibleMessages.push({ role: "system", content, timestamp: new Date() });
+ this.shouldScrollToBottom = true;
+ }
+
+ /**
+ * Gather the tutorial step + canvas state into a few context lines the LLM
+ * can read at the top of the prompt. Keeps the model grounded in what's
+ * actually on the canvas instead of guessing.
+ */
+ private buildWorkflowContext(): string[] {
+ const step = this.tutorialService.currentStep;
+ const graph = this.workflowActionService.getTexeraGraph();
+ const allOps = graph.getAllOperators();
+ const highlighted = this.workflowActionService.getJointGraphWrapper().getCurrentHighlightedOperatorIDs();
+ const selectedOp = highlighted.length === 1 ? graph.getOperator(highlighted[0]) : null;
+
+ const stepLine = step
+ ? `[Tutorial step] ${step.title} — ${step.aiHint}`
+ : "[Tutorial step] (none — the user is exploring on their own)";
+ const canvasLine = allOps.length
+ ? `[Canvas operators] ${allOps.map(o => `${o.operatorType}#${o.operatorID.slice(-6)}`).join(", ")}`
+ : "[Canvas operators] (empty)";
+ const selectedLine = selectedOp
+ ? `[Selected operator] ${selectedOp.operatorType} | properties: ${JSON.stringify(selectedOp.operatorProperties)}`
+ : "[Selected operator] (none)";
+ return [stepLine, canvasLine, selectedLine];
+ }
+
+ /**
+ * Pull an optional action directive off the tail of the LLM reply. The
+ * directive format is `[[ACTION:name {json}]]`; the function returns the
+ * stripped user-facing text plus the parsed action (if any).
+ */
+ private parseReply(reply: string): { text: string; action?: { name: string; params: any } } {
+ const match = reply.match(ACTION_REGEX);
+ if (!match) return { text: reply };
+ try {
+ const params = JSON.parse(match[2]);
+ return {
+ text: reply.replace(ACTION_REGEX, "").trim(),
+ action: { name: match[1], params },
+ };
+ } catch {
+ // Malformed JSON — drop the directive but keep the text so the user
+ // still sees Argus's explanation.
+ return { text: reply.replace(ACTION_REGEX, "").trim() };
+ }
+ }
+
+ /**
+ * Run an Argus-emitted action against the workflow. Returns a short
+ * confirmation / error string to surface in the chat as a system message.
+ */
+ private executeAction(name: string, params: any): string | null {
+ if (name === "add_operator") {
+ return this.executeAddOperator(params?.type);
+ }
+ return `(Unknown action: ${name})`;
+ }
+
+ private executeAddOperator(operatorType: string | undefined): string | null {
+ if (!operatorType || typeof operatorType !== "string") {
+ return "(Argus tried to add an operator but didn't say which type.)";
+ }
+ try {
+ const predicate = this.workflowUtilService.getNewOperatorPredicate(operatorType);
+ const point = this.pickAddPosition();
+ this.workflowActionService.addOperator(predicate, point);
+ return `Added ${operatorType} on the canvas.`;
+ } catch (err: any) {
+ return `Couldn't add ${operatorType}: ${err?.message ?? "operator type not found"}`;
+ }
+ }
+
+ /**
+ * Pick a position for a newly-added operator: start at the seed point,
+ * shift right by one step for each operator already on the canvas so they
+ * don't stack on top of each other.
+ */
+ private pickAddPosition(): Point {
+ const count = this.workflowActionService.getTexeraGraph().getAllOperators().length;
+ return {
+ x: ARGUS_PLACEMENT_SEED.x + count * ARGUS_PLACEMENT_STEP_X,
+ y: ARGUS_PLACEMENT_SEED.y,
+ };
+ }
+
+ private scrollToBottom(): void {
+ if (this.messageContainer) {
+ const el = this.messageContainer.nativeElement;
+ el.scrollTop = el.scrollHeight;
+ }
+ }
+
+ ngOnDestroy(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
+ this.agentStepsSub?.unsubscribe();
+ if (this.agentId) {
+ this.agentService.deactivateAgent(this.agentId);
+ }
+ }
+}
diff --git a/frontend/src/app/workspace/component/tutorial/tutorial-panel/tutorial-panel.component.html b/frontend/src/app/workspace/component/tutorial/tutorial-panel/tutorial-panel.component.html
new file mode 100644
index 00000000000..ee6183d2860
--- /dev/null
+++ b/frontend/src/app/workspace/component/tutorial/tutorial-panel/tutorial-panel.component.html
@@ -0,0 +1,73 @@
+
+
+
+
+
🎉
+
+ Awesome — you did it!
+ You just built your first Texera workflow. Click any operator to peek at its data.
+
+
+
+
+
+
+
+
+
+
+ Start Tutorial
+
+
+
+
+
diff --git a/frontend/src/app/workspace/component/tutorial/tutorial-panel/tutorial-panel.component.scss b/frontend/src/app/workspace/component/tutorial/tutorial-panel/tutorial-panel.component.scss
new file mode 100644
index 00000000000..41a1d1ef751
--- /dev/null
+++ b/frontend/src/app/workspace/component/tutorial/tutorial-panel/tutorial-panel.component.scss
@@ -0,0 +1,149 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Completion toast — pops in after the user finishes a flow, then drifts away
+.tutorial-completion {
+ position: fixed;
+ bottom: 110px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 10001;
+ background: linear-gradient(135deg, #fff 0%, #f0f5ff 100%);
+ border: 2px solid #1890ff;
+ border-radius: 14px;
+ padding: 14px 22px;
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ box-shadow: 0 8px 28px rgba(24, 144, 255, 0.28);
+ font-size: 15px;
+ max-width: 460px;
+ animation: tutorial-pop-in 0.5s cubic-bezier(0.18, 1.25, 0.4, 1);
+}
+
+.tutorial-completion-emoji {
+ font-size: 34px;
+ line-height: 1;
+ animation: tutorial-bounce 1.6s ease-in-out infinite;
+}
+
+.tutorial-completion-text {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ color: #1f1f1f;
+
+ strong {
+ font-size: 15px;
+ color: #1890ff;
+ }
+}
+
+.tutorial-completion-sub {
+ font-size: 13px;
+ color: #555;
+}
+
+@keyframes tutorial-pop-in {
+ 0% {
+ opacity: 0;
+ transform: translateX(-50%) translateY(20px) scale(0.85);
+ }
+ 60% {
+ opacity: 1;
+ transform: translateX(-50%) translateY(-4px) scale(1.04);
+ }
+ 100% {
+ opacity: 1;
+ transform: translateX(-50%) translateY(0) scale(1);
+ }
+}
+
+@keyframes tutorial-bounce {
+ 0%,
+ 100% {
+ transform: translateY(0) rotate(-4deg);
+ }
+ 50% {
+ transform: translateY(-6px) rotate(8deg);
+ }
+}
+
+// Floating launcher cluster (top-right) — pairs the Start button with the
+// trophy-shelf shortcut so users can see their badges without re-launching.
+.tutorial-launcher-cluster {
+ position: fixed;
+ top: 12px;
+ right: 16px;
+ z-index: 10000;
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+
+.tutorial-trophy-btn {
+ height: 36px;
+ width: 36px;
+ border-radius: 50%;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background: #fff9ec;
+ border: 1.5px solid #faad14;
+ color: #faad14;
+ font-size: 16px;
+ box-shadow: 0 3px 10px rgba(250, 173, 20, 0.3);
+ transition:
+ transform 0.15s ease,
+ box-shadow 0.15s ease;
+
+ &:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 6px 14px rgba(250, 173, 20, 0.45);
+ }
+}
+
+.tutorial-start-btn {
+ height: 36px;
+ padding: 0 16px;
+ border-radius: 20px;
+ font-weight: 600;
+ font-size: 13px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ box-shadow: 0 4px 14px rgba(24, 144, 255, 0.4);
+ animation: tutorial-btn-pulse 2s ease-in-out infinite;
+ transition: transform 0.15s ease;
+
+ &:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 6px 20px rgba(24, 144, 255, 0.55);
+ }
+}
+
+@keyframes tutorial-btn-pulse {
+ 0%,
+ 100% {
+ box-shadow: 0 4px 14px rgba(24, 144, 255, 0.4);
+ }
+ 50% {
+ box-shadow: 0 4px 24px rgba(24, 144, 255, 0.7);
+ }
+}
diff --git a/frontend/src/app/workspace/component/tutorial/tutorial-panel/tutorial-panel.component.ts b/frontend/src/app/workspace/component/tutorial/tutorial-panel/tutorial-panel.component.ts
new file mode 100644
index 00000000000..9073b6020da
--- /dev/null
+++ b/frontend/src/app/workspace/component/tutorial/tutorial-panel/tutorial-panel.component.ts
@@ -0,0 +1,391 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Component, OnDestroy, OnInit } from "@angular/core";
+import { NgIf } from "@angular/common";
+import { Subject } from "rxjs";
+import { takeUntil } from "rxjs/operators";
+import confetti from "canvas-confetti";
+import { driver, Driver } from "driver.js";
+import { NzButtonComponent } from "ng-zorro-antd/button";
+import { NzIconDirective } from "ng-zorro-antd/icon";
+import { NzTooltipDirective } from "ng-zorro-antd/tooltip";
+import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch";
+import { NzWaveDirective } from "ng-zorro-antd/core/wave";
+import { TutorialService } from "../../../service/tutorial/tutorial.service";
+import { BadgeDef, TutorialFlow, getFlowById } from "../../../service/tutorial/flows";
+import { TutorialProgress } from "../../../service/tutorial/tutorial-progress";
+import { BadgeUnlockedComponent } from "../badge-unlocked/badge-unlocked.component";
+import { TrophyShelfComponent } from "../trophy-shelf/trophy-shelf.component";
+
+@Component({
+ selector: "texera-tutorial-panel",
+ templateUrl: "tutorial-panel.component.html",
+ styleUrls: ["tutorial-panel.component.scss"],
+ imports: [
+ NgIf,
+ NzButtonComponent,
+ NzWaveDirective,
+ ɵNzTransitionPatchDirective,
+ NzIconDirective,
+ NzTooltipDirective,
+ BadgeUnlockedComponent,
+ TrophyShelfComponent,
+ ],
+})
+export class TutorialPanelComponent implements OnInit, OnDestroy {
+ private readonly destroy$ = new Subject();
+ private welcomeDriver: Driver | null = null;
+ private resumeDriver: Driver | null = null;
+ private chainDriver: Driver | null = null;
+
+ public isActive = false;
+ public showCompletion = false;
+ public progress: TutorialProgress | null = null;
+ public trophyShelfOpen = false;
+ public pendingBadge: BadgeDef | null = null;
+ public pendingBadgeFlow = "";
+
+ constructor(public tutorialService: TutorialService) {}
+
+ ngOnInit(): void {
+ this.tutorialService.isActive$.pipe(takeUntil(this.destroy$)).subscribe(active => {
+ this.isActive = active;
+ });
+
+ this.tutorialService.progress$.pipe(takeUntil(this.destroy$)).subscribe(p => {
+ this.progress = p;
+ });
+
+ this.tutorialService.completed$.pipe(takeUntil(this.destroy$)).subscribe(({ flowId }) => {
+ this.fireConfetti();
+
+ const finished = getFlowById(flowId);
+ const nextFlow = finished?.chainTo ? getFlowById(finished.chainTo) : null;
+
+ if (finished && nextFlow) {
+ // Chain-able flow: merge the "you did it" celebration into the
+ // chain-confirmation popover. No separate "Awesome!" toast.
+ setTimeout(() => this.showChainConfirmPopover(finished, nextFlow), 400);
+ } else {
+ // Terminal flow: show the standard celebration toast.
+ this.showCompletion = true;
+ setTimeout(() => (this.showCompletion = false), 6000);
+ }
+ });
+
+ this.tutorialService.badgeUnlocked$.pipe(takeUntil(this.destroy$)).subscribe(evt => {
+ // Briefly defer so the badge card lands on top of the celebration toast,
+ // not under it.
+ setTimeout(() => {
+ this.pendingBadge = evt.badge;
+ this.pendingBadgeFlow = evt.flowName;
+ }, 700);
+ });
+
+ setTimeout(() => this.openInitialPopover(), 800);
+ }
+
+ /**
+ * On workspace mount, show one of:
+ * - flow picker (first-time user, no prior progress)
+ * - resume prompt (in-flight flow exists)
+ * - nothing (user already saw welcome and isn't mid-tour)
+ */
+ private openInitialPopover(): void {
+ const p = this.progress ?? this.tutorialService.progress;
+ if (p.current && getFlowById(p.current.flowId)?.steps.length) {
+ this.showResumePopover();
+ } else if (!p.seenWelcome) {
+ this.showFlowPickerPopover();
+ }
+ }
+
+ onLaunchPicker(): void {
+ this.showFlowPickerPopover();
+ }
+
+ onTrophyClick(): void {
+ this.trophyShelfOpen = true;
+ }
+
+ onTrophyClosed(): void {
+ this.trophyShelfOpen = false;
+ }
+
+ onTrophyStartFlow(flowId: string): void {
+ this.trophyShelfOpen = false;
+ this.tutorialService.markWelcomeSeen();
+ setTimeout(() => this.tutorialService.start(flowId), 200);
+ }
+
+ onBadgeDismissed(): void {
+ this.pendingBadge = null;
+ }
+
+ onBadgeViewShelf(): void {
+ this.pendingBadge = null;
+ this.trophyShelfOpen = true;
+ }
+
+ // ===== Driver-based popovers =====
+
+ private showFlowPickerPopover(): void {
+ this.welcomeDriver = driver({
+ showProgress: false,
+ allowClose: true,
+ overlayColor: "#000",
+ overlayOpacity: 0.7,
+ stagePadding: 0,
+ popoverClass: "tutorial-welcome-popover tutorial-picker-popover",
+ showButtons: ["close"],
+ onCloseClick: () => {
+ this.tutorialService.markWelcomeSeen();
+ this.welcomeDriver?.destroy();
+ },
+ onDestroyed: () => {
+ this.welcomeDriver = null;
+ },
+ steps: [
+ {
+ popover: {
+ title: "👋 Welcome to Texera!",
+ description: this.buildPickerHtml(),
+ },
+ },
+ ],
+ });
+ this.welcomeDriver.drive();
+
+ setTimeout(() => this.bindPickerHandlers(), 50);
+ }
+
+ private buildPickerHtml(): string {
+ const flows = this.tutorialService.flows;
+ const earnedBadges = new Set(this.progress?.earnedBadges ?? []);
+ const cards = flows
+ .map(flow => {
+ const done = earnedBadges.has(flow.badge.id);
+ const disabled = flow.comingSoon || flow.steps.length === 0;
+ const meta = `${flow.estimatedMinutes} min · ${flow.difficulty}`;
+ const ribbon = done
+ ? "✓ Done "
+ : flow.comingSoon
+ ? "Coming soon "
+ : "";
+ return `
+
+
+ ${flow.shortDesc}
+ ${meta}
+
+ `;
+ })
+ .join("");
+
+ return `
+
+
+
+ Texera lets you build data workflows visually — drag operators (sources, filters, charts, ML models)
+ onto a canvas, wire them together, and run the pipeline. No code required.
+
+
Ready to try? Pick a quick tour to get started.
+
+
${cards}
+
I'll explore on my own
+
+ `;
+ }
+
+ private bindPickerHandlers(): void {
+ const popoverRoot = document.querySelector(".tutorial-picker-popover");
+ if (!popoverRoot) return;
+
+ popoverRoot.querySelectorAll("button[data-flow-id]").forEach(btn => {
+ btn.addEventListener("click", () => {
+ const flowId = btn.dataset["flowId"];
+ if (!flowId || btn.hasAttribute("disabled")) return;
+ this.tutorialService.markWelcomeSeen();
+ this.welcomeDriver?.destroy();
+ setTimeout(() => this.tutorialService.start(flowId), 250);
+ });
+ });
+
+ const skipBtn = popoverRoot.querySelector("button[data-action='skip']");
+ skipBtn?.addEventListener("click", () => {
+ this.tutorialService.markWelcomeSeen();
+ this.welcomeDriver?.destroy();
+ });
+ }
+
+ private showResumePopover(): void {
+ const p = this.progress ?? this.tutorialService.progress;
+ if (!p.current) return;
+ const flow = getFlowById(p.current.flowId);
+ if (!flow) return;
+
+ const stepNum = (p.current.stepIndex ?? 0) + 1;
+ this.resumeDriver = driver({
+ showProgress: false,
+ allowClose: true,
+ overlayColor: "#000",
+ overlayOpacity: 0.65,
+ stagePadding: 0,
+ popoverClass: "tutorial-welcome-popover tutorial-resume-popover",
+ showButtons: ["close"],
+ onCloseClick: () => this.resumeDriver?.destroy(),
+ onDestroyed: () => {
+ this.resumeDriver = null;
+ },
+ steps: [
+ {
+ popover: {
+ title: "👋 Welcome back!",
+ description: `
+
+
You're partway through ${flow.name} — step ${stepNum} of ${flow.steps.length}.
+
Pick up where you left off, restart this flow, or browse other tours.
+
+ Resume ▶
+ Restart this flow
+ Pick another tour
+
+
+ `,
+ },
+ },
+ ],
+ });
+ this.resumeDriver.drive();
+
+ setTimeout(() => this.bindResumeHandlers(flow), 50);
+ }
+
+ private bindResumeHandlers(flow: TutorialFlow): void {
+ const root = document.querySelector(".tutorial-resume-popover");
+ if (!root) return;
+ root.querySelector("[data-action='resume']")?.addEventListener("click", () => {
+ this.resumeDriver?.destroy();
+ setTimeout(() => this.tutorialService.resume(), 250);
+ });
+ root.querySelector("[data-action='restart']")?.addEventListener("click", () => {
+ this.resumeDriver?.destroy();
+ setTimeout(() => this.tutorialService.start(flow.id), 250);
+ });
+ root.querySelector("[data-action='picker']")?.addEventListener("click", () => {
+ this.resumeDriver?.destroy();
+ setTimeout(() => this.showFlowPickerPopover(), 250);
+ });
+ }
+
+ /**
+ * Asks the user whether to chain into the next flow after the current one
+ * finished. Driven by the completed flow's `chainTo` field — fires after a
+ * short delay so the celebration burst + badge notification land first.
+ */
+ private showChainConfirmPopover(finished: TutorialFlow, next: TutorialFlow): void {
+ this.chainDriver = driver({
+ showProgress: false,
+ allowClose: true,
+ overlayColor: "#000",
+ overlayOpacity: 0.65,
+ stagePadding: 0,
+ popoverClass: "tutorial-welcome-popover tutorial-resume-popover",
+ showButtons: ["close"],
+ onCloseClick: () => this.chainDriver?.destroy(),
+ onDestroyed: () => {
+ this.chainDriver = null;
+ },
+ steps: [
+ {
+ popover: {
+ title: `${finished.badge.emoji} ${finished.badge.name} unlocked!`,
+ description: `
+
+
Nice — you've got the lay of the land. Want to build your first workflow now?
+
+ Continue
+ Not now
+
+
+ `,
+ },
+ },
+ ],
+ });
+ this.chainDriver.drive();
+
+ setTimeout(() => this.bindChainHandlers(next), 50);
+ }
+
+ private bindChainHandlers(next: TutorialFlow): void {
+ const root = document.querySelector(".tutorial-resume-popover");
+ if (!root) return;
+ root.querySelector("[data-action='continue']")?.addEventListener("click", () => {
+ this.chainDriver?.destroy();
+ setTimeout(() => this.tutorialService.start(next.id), 250);
+ });
+ root.querySelector("[data-action='later']")?.addEventListener("click", () => {
+ this.chainDriver?.destroy();
+ });
+ }
+
+ private fireConfetti(): void {
+ const duration = 1800;
+ const end = Date.now() + duration;
+ const colors = ["#1890ff", "#52c41a", "#faad14", "#eb2f96", "#722ed1"];
+
+ const frame = () => {
+ confetti({
+ particleCount: 4,
+ angle: 60,
+ spread: 60,
+ startVelocity: 55,
+ origin: { x: 0, y: 0.85 },
+ colors,
+ });
+ confetti({
+ particleCount: 4,
+ angle: 120,
+ spread: 60,
+ startVelocity: 55,
+ origin: { x: 1, y: 0.85 },
+ colors,
+ });
+ if (Date.now() < end) requestAnimationFrame(frame);
+ };
+ frame();
+ }
+
+ ngOnDestroy(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
+ this.welcomeDriver?.destroy();
+ this.resumeDriver?.destroy();
+ this.chainDriver?.destroy();
+ }
+}
diff --git a/frontend/src/app/workspace/component/workspace.component.html b/frontend/src/app/workspace/component/workspace.component.html
index c54446fb318..143a08c4300 100644
--- a/frontend/src/app/workspace/component/workspace.component.html
+++ b/frontend/src/app/workspace/component/workspace.component.html
@@ -37,4 +37,8 @@
*ngIf="copilotEnabled"
[agentIdToActivate]="agentIdToActivate">
+
+
+
+
diff --git a/frontend/src/app/workspace/component/workspace.component.spec.ts b/frontend/src/app/workspace/component/workspace.component.spec.ts
index 0eaaf7f5fb4..3bb23292b80 100644
--- a/frontend/src/app/workspace/component/workspace.component.spec.ts
+++ b/frontend/src/app/workspace/component/workspace.component.spec.ts
@@ -359,4 +359,12 @@ describe("WorkspaceComponent", () => {
expect(component.copilotEnabled).toBe(false);
});
});
+
+ describe("tutorialEnabled", () => {
+ it("passes through to GuiConfigService.env.tutorialEnabled", async () => {
+ await createFixture();
+ // MockGuiConfigService defaults `tutorialEnabled` to false.
+ expect(component.tutorialEnabled).toBe(false);
+ });
+ });
});
diff --git a/frontend/src/app/workspace/component/workspace.component.ts b/frontend/src/app/workspace/component/workspace.component.ts
index 9968c26f647..02952db45e1 100644
--- a/frontend/src/app/workspace/component/workspace.component.ts
+++ b/frontend/src/app/workspace/component/workspace.component.ts
@@ -61,6 +61,8 @@ import { LeftPanelComponent } from "./left-panel/left-panel.component";
import { AgentPanelComponent } from "./agent/agent-panel/agent-panel.component";
import { PropertyEditorComponent } from "./property-editor/property-editor.component";
import { FormlyRepeatDndComponent } from "../../common/formly/repeat-dnd/repeat-dnd.component";
+import { TutorialPanelComponent } from "./tutorial/tutorial-panel/tutorial-panel.component";
+import { TutorialChatComponent } from "./tutorial/tutorial-chat/tutorial-chat.component";
export const SAVE_DEBOUNCE_TIME_IN_MS = 5000;
@@ -84,6 +86,8 @@ export const SAVE_DEBOUNCE_TIME_IN_MS = 5000;
AgentPanelComponent,
PropertyEditorComponent,
FormlyRepeatDndComponent,
+ TutorialPanelComponent,
+ TutorialChatComponent,
],
})
export class WorkspaceComponent implements AfterViewInit, OnInit, OnDestroy {
@@ -328,4 +332,8 @@ export class WorkspaceComponent implements AfterViewInit, OnInit, OnDestroy {
public get copilotEnabled(): boolean {
return this.config.env.copilotEnabled;
}
+
+ public get tutorialEnabled(): boolean {
+ return this.config.env.tutorialEnabled;
+ }
}
diff --git a/frontend/src/app/workspace/service/tutorial/flows/build-complex.ts b/frontend/src/app/workspace/service/tutorial/flows/build-complex.ts
new file mode 100644
index 00000000000..ddf5788d41a
--- /dev/null
+++ b/frontend/src/app/workspace/service/tutorial/flows/build-complex.ts
@@ -0,0 +1,215 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import type { TutorialFlow, TutorialStep } from "./index";
+
+/**
+ * Premise: the tour loads a prebuilt CSVFileScan -> Filter starter workflow
+ * (movies-filter.json) into the canvas before the first step runs. This flow
+ * only teaches the visualization addition — wiring a Dot Plot onto the
+ * Filter's output. The ML side was split off into `build-ml`.
+ */
+const STEPS: TutorialStep[] = [
+ {
+ popover: {
+ title: "Ready for more operators? 🚀",
+ description:
+ "Time for a bit more analysis. We prepared a CSV + Filter starter on the canvas — click Next and we'll walk through them, then add a chart on top to actually see the data.",
+ },
+ title: "Step 1 — Add-chart intro",
+ aiHint:
+ "Intro for the chart-adding flow. The tour auto-loaded a CSV File Scan + Filter starter workflow on the canvas. The next two steps spotlight each prebuilt operator with a one-line explanation, then the flow guides the user to add a Dot Plot on top.",
+ },
+ {
+ element: '[model-id^="CSVFileScan-operator-"]',
+ popover: {
+ title: "Source: CSV File Scan",
+ description:
+ "This one reads rows from a CSV file. We pointed it at a movies dataset , so it's emitting one row per movie.",
+ side: "bottom",
+ align: "center",
+ },
+ title: "Step 2 — Meet the source",
+ aiHint:
+ "Spotlight on the prebuilt CSV File Scan operator on the canvas, with a one-line explanation that it's reading a movies CSV. No interaction needed — user clicks Next.",
+ },
+ {
+ element: '[model-id^="Filter-operator-"]',
+ popover: {
+ title: "Transform: Filter",
+ description:
+ "Right after CSV, this Filter keeps only rows where year > 1960 — so the chart later only shows the modern era.",
+ side: "bottom",
+ align: "center",
+ },
+ title: "Step 3 — Meet the filter",
+ aiHint:
+ "Spotlight on the prebuilt Filter operator with its single predicate (year > 1960) explained. No interaction needed — user clicks Next.",
+ },
+ {
+ element: 'nz-collapse-panel[data-group-name="Visualization"]',
+ popover: {
+ title: "Open the Visualization category",
+ description:
+ 'On the left, click "Visualization" . This category holds every chart Texera supports: bar, line, pie, scatter, and more exotic ones.',
+ side: "right",
+ align: "start",
+ },
+ title: "Step 4 — Visualization category",
+ aiHint: "The user is expanding the 'Visualization' group in the operator menu so they can find chart operators.",
+ advanceOnClick: true,
+ },
+ {
+ element: 'nz-collapse-panel[data-group-name="Basic"]',
+ popover: {
+ title: "Then the Basic sub-group",
+ description:
+ 'Visualization is split into sub-groups. Click "Basic" to find the everyday charts (bar, line, pie).',
+ side: "right",
+ align: "start",
+ },
+ title: "Step 5 — Basic sub-group",
+ aiHint:
+ "The Visualization group has nested sub-groups. The user is opening 'Basic' to reveal Bar/Line/Pie chart operators.",
+ advanceOnClick: true,
+ },
+ {
+ element: '.operator-label[data-operator-type="DotPlot"]',
+ popover: {
+ title: "Drag Dot Plot onto the canvas",
+ description:
+ "Grab Dot Plot and drop it on the canvas, somewhere to the right of your Filter box. We're going to feed Filter's output into it.",
+ side: "right",
+ align: "start",
+ },
+ title: "Step 6 — Drag Dot Plot",
+ aiHint:
+ "The user drags the Dot Plot visualization operator onto the canvas. It will be connected to Filter's output in the next step.",
+ autoAdvanceOn: "operatorAdded",
+ },
+ {
+ element: "texera-workflow-editor",
+ popover: {
+ title: "Wire Filter into the chart",
+ description:
+ "Drag from the output port on the right edge of Filter over to the input port on the left edge of Dot Plot . Now the chart will receive the filtered rows.",
+ side: "over",
+ align: "center",
+ },
+ title: "Step 7 — Connect Filter to Dot Plot",
+ aiHint:
+ "The user is dragging an edge from Filter's output to Dot Plot's input so the chart visualizes the filtered rows.",
+ autoAdvanceOn: "linkAdded",
+ },
+ {
+ element: '[model-id^="DotPlot-operator-"]',
+ popover: {
+ title: "Click the chart to configure it",
+ description:
+ "Click the Dot Plot box. The property panel on the right will switch to its settings so you can pick which column to count.",
+ side: "bottom",
+ align: "center",
+ },
+ title: "Step 8 — Select Dot Plot",
+ aiHint:
+ "The user clicks the Dot Plot operator on the canvas so the property panel switches to its single-field config (Count Attribute).",
+ autoAdvanceOn: "operatorSelected",
+ },
+ {
+ element: 'li[data-tutorial="open-property-panel"]',
+ popover: {
+ title: "Open the property panel",
+ description:
+ "If the panel collapsed, click the form icon at the top-right to reopen it. (Already open? Skipping.) ",
+ side: "left",
+ align: "center",
+ },
+ title: "Step 9 — Reopen property panel",
+ aiHint:
+ "The user reopens the property panel for the Dot Plot operator. Auto-skipped when the panel is already open.",
+ advanceOnClick: true,
+ },
+ {
+ element: ".property-editor-form",
+ popover: {
+ title: "Pick the column",
+ description:
+ "In the highlighted form, set the only required field: • Count Attribute : runtime This will show one dot per runtime value, sized by how many movies share it. Click Next once set.",
+ side: "left",
+ align: "center",
+ },
+ title: "Step 10 — Configure Dot Plot",
+ aiHint:
+ "The user fills Dot Plot's single required field: Count Attribute = 'runtime'. The field renders as an nz-select dropdown of column names (AutofillAttributeName), and after selection the operator groups by runtime and counts rows. Spotlight is on .property-editor-form; styles.scss handles overflow / cdk-overlay z-index so the dropdown opens normally.",
+ autoAdvanceOn: "propertyChanged",
+ },
+ {
+ element: "#run-button",
+ popover: {
+ title: "Run it",
+ description: "Hit Run . Texera will pipe the rows through Filter into Dot Plot and render it.",
+ side: "bottom",
+ align: "start",
+ },
+ title: "Step 11 — Run the workflow",
+ aiHint: "The user clicks the Run button to execute the workflow with the new Dot Plot.",
+ advanceOnClick: true,
+ },
+ {
+ element: 'li[data-tutorial="open-result-panel"]',
+ popover: {
+ title: "Open the result panel",
+ description:
+ "Click the square icon at the bottom-left to expand the result panel. Then click the Dot Plot on the canvas to see its rendered output.",
+ side: "top",
+ align: "start",
+ },
+ title: "Step 12 — Open result panel",
+ aiHint: "The user clicks the 'Open Result Panel' icon at the bottom-left to expand the data panel.",
+ advanceOnClick: true,
+ },
+ {
+ popover: {
+ title: "Chart unlocked",
+ description:
+ "Your data is now visual. Click any operator to inspect its output in the result panel. When you're ready for the next level, try the Add a simple ML model tour next.",
+ },
+ title: "Step 13 — Chart celebration",
+ aiHint:
+ "Final step. The user has wired a Dot Plot off Filter and run the workflow. No element highlighted, centered celebratory popover that teases the ML follow-up tour.",
+ },
+];
+
+export const BUILD_COMPLEX_FLOW: TutorialFlow = {
+ id: "build-complex",
+ name: "Add a chart",
+ shortDesc: "Plug a Dot Plot onto a pre-built CSV + Filter workflow and see the data.",
+ difficulty: "medium",
+ estimatedMinutes: 2,
+ prerequisites: ["build-simple"],
+ prebuiltWorkflow: "movies-filter",
+ badge: {
+ id: "chart-maker",
+ emoji: "📊",
+ name: "Chart Maker",
+ description: "Visualized a Texera workflow with your first chart.",
+ hue: "#13c2c2",
+ },
+ steps: STEPS,
+};
diff --git a/frontend/src/app/workspace/service/tutorial/flows/build-simple.ts b/frontend/src/app/workspace/service/tutorial/flows/build-simple.ts
new file mode 100644
index 00000000000..dd8cd8cea2f
--- /dev/null
+++ b/frontend/src/app/workspace/service/tutorial/flows/build-simple.ts
@@ -0,0 +1,271 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import type { TutorialFlow, TutorialStep } from "./index";
+
+const STEPS: TutorialStep[] = [
+ {
+ popover: {
+ title: "Let's build your first workflow",
+ description:
+ "We'll wire a tiny pipeline together: CSV File Scan → Limit → Run → see the rows. Should take about 2 minutes. Ready? Click Next.",
+ },
+ title: "Step 1 — Intro",
+ aiHint:
+ "Brief intro for build-simple. Assumes the user has seen (or will skip) the workspace overview tour, so this step does NOT dwell on what the operator panel is — it just states the goal (CSV -> Limit -> Run -> view) and starts. No element highlighted, centered popover.",
+ },
+ {
+ element: 'nz-collapse-panel[data-group-name="Data Input"]',
+ popover: {
+ title: "First, let's get some data",
+ description:
+ 'Click "Data Input" to open the category. Sources are operators that pull data into your workflow — CSV files, databases, APIs.',
+ side: "right",
+ align: "start",
+ },
+ title: "Step 2 — Find a Source",
+ aiHint: "The user is locating the Data Input category to find a source operator.",
+ advanceOnClick: true,
+ },
+ {
+ element: '.operator-label[data-operator-type="CSVFileScan"]',
+ popover: {
+ title: "Drag it onto the canvas",
+ description:
+ "Grab CSV File Scan and drop it onto the empty canvas on the right. This little box's job: read rows from a CSV file.",
+ side: "right",
+ align: "start",
+ },
+ title: "Step 3 — Drag CSV File Scan",
+ aiHint: "The user is dragging the CSV File Scan source operator from the operator panel onto the canvas.",
+ autoAdvanceOn: "operatorAdded",
+ },
+ {
+ element: '[model-id^="CSVFileScan-operator-"]',
+ popover: {
+ title: "Nice! Now give it a click",
+ description:
+ "There's your CSV File Scan on the canvas. Click it once to select it — that's how we tell Texera \"I want to configure this one\".",
+ side: "bottom",
+ align: "center",
+ },
+ title: "Step 4 — Click the Operator",
+ aiHint: "The user needs to click the just-placed CSV File Scan operator on the canvas to open its property editor.",
+ autoAdvanceOn: "operatorSelected",
+ },
+ {
+ element: 'li[data-tutorial="open-property-panel"]',
+ popover: {
+ title: "Open its settings",
+ description:
+ "See the little form icon at the top-right? Click it to slide open the settings panel on the right side. (Already open? We'll just skip ahead.) ",
+ side: "left",
+ align: "center",
+ },
+ title: "Step 5 — Open the Property Panel",
+ aiHint:
+ "The user needs to click the small form icon at the top-right of the canvas to expand the (currently collapsed) property panel. Auto-skipped when the panel is already open.",
+ advanceOnClick: true,
+ },
+ {
+ element: "#formly-title",
+ popover: {
+ title: "This is the property panel",
+ description:
+ "Every operator's settings live here. For CSV File Scan , the only thing we need is to pick a file. Click Next.",
+ side: "left",
+ align: "center",
+ },
+ title: "Step 6 — Meet the property panel",
+ aiHint:
+ "Always-shown explainer that introduces the property panel. Runs whether the previous open-property-panel step was clicked or auto-skipped, so users who already had the panel open still get the orientation.",
+ },
+ {
+ element: 'button[data-tutorial="file-select-button"]',
+ popover: {
+ title: "Pick a file",
+ description: "Click Select File to open the dataset browser — that's where your CSVs live.",
+ side: "left",
+ align: "center",
+ },
+ title: "Step 7 — Open the File Picker",
+ aiHint: "The user is about to click the Select File button to open the dataset selection modal.",
+ advanceOnClick: true,
+ },
+ {
+ element: ".ant-modal-content",
+ popover: {
+ title: "Browse your data",
+ description:
+ "Pick a dataset on the left, expand a version, then double-click any .csv . The modal closes itself and the file name auto-fills — no typing required.",
+ side: "top",
+ align: "end",
+ },
+ title: "Step 8 — Pick a CSV File",
+ aiHint:
+ "The user is choosing a CSV file from the dataset selection modal. After they pick one, the modal closes and the fileName property updates.",
+ autoAdvanceOn: "propertyChanged",
+ },
+ {
+ element: 'nz-collapse-panel[data-group-name="Data Cleaning"]',
+ popover: {
+ title: "Time for a second operator",
+ description:
+ 'Back to the left panel . Click "Data Cleaning" to open that category — we\'re about to chain a second operator after our source.',
+ side: "right",
+ align: "start",
+ },
+ title: "Step 9 — Find the Data Cleaning Category",
+ aiHint: "The user needs to expand the Data Cleaning category in the operator panel to find Limit.",
+ advanceOnClick: true,
+ },
+ {
+ element: '.operator-label[data-operator-type="Limit"]',
+ popover: {
+ title: "Drag Limit onto the canvas",
+ description:
+ "Grab Limit and drop it on the canvas next to CSV File Scan. Limit caps your output to the first N rows — perfect for a quick peek.",
+ side: "right",
+ align: "start",
+ },
+ title: "Step 10 — Drag Limit",
+ aiHint: "The user is dragging the Limit operator onto the canvas as a second operator.",
+ autoAdvanceOn: "operatorAdded",
+ },
+ {
+ element: "texera-workflow-editor",
+ popover: {
+ title: "Wire them up",
+ description:
+ 'Drag from the ▶ port on the right edge of CSV File Scan over to the port on the left edge of Limit . That edge tells Texera "send rows this way".',
+ side: "over",
+ align: "center",
+ },
+ title: "Step 11 — Connect Operators",
+ aiHint: "The user is dragging an edge from CSV File Scan's output port to Limit's input port.",
+ autoAdvanceOn: "linkAdded",
+ },
+ {
+ element: '[model-id^="Limit-operator-"]',
+ popover: {
+ title: "Click Limit to configure it",
+ description:
+ "Click the Limit box on the canvas. The property panel on the right will swap over to its settings — each operator has its own.",
+ side: "bottom",
+ align: "center",
+ },
+ title: "Step 12 — Click Limit",
+ aiHint:
+ "The user needs to click the just-connected Limit operator on the canvas to switch the property editor to its fields.",
+ autoAdvanceOn: "operatorSelected",
+ },
+ {
+ element: 'li[data-tutorial="open-property-panel"]',
+ popover: {
+ title: "Open its settings",
+ description:
+ "If the panel collapsed again, click the form icon at the top-right to reopen it. (Already open? Skipping…) ",
+ side: "left",
+ align: "center",
+ },
+ title: "Step 13 — Open the Property Panel (Limit)",
+ aiHint:
+ "Same as step 5: the user clicks the form icon to reopen the property panel for the Limit operator. Auto-skipped when the panel is already open.",
+ advanceOnClick: true,
+ },
+ {
+ element: ".property-editor-form",
+ popover: {
+ title: "How many rows?",
+ description:
+ "Type a number into the Limit field — that's the max rows you'll see. Try 10 for a snappy preview.",
+ side: "left",
+ align: "start",
+ },
+ title: "Step 14 — Configure Limit",
+ aiHint: "The user is configuring the Limit operator by typing a number in the limit field (max output rows).",
+ autoAdvanceOn: "propertyChanged",
+ },
+ {
+ element: "#texera-compute-unit-selection",
+ popover: {
+ title: "Almost there — pick a Computing Unit",
+ description:
+ "Before running, click the Computing Unit selector at the top and pick (or start) one. That's the little worker that'll execute your workflow.",
+ side: "bottom",
+ align: "start",
+ },
+ title: "Step 15 — Connect a Computing Unit",
+ aiHint: "The user needs to select/connect a Computing Unit from the top menu before running the workflow.",
+ autoAdvanceOn: "computingUnitConnected",
+ },
+ {
+ element: "#run-button",
+ popover: {
+ title: "Hit Run!",
+ description:
+ "Once the unit shows Connected , smash that ▶ Run button and watch Texera get to work.",
+ side: "bottom",
+ align: "start",
+ },
+ title: "Step 16 — Run the Workflow",
+ aiHint: "The user is clicking the Run button at the top to execute the workflow.",
+ advanceOnClick: true,
+ },
+ {
+ element: 'li[data-tutorial="open-result-panel"]',
+ popover: {
+ title: "Open the result panel",
+ description:
+ "Your workflow finished — the data is ready! Click this little square icon at the bottom-left to slide open the result panel and see your rows.",
+ side: "top",
+ align: "start",
+ },
+ title: "Step 17 — Open the Result Panel",
+ aiHint:
+ "The user just ran the workflow. They need to click the 'Open Result Panel' icon (the square / border icon at the bottom-left of the screen) to expand the result panel and view their data.",
+ advanceOnClick: true,
+ },
+ {
+ popover: {
+ title: "🎉 You did it!",
+ description:
+ "Your CSV rows are now waiting for you in the result panel. You just built and ran your very first Texera workflow — from empty canvas to real data in under two minutes. Welcome aboard! ",
+ },
+ title: "Step 18 — Celebration",
+ aiHint:
+ "Final celebratory step. The user has opened the result panel and can now see their workflow's output. No element is highlighted — the popover is centered like the welcome screen for a clean closing moment.",
+ },
+];
+
+export const BUILD_SIMPLE_FLOW: TutorialFlow = {
+ id: "build-simple",
+ name: "Build your first workflow",
+ shortDesc: "CSV → Limit → Run → View. The friendly 18-step intro.",
+ difficulty: "intro",
+ estimatedMinutes: 2,
+ badge: {
+ id: "workflow-builder",
+ emoji: "🧱",
+ name: "Workflow Builder",
+ description: "Built and ran your first Texera workflow.",
+ hue: "#1890ff",
+ },
+ steps: STEPS,
+};
diff --git a/frontend/src/app/workspace/service/tutorial/flows/flows-integrity.spec.ts b/frontend/src/app/workspace/service/tutorial/flows/flows-integrity.spec.ts
new file mode 100644
index 00000000000..6e0666988c8
--- /dev/null
+++ b/frontend/src/app/workspace/service/tutorial/flows/flows-integrity.spec.ts
@@ -0,0 +1,154 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { BONUS_BADGES, FLOWS, getFlowById, GRADUATE_BADGE, SPEED_RUNNER_BADGE, TutorialFlow } from "./index";
+
+describe("FLOWS registry integrity", () => {
+ describe("identifiers", () => {
+ it("every flow has a unique id", () => {
+ const ids = FLOWS.map(f => f.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+
+ it("every flow's badge id is unique across all flows + bonus badges", () => {
+ const ids = [...FLOWS.map(f => f.badge.id), ...BONUS_BADGES.map(b => b.id)];
+ const seen = new Set();
+ const dupes: string[] = [];
+ for (const id of ids) {
+ if (seen.has(id)) dupes.push(id);
+ seen.add(id);
+ }
+ expect(dupes).toEqual([]);
+ });
+
+ it("getFlowById returns the matching flow", () => {
+ for (const flow of FLOWS) {
+ expect(getFlowById(flow.id)).toBe(flow);
+ }
+ });
+
+ it("getFlowById returns null for an unknown id", () => {
+ expect(getFlowById("does-not-exist")).toBeNull();
+ });
+ });
+
+ describe("step content", () => {
+ it("every step in every launchable flow has a non-empty title and aiHint", () => {
+ const offenders: string[] = [];
+ for (const flow of FLOWS.filter(f => !f.comingSoon && f.steps.length > 0)) {
+ flow.steps.forEach((step, i) => {
+ if (!step.title) offenders.push(`${flow.id}[${i}].title`);
+ if (!step.aiHint) offenders.push(`${flow.id}[${i}].aiHint`);
+ });
+ }
+ expect(offenders).toEqual([]);
+ });
+
+ it("flows marked comingSoon have empty steps arrays (convention check)", () => {
+ const violations = FLOWS.filter(f => f.comingSoon && f.steps.length > 0).map(f => f.id);
+ expect(violations).toEqual([]);
+ });
+ });
+
+ describe("skipToStep targets", () => {
+ it("every skipToStep value points to a strictly later step inside the same flow", () => {
+ const offenders: string[] = [];
+ for (const flow of FLOWS) {
+ flow.steps.forEach((step, i) => {
+ if (step.skipToStep === undefined) return;
+ if (step.skipToStep <= i || step.skipToStep >= flow.steps.length) {
+ offenders.push(`${flow.id}[${i}] → ${step.skipToStep}`);
+ }
+ });
+ }
+ expect(offenders).toEqual([]);
+ });
+
+ it("skipToStep is only used together with showDetailsButton (paired branching)", () => {
+ const unpaired: string[] = [];
+ for (const flow of FLOWS) {
+ flow.steps.forEach((step, i) => {
+ if (step.skipToStep !== undefined && step.showDetailsButton !== true) {
+ unpaired.push(`${flow.id}[${i}]`);
+ }
+ });
+ }
+ expect(unpaired).toEqual([]);
+ });
+ });
+
+ describe("chainTo references", () => {
+ it("every chainTo points at a real flow id", () => {
+ const broken: string[] = [];
+ for (const flow of FLOWS) {
+ if (flow.chainTo && getFlowById(flow.chainTo) === null) {
+ broken.push(`${flow.id} → ${flow.chainTo}`);
+ }
+ }
+ expect(broken).toEqual([]);
+ });
+
+ it("no flow chains to itself", () => {
+ const selfLoops = FLOWS.filter(f => f.chainTo === f.id).map(f => f.id);
+ expect(selfLoops).toEqual([]);
+ });
+ });
+
+ describe("prerequisites", () => {
+ it("every prerequisite id resolves to a real flow", () => {
+ const broken: string[] = [];
+ for (const flow of FLOWS) {
+ for (const prereq of flow.prerequisites ?? []) {
+ if (getFlowById(prereq) === null) broken.push(`${flow.id} ← ${prereq}`);
+ }
+ }
+ expect(broken).toEqual([]);
+ });
+ });
+
+ describe("bonus badges", () => {
+ it("BONUS_BADGES contains SPEED_RUNNER and GRADUATE", () => {
+ expect(BONUS_BADGES).toContain(SPEED_RUNNER_BADGE);
+ expect(BONUS_BADGES).toContain(GRADUATE_BADGE);
+ });
+ });
+});
+
+describe("overview-workspace branching", () => {
+ let flow: TutorialFlow;
+
+ beforeAll(() => {
+ const found = getFlowById("overview-workspace");
+ expect(found).not.toBeNull();
+ flow = found!;
+ });
+
+ it("every group-overview step jumps over at least one detail step", () => {
+ const offenders: string[] = [];
+ flow.steps.forEach((step, index) => {
+ if (step.skipToStep === undefined) return;
+ if (step.skipToStep <= index + 1) {
+ offenders.push(`${flow.id}[${index}] → ${step.skipToStep} (no skip)`);
+ }
+ const target = flow.steps[step.skipToStep];
+ if (!target?.title) offenders.push(`${flow.id}[${step.skipToStep}] has no title`);
+ });
+ expect(offenders).toEqual([]);
+ });
+});
diff --git a/frontend/src/app/workspace/service/tutorial/flows/index.ts b/frontend/src/app/workspace/service/tutorial/flows/index.ts
new file mode 100644
index 00000000000..cf873325c66
--- /dev/null
+++ b/frontend/src/app/workspace/service/tutorial/flows/index.ts
@@ -0,0 +1,145 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { DriveStep } from "driver.js";
+import { OVERVIEW_WORKSPACE_FLOW } from "./overview-workspace";
+import { BUILD_SIMPLE_FLOW } from "./build-simple";
+import { BUILD_COMPLEX_FLOW } from "./build-complex";
+import { PUBLIC_WORKFLOW_FLOW } from "./public-workflow";
+
+export type AutoAdvanceTrigger =
+ | "operatorAdded"
+ | "operatorSelected"
+ | "linkAdded"
+ | "executionStarted"
+ | "propertyChanged"
+ | "computingUnitConnected";
+
+export interface TutorialStep extends DriveStep {
+ /** Short label used by the AI chat for context. */
+ title: string;
+ /** Prompt fragment given to the AI when the user asks a question on this step. */
+ aiHint: string;
+ /** If set, advance automatically when this event fires on the workflow. */
+ autoAdvanceOn?: AutoAdvanceTrigger;
+ /** If true, clicking the highlighted element advances to the next step. */
+ advanceOnClick?: boolean;
+ /** Default Next jumps to this index instead of `+1`. Pair with `showDetailsButton` for opt-in deep dives. */
+ skipToStep?: number;
+ /** Adds a secondary "Show details" button to the popover that does normal moveNext. Only meaningful with `skipToStep`. */
+ showDetailsButton?: boolean;
+}
+
+export interface BadgeDef {
+ id: string;
+ emoji: string;
+ name: string;
+ description: string;
+ /** Optional palette to tint the locked silhouette / unlocked frame. */
+ hue?: string;
+}
+
+export interface TutorialFlow {
+ id: string;
+ name: string;
+ shortDesc: string;
+ difficulty: "intro" | "easy" | "medium" | "advanced";
+ estimatedMinutes: number;
+ /** Stub flows have empty `steps` arrays and aren't launchable yet. */
+ comingSoon?: boolean;
+ /** Flow ids the user is encouraged to finish first. */
+ prerequisites?: string[];
+ badge: BadgeDef;
+ steps: TutorialStep[];
+ /** Basename of a JSON fixture under `assets/tutorial-workflows/` to load into the canvas before step 0. */
+ prebuiltWorkflow?: string;
+ /**
+ * Flow id to chain into automatically when this one completes.
+ */
+ chainTo?: string;
+}
+
+/** Coming-soon placeholders advertised in the picker but not launchable. */
+const BUILD_ML_FLOW: TutorialFlow = {
+ id: "build-ml",
+ name: "Add a simple ML model",
+ shortDesc: "Train a Logistic Regression classifier on a pre-built workflow.",
+ difficulty: "advanced",
+ estimatedMinutes: 2,
+ prerequisites: ["build-simple"],
+ comingSoon: true,
+ badge: {
+ id: "data-scientist",
+ emoji: "🧪",
+ name: "Data Scientist",
+ description: "Trained your first ML model inside Texera.",
+ hue: "#52c41a",
+ },
+ steps: [],
+};
+
+const HUB_TOUR_FLOW: TutorialFlow = {
+ id: "hub-tour",
+ name: "Tour the Texera hub",
+ shortDesc: "Workflows, datasets, public projects — start here.",
+ difficulty: "intro",
+ estimatedMinutes: 1,
+ comingSoon: true,
+ badge: {
+ id: "first-steps",
+ emoji: "🚀",
+ name: "First Steps",
+ description: "Took the Texera hub orientation tour.",
+ hue: "#1890ff",
+ },
+ steps: [],
+};
+
+export const FLOWS: TutorialFlow[] = [
+ OVERVIEW_WORKSPACE_FLOW,
+ BUILD_SIMPLE_FLOW,
+ BUILD_COMPLEX_FLOW,
+ BUILD_ML_FLOW,
+ HUB_TOUR_FLOW,
+ PUBLIC_WORKFLOW_FLOW,
+];
+
+export function getFlowById(id: string): TutorialFlow | null {
+ return FLOWS.find(f => f.id === id) ?? null;
+}
+
+/** Special meta-badge granted automatically when every other badge is earned. */
+export const GRADUATE_BADGE: BadgeDef = {
+ id: "graduate",
+ emoji: "🎓",
+ name: "Texera Graduate",
+ description: "Completed every tutorial flow — true Texera fluency.",
+ hue: "#722ed1",
+};
+
+/** Bonus badge for fast completions. */
+export const SPEED_RUNNER_BADGE: BadgeDef = {
+ id: "speed-runner",
+ emoji: "⚡",
+ name: "Speed Runner",
+ description: "Finished a flow in under 90 seconds.",
+ hue: "#fa8c16",
+};
+
+export const BONUS_BADGES: BadgeDef[] = [SPEED_RUNNER_BADGE, GRADUATE_BADGE];
diff --git a/frontend/src/app/workspace/service/tutorial/flows/overview-workspace.ts b/frontend/src/app/workspace/service/tutorial/flows/overview-workspace.ts
new file mode 100644
index 00000000000..c7f11f48990
--- /dev/null
+++ b/frontend/src/app/workspace/service/tutorial/flows/overview-workspace.ts
@@ -0,0 +1,348 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import type { TutorialFlow, TutorialStep } from "./index";
+
+/**
+ * Workspace orientation tour with branching detail dives.
+ *
+ * The main spine is 8 steps (Operator panel → 3 toolbar group overviews →
+ * Result panel → Mini-map → AI Agent → Hand-off). Each group overview
+ * offers two ways forward via the popover footer:
+ * - Next → skips ahead to the next main step
+ * - Learn more → drills into the group's individual icons one by one
+ *
+ * The 14 detail steps hide their own progress counter (`showProgress: false`)
+ * so the visible "Step X / 8" only ever reflects spine progress — detail
+ * dives feel like an aside instead of inflating the perceived length.
+ */
+const STEPS: TutorialStep[] = [
+ // 0 · MAIN 1/8
+ {
+ element: "texera-operator-menu",
+ popover: {
+ title: "Operator panel",
+ description: "The building blocks of every workflow live here. Drag them onto the canvas to use them.",
+ side: "right",
+ align: "start",
+ progressText: "Step 1 / 8",
+ },
+ title: "Step 1 — Operator Panel",
+ aiHint: "Orientation: operator menu on the left.",
+ },
+ // 1 · MAIN 2/8 (branch: Next skips to idx 9, Learn more drills in)
+ {
+ element: "#user-buttons",
+ popover: {
+ title: "Toolbar · file actions",
+ description: "Save, import, export, and other workflow-file operations.",
+ side: "bottom",
+ align: "start",
+ progressText: "Step 2 / 8",
+ },
+ title: "Step 2 — Left group overview",
+ aiHint:
+ "Left toolbar group overview. Default Next skips past the 7 file-action details (idx 2-8) to the middle overview; 'Learn more' drills in.",
+ skipToStep: 9,
+ showDetailsButton: true,
+ },
+ // 2 · DETAIL
+ {
+ element: 'button[title="dashboard"]',
+ popover: {
+ title: "Dashboard",
+ description: "Jump back to your workflow list.",
+ side: "bottom",
+ align: "center",
+ popoverClass: "tutorial-no-progress",
+ },
+ title: "Step — Dashboard",
+ aiHint: "Detail (left group): dashboard button.",
+ },
+ // 3 · DETAIL
+ {
+ element: 'button[title="create new"]',
+ popover: {
+ title: "New workflow",
+ description: "Start a blank workflow in a new tab.",
+ side: "bottom",
+ align: "center",
+ popoverClass: "tutorial-no-progress",
+ },
+ title: "Step — New",
+ aiHint: "Detail (left group): create-new button.",
+ },
+ // 4 · DETAIL
+ {
+ element: 'button[title="save"]',
+ popover: {
+ title: "Save",
+ description: "Snapshots the workflow now instead of waiting for the next auto-save.",
+ side: "bottom",
+ align: "center",
+ popoverClass: "tutorial-no-progress",
+ },
+ title: "Step — Save",
+ aiHint: "Detail (left group): save button.",
+ },
+ // 5 · DETAIL
+ {
+ element: 'button[title="delete all"]',
+ popover: {
+ title: "Delete all",
+ description: "Wipe every operator from the canvas — handy when you want a fresh start.",
+ side: "bottom",
+ align: "center",
+ popoverClass: "tutorial-no-progress",
+ },
+ title: "Step — Delete all",
+ aiHint: "Detail (left group): delete-all-operators button.",
+ },
+ // 6 · DETAIL
+ {
+ element: 'button[title="import workflow"]',
+ popover: {
+ title: "Import",
+ description: "Upload a workflow JSON file and load it onto this canvas.",
+ side: "bottom",
+ align: "center",
+ popoverClass: "tutorial-no-progress",
+ },
+ title: "Step — Import",
+ aiHint: "Detail (left group): import-workflow button.",
+ },
+ // 7 · DETAIL
+ {
+ element: 'button[title="export workflow"]',
+ popover: {
+ title: "Export",
+ description: "Download the current workflow as a JSON file.",
+ side: "bottom",
+ align: "center",
+ popoverClass: "tutorial-no-progress",
+ },
+ title: "Step — Export",
+ aiHint: "Detail (left group): export-workflow button.",
+ },
+ // 8 · DETAIL
+ {
+ element: 'button[title="change description"]',
+ popover: {
+ title: "Description",
+ description: "Edit the workflow's description text.",
+ side: "bottom",
+ align: "center",
+ popoverClass: "tutorial-no-progress",
+ },
+ title: "Step — Description",
+ aiHint: "Detail (left group): change-description button.",
+ },
+ // 9 · MAIN 3/8 (branch: Next skips to idx 14, Learn more drills in)
+ {
+ element: "#expanded-utilities nz-space-compact",
+ popover: {
+ title: "Toolbar · canvas utilities",
+ description:
+ "Tools for keeping the canvas tidy as your workflow grows — layers, layout, comments, undo / redo, and more.",
+ side: "bottom",
+ align: "center",
+ progressText: "Step 3 / 8",
+ popoverClass: "tutorial-wide-popover",
+ },
+ title: "Step 3 — Middle group overview",
+ aiHint:
+ "Middle toolbar group overview. Default Next skips past auto-layout/comment/undo/redo (idx 10-13) to the right overview; 'Learn more' drills in.",
+ skipToStep: 14,
+ showDetailsButton: true,
+ },
+ // 10 · DETAIL
+ {
+ element: 'button[title="auto layout"]',
+ popover: {
+ title: "Auto-layout",
+ description: "Rearranges your operators into a tidy left-to-right pipeline.",
+ side: "bottom",
+ align: "center",
+ popoverClass: "tutorial-no-progress",
+ },
+ title: "Step — Auto-layout",
+ aiHint: "Detail (middle group): auto-layout button.",
+ },
+ // 11 · DETAIL
+ {
+ element: 'button[title="add a comment"]',
+ popover: {
+ title: "Add Comment",
+ description: "Drops a sticky-note comment box on the canvas.",
+ side: "bottom",
+ align: "center",
+ popoverClass: "tutorial-no-progress",
+ },
+ title: "Step — Add Comment",
+ aiHint: "Detail (middle group): add-comment button.",
+ },
+ // 12 · DETAIL
+ {
+ element: 'button[title="undo"]',
+ popover: {
+ title: "↶ Undo",
+ description: "Steps back through your last edit.",
+ side: "bottom",
+ align: "center",
+ popoverClass: "tutorial-no-progress",
+ },
+ title: "Step — Undo",
+ aiHint: "Detail (middle group): undo button.",
+ },
+ // 13 · DETAIL
+ {
+ element: 'button[title="redo"]',
+ popover: {
+ title: "↷ Redo",
+ description: "Replays an undone edit.",
+ side: "bottom",
+ align: "center",
+ popoverClass: "tutorial-no-progress",
+ },
+ title: "Step — Redo",
+ aiHint: "Detail (middle group): redo button.",
+ },
+ // 14 · MAIN 4/8 (branch: Next skips to idx 18, Learn more drills in)
+ {
+ element: "#execution-buttons",
+ popover: {
+ title: "Toolbar · run controls",
+ description: "Where you launch and watch your workflow execute.",
+ side: "bottom",
+ align: "end",
+ progressText: "Step 4 / 8",
+ },
+ title: "Step 4 — Right group overview",
+ aiHint:
+ "Right toolbar group overview. Default Next skips past Computing Unit / Share / Run (idx 15-17) to the result panel; 'Learn more' drills in.",
+ skipToStep: 18,
+ showDetailsButton: true,
+ },
+ // 15 · DETAIL
+ {
+ element: "#texera-compute-unit-selection",
+ popover: {
+ title: "Computing Unit",
+ description: "The worker that runs your workflow. Pick one before hitting Run.",
+ side: "bottom",
+ align: "center",
+ popoverClass: "tutorial-no-progress",
+ },
+ title: "Step — Computing Unit",
+ aiHint: "Detail (right group): computing-unit selector.",
+ },
+ // 16 · DETAIL
+ {
+ element: "#share-button",
+ popover: {
+ title: "Share",
+ description: "Invite collaborators to view or co-edit live.",
+ side: "bottom",
+ align: "center",
+ popoverClass: "tutorial-no-progress",
+ },
+ title: "Step — Share",
+ aiHint: "Detail (right group): share button.",
+ },
+ // 17 · DETAIL
+ {
+ element: "#run-button",
+ popover: {
+ title: "▶ Run",
+ description: "Launches your workflow on the selected Computing Unit.",
+ side: "bottom",
+ align: "end",
+ popoverClass: "tutorial-no-progress",
+ },
+ title: "Step — Run",
+ aiHint: "Detail (right group): the Run button.",
+ },
+ // 18 · MAIN 5/8
+ {
+ element: 'li[data-tutorial="open-result-panel"]',
+ popover: {
+ title: "Result panel",
+ description: "After a run, click here to see the rows and charts.",
+ side: "top",
+ align: "start",
+ progressText: "Step 5 / 8",
+ },
+ title: "Step 5 — Result Panel",
+ aiHint: "Orientation: result-panel toggle in the bottom-left.",
+ },
+ // 19 · MAIN 6/8
+ {
+ element: "texera-mini-map",
+ popover: {
+ title: "Mini-map",
+ description: "Bottom-right helper for navigating large workflows.",
+ side: "top",
+ align: "end",
+ progressText: "Step 6 / 8",
+ },
+ title: "Step 6 — Mini-map",
+ aiHint: "Orientation: mini-map widget in the bottom-right.",
+ },
+ // 20 · MAIN 7/8
+ {
+ element: "#agent-docked-button",
+ popover: {
+ title: "🤖 AI Agent",
+ description: "Bottom-right chat button. Ask Texera's AI to help you build, debug, or explain your workflow.",
+ side: "left",
+ align: "center",
+ progressText: "Step 7 / 8",
+ },
+ title: "Step 7 — AI Agent",
+ aiHint: "Orientation: docked AI Agent launcher in the bottom-right.",
+ },
+ // 21 · MAIN 8/8
+ {
+ popover: {
+ title: "Ready to build?",
+ description: "That's the layout. Click Done when you're ready.",
+ progressText: "Step 8 / 8",
+ },
+ title: "Step 8 — Hand-off",
+ aiHint:
+ "Final narration. Done triggers the chain-confirmation popover that asks the user whether to launch build-simple.",
+ },
+];
+
+export const OVERVIEW_WORKSPACE_FLOW: TutorialFlow = {
+ id: "overview-workspace",
+ name: "Workspace overview",
+ shortDesc: "8-step tour of the workspace. Each toolbar group offers an optional deep dive into individual icons.",
+ difficulty: "intro",
+ estimatedMinutes: 2,
+ badge: {
+ id: "explorer",
+ emoji: "🧭",
+ name: "Explorer",
+ description: "Took the workspace overview tour and learned where everything lives.",
+ hue: "#52c41a",
+ },
+ steps: STEPS,
+ chainTo: "build-simple",
+};
diff --git a/frontend/src/app/workspace/service/tutorial/flows/public-workflow.ts b/frontend/src/app/workspace/service/tutorial/flows/public-workflow.ts
new file mode 100644
index 00000000000..3396298004f
--- /dev/null
+++ b/frontend/src/app/workspace/service/tutorial/flows/public-workflow.ts
@@ -0,0 +1,42 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import type { TutorialFlow } from "./index";
+
+/**
+ * Cross-page flow: open a public workflow from the hub, clone it, run it.
+ * Stub for now — needs hub-side selectors and the cross-route resume plumbing.
+ */
+export const PUBLIC_WORKFLOW_FLOW: TutorialFlow = {
+ id: "public-workflow",
+ name: "Run a public workflow",
+ shortDesc: "Browse, clone, and run a community workflow end-to-end.",
+ difficulty: "easy",
+ estimatedMinutes: 2,
+ comingSoon: true,
+ prerequisites: ["hub-tour"],
+ badge: {
+ id: "voyager",
+ emoji: "🔭",
+ name: "Community Voyager",
+ description: "Cloned and ran your first public workflow.",
+ hue: "#eb2f96",
+ },
+ steps: [],
+};
diff --git a/frontend/src/app/workspace/service/tutorial/tutorial-progress.spec.ts b/frontend/src/app/workspace/service/tutorial/tutorial-progress.spec.ts
new file mode 100644
index 00000000000..93675b715bd
--- /dev/null
+++ b/frontend/src/app/workspace/service/tutorial/tutorial-progress.spec.ts
@@ -0,0 +1,94 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { DEFAULT_PROGRESS, loadProgress, saveProgress, TutorialProgress } from "./tutorial-progress";
+
+const STORAGE_KEY = "texera-tutorial-progress";
+const LEGACY_SEEN_KEY = "texera-tutorial-seen";
+
+describe("tutorial-progress", () => {
+ beforeEach(() => {
+ localStorage.removeItem(STORAGE_KEY);
+ localStorage.removeItem(LEGACY_SEEN_KEY);
+ });
+
+ describe("loadProgress", () => {
+ it("returns DEFAULT_PROGRESS when localStorage is empty", () => {
+ expect(loadProgress()).toEqual(DEFAULT_PROGRESS);
+ });
+
+ it("migrates the legacy `texera-tutorial-seen=1` flag into seenWelcome=true", () => {
+ localStorage.setItem(LEGACY_SEEN_KEY, "1");
+
+ const progress = loadProgress();
+
+ expect(progress.seenWelcome).toBe(true);
+ expect(progress.earnedBadges).toEqual(DEFAULT_PROGRESS.earnedBadges);
+ expect(progress.xp).toBe(DEFAULT_PROGRESS.xp);
+ });
+
+ it("returns DEFAULT_PROGRESS when the legacy flag is anything other than '1'", () => {
+ localStorage.setItem(LEGACY_SEEN_KEY, "0");
+
+ expect(loadProgress().seenWelcome).toBe(false);
+ });
+
+ it("resets to defaults but preserves seenWelcome when schema version is stale", () => {
+ const stale = { version: 0, seenWelcome: true, earnedBadges: ["should-be-dropped"] };
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(stale));
+
+ const progress = loadProgress();
+
+ expect(progress.seenWelcome).toBe(true);
+ expect(progress.earnedBadges).toEqual([]);
+ expect(progress.version).toBe(DEFAULT_PROGRESS.version);
+ });
+
+ it("returns DEFAULT_PROGRESS when localStorage holds malformed JSON", () => {
+ localStorage.setItem(STORAGE_KEY, "{not-json");
+
+ expect(loadProgress()).toEqual(DEFAULT_PROGRESS);
+ });
+ });
+
+ describe("saveProgress + loadProgress round-trip", () => {
+ it("persists every field unchanged", () => {
+ const sample: TutorialProgress = {
+ current: { flowId: "overview-workspace", stepIndex: 3 },
+ completed: { "build-simple": { completedAt: 123456, durationMs: 78000 } },
+ earnedBadges: ["explorer", "speed-runner"],
+ xp: 240,
+ microsEarned: { "build-simple": [0, 1, 2] },
+ seenWelcome: true,
+ version: DEFAULT_PROGRESS.version,
+ };
+
+ saveProgress(sample);
+
+ expect(loadProgress()).toEqual(sample);
+ });
+
+ it("survives an unrelated localStorage write between save and load", () => {
+ saveProgress({ ...DEFAULT_PROGRESS, xp: 50 });
+ localStorage.setItem("some-other-key", "noise");
+
+ expect(loadProgress().xp).toBe(50);
+ });
+ });
+});
diff --git a/frontend/src/app/workspace/service/tutorial/tutorial-progress.ts b/frontend/src/app/workspace/service/tutorial/tutorial-progress.ts
new file mode 100644
index 00000000000..91c5d9ba853
--- /dev/null
+++ b/frontend/src/app/workspace/service/tutorial/tutorial-progress.ts
@@ -0,0 +1,75 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export interface TutorialProgress {
+ /** In-flight flow + step. Cleared when the user finishes or fully resets. */
+ current?: { flowId: string; stepIndex: number };
+ /** flowId → completion record. */
+ completed: Record;
+ /** Earned badge ids (includes flow badges + bonus badges like speed-runner / graduate). */
+ earnedBadges: string[];
+ /** Total XP across all flows + replays. */
+ xp: number;
+ /** flowId → step indices that have already fired their micro-reward. Prevents
+ * replays from awarding XP again for already-completed steps. */
+ microsEarned: Record;
+ /** Whether the user has dismissed (or completed) the welcome flow picker. */
+ seenWelcome: boolean;
+ /** Schema version — bump when shape changes; older payloads are reset. */
+ version: number;
+}
+
+const STORAGE_KEY = "texera-tutorial-progress";
+const CURRENT_VERSION = 1;
+
+export const DEFAULT_PROGRESS: TutorialProgress = {
+ completed: {},
+ earnedBadges: [],
+ xp: 0,
+ microsEarned: {},
+ seenWelcome: false,
+ version: CURRENT_VERSION,
+};
+
+export function loadProgress(): TutorialProgress {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) {
+ // Migrate the v1 "seen" key so returning users aren't re-prompted.
+ const legacy = localStorage.getItem("texera-tutorial-seen") === "1";
+ return { ...DEFAULT_PROGRESS, seenWelcome: legacy };
+ }
+ const parsed = JSON.parse(raw) as Partial;
+ if (parsed.version !== CURRENT_VERSION) {
+ // Older schema — reset gracefully but keep the seen flag if present.
+ return { ...DEFAULT_PROGRESS, seenWelcome: !!parsed.seenWelcome };
+ }
+ return { ...DEFAULT_PROGRESS, ...parsed };
+ } catch {
+ return { ...DEFAULT_PROGRESS };
+ }
+}
+
+export function saveProgress(p: TutorialProgress): void {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(p));
+ } catch {
+ /* localStorage may be unavailable in some environments */
+ }
+}
diff --git a/frontend/src/app/workspace/service/tutorial/tutorial.service.ts b/frontend/src/app/workspace/service/tutorial/tutorial.service.ts
new file mode 100644
index 00000000000..0c6ad374ce9
--- /dev/null
+++ b/frontend/src/app/workspace/service/tutorial/tutorial.service.ts
@@ -0,0 +1,576 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Injectable, NgZone, OnDestroy } from "@angular/core";
+import { BehaviorSubject, Subject } from "rxjs";
+import { filter, takeUntil } from "rxjs/operators";
+import { driver, Driver, DriveStep } from "driver.js";
+import { WorkflowActionService } from "../workflow-graph/model/workflow-action.service";
+import { ExecuteWorkflowService } from "../execute-workflow/execute-workflow.service";
+import { ExecutionState } from "../../types/execute-workflow.interface";
+import { ComputingUnitStatusService } from "../../../common/service/computing-unit/computing-unit-status/computing-unit-status.service";
+import { ComputingUnitState } from "../../../common/type/computing-unit-connection.interface";
+import {
+ AutoAdvanceTrigger,
+ BONUS_BADGES,
+ BadgeDef,
+ FLOWS,
+ GRADUATE_BADGE,
+ SPEED_RUNNER_BADGE,
+ TutorialFlow,
+ TutorialStep,
+ getFlowById,
+} from "./flows";
+import { DEFAULT_PROGRESS, TutorialProgress, loadProgress, saveProgress } from "./tutorial-progress";
+const XP_PER_STEP = 10;
+const SPEED_RUNNER_THRESHOLD_MS = 90_000;
+
+export interface BadgeUnlockEvent {
+ badge: BadgeDef;
+ flowName: string;
+}
+
+export type { TutorialStep, TutorialFlow, BadgeDef, AutoAdvanceTrigger };
+
+@Injectable({
+ providedIn: "root",
+})
+export class TutorialService implements OnDestroy {
+ private readonly destroy$ = new Subject();
+ private driverObj: Driver | null = null;
+ private currentFlow: TutorialFlow | null = null;
+ private lastActiveIndex = 0;
+ private flowStartedAt = 0;
+
+ // One-shot DOM click listener for steps with `advanceOnClick: true`.
+ private clickAdvanceEl: Element | null = null;
+ private clickAdvanceHandler: ((ev: Event) => void) | null = null;
+
+ // ===== Reactive state =====
+ private readonly isActiveSubject = new BehaviorSubject(false);
+ public readonly isActive$ = this.isActiveSubject.asObservable();
+
+ private readonly currentStepSubject = new BehaviorSubject(0);
+ public readonly currentStep$ = this.currentStepSubject.asObservable();
+
+ private readonly currentFlowSubject = new BehaviorSubject(null);
+ public readonly currentFlow$ = this.currentFlowSubject.asObservable();
+
+ private readonly completedSubject = new Subject<{ flowId: string }>();
+ public readonly completed$ = this.completedSubject.asObservable();
+
+ private readonly badgeUnlockedSubject = new Subject();
+ public readonly badgeUnlocked$ = this.badgeUnlockedSubject.asObservable();
+
+ private readonly progressSubject = new BehaviorSubject(DEFAULT_PROGRESS);
+ public readonly progress$ = this.progressSubject.asObservable();
+
+ public readonly flows: TutorialFlow[] = FLOWS;
+
+ constructor(
+ private workflowActionService: WorkflowActionService,
+ private executeWorkflowService: ExecuteWorkflowService,
+ private computingUnitStatusService: ComputingUnitStatusService,
+ private ngZone: NgZone
+ ) {
+ this.progressSubject.next(loadProgress());
+ this.wireEventListeners();
+ }
+
+ // ===== Getters =====
+
+ get isActive(): boolean {
+ return this.isActiveSubject.getValue();
+ }
+
+ get currentStepIndex(): number {
+ return this.currentStepSubject.getValue();
+ }
+
+ get currentStep(): TutorialStep | null {
+ if (!this.isActive || !this.currentFlow) return null;
+ return this.currentFlow.steps[this.currentStepIndex] ?? null;
+ }
+
+ get currentFlowSnapshot(): TutorialFlow | null {
+ return this.currentFlow;
+ }
+
+ get stepCount(): number {
+ return this.currentFlow?.steps.length ?? 0;
+ }
+
+ get progress(): TutorialProgress {
+ return this.progressSubject.getValue();
+ }
+
+ // ===== Flow control =====
+
+ /** Start a flow. Re-runs of completed flows act as replays (no second badge, sparkles still fire). */
+ start(flowId: string, startAt: number = 0): void {
+ const flow = getFlowById(flowId);
+ if (!flow) {
+ // eslint-disable-next-line no-console
+ console.warn(`[tutorial] unknown flow "${flowId}"`);
+ return;
+ }
+ if (flow.comingSoon || flow.steps.length === 0) {
+ return;
+ }
+
+ if (this.driverObj) {
+ this.driverObj.destroy();
+ this.driverObj = null;
+ }
+ this.resetOperatorMenuCategories();
+ const safeStart = Math.max(0, Math.min(startAt, flow.steps.length - 1));
+ this.currentFlow = flow;
+ this.currentFlowSubject.next(flow);
+ this.lastActiveIndex = safeStart;
+ this.flowStartedAt = Date.now();
+ this.currentStepSubject.next(safeStart);
+ this.isActiveSubject.next(true);
+
+ // Persist the start so a refresh mid-tour can offer to resume.
+ this.updateProgress(p => ({ ...p, current: { flowId, stepIndex: safeStart } }));
+
+ const driverSteps: DriveStep[] = flow.steps.map(step => {
+ // skipToStep redirects the default Next button. driver.js exposes onNextClick on Popover, not DriveStep.
+ const popover =
+ typeof step.skipToStep === "number"
+ ? { ...step.popover, onNextClick: () => this.driverObj?.moveTo(step.skipToStep!) }
+ : step.popover;
+ return {
+ element: step.element,
+ popover,
+ };
+ });
+
+ // Skip prebuilt-workflow load on resume so we don't wipe the user's mid-tour canvas.
+ if (flow.prebuiltWorkflow && safeStart === 0) {
+ this.loadPrebuiltWorkflow(flow.prebuiltWorkflow)
+ .catch(err => {
+ // eslint-disable-next-line no-console
+ console.warn(`[tutorial] failed to load prebuilt workflow "${flow.prebuiltWorkflow}":`, err);
+ })
+ .finally(() => this.launchDriver(driverSteps, safeStart));
+ return;
+ }
+
+ this.launchDriver(driverSteps, safeStart);
+ }
+
+ /** Load a fixture from `assets/tutorial-workflows/`. Resolves after JointJS has had a tick to paint. */
+ private loadPrebuiltWorkflow(name: string): Promise {
+ return fetch(`assets/tutorial-workflows/${name}.json`)
+ .then(r => {
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
+ return r.json();
+ })
+ .then(workflow => {
+ this.ngZone.run(() => this.workflowActionService.reloadWorkflow(workflow));
+ // Wait one paint tick for JointJS so spotlight rects measure correctly.
+ return new Promise(resolve => setTimeout(resolve, 150));
+ });
+ }
+
+ private launchDriver(driverSteps: DriveStep[], safeStart: number): void {
+ // Construct driver.js outside Angular's zone — its resize / scroll / mutation listeners would
+ // otherwise re-run change detection on every event and make ng-zorro tooltips/waves jitter.
+ this.ngZone.runOutsideAngular(() => {
+ this.driverObj = driver({
+ showProgress: true,
+ allowClose: true,
+ overlayClickBehavior: () => {
+ /* no-op: clicking the dim overlay does nothing */
+ },
+ animate: false,
+ smoothScroll: true,
+ stagePadding: 16,
+ stageRadius: 8,
+ disableActiveInteraction: false,
+ overlayColor: "#000",
+ overlayOpacity: 0.65,
+ nextBtnText: "Next →",
+ prevBtnText: "← Back",
+ doneBtnText: "All done! 🎉",
+ progressText: "Step {{current}} / {{total}}",
+ steps: driverSteps,
+ onHighlightStarted: (el, _step, opts) => this.handleHighlight(el, opts),
+ onDestroyed: () => this.handleDestroyed(),
+ });
+ this.driverObj.drive(safeStart);
+ });
+ }
+
+ /** Continue an in-progress flow (resume from saved stepIndex). */
+ resume(): boolean {
+ const cur = this.progress.current;
+ if (!cur) return false;
+ const flow = getFlowById(cur.flowId);
+ if (!flow || flow.comingSoon || flow.steps.length === 0) return false;
+ this.start(cur.flowId, cur.stepIndex);
+ return true;
+ }
+
+ next(): void {
+ this.driverObj?.moveNext();
+ }
+
+ previous(): void {
+ this.driverObj?.movePrevious();
+ }
+
+ skip(): void {
+ this.driverObj?.destroy();
+ }
+
+ restart(): void {
+ if (this.currentFlow) this.start(this.currentFlow.id);
+ }
+
+ markWelcomeSeen(): void {
+ this.updateProgress(p => ({ ...p, seenWelcome: true }));
+ }
+
+ /** Wipe all progress — useful for demos / workshops. */
+ resetProgress(): void {
+ this.progressSubject.next({ ...DEFAULT_PROGRESS });
+ saveProgress(this.progressSubject.getValue());
+ }
+
+ // ===== Private helpers =====
+
+ /** Collapse every expanded operator-menu category so "Click Data Input to open the category" makes sense regardless of prior state. */
+ private resetOperatorMenuCategories(): void {
+ const menu = document.querySelector("texera-operator-menu");
+ if (!menu) return;
+ menu.querySelectorAll(".ant-collapse-item-active > .ant-collapse-header").forEach(header => {
+ (header as HTMLElement).click();
+ });
+ }
+
+ /** Inject a "Learn more" button next to Next on group-overview steps. Idempotent. */
+ private injectShowDetailsButton(stepIdx: number): void {
+ const popover = document.querySelector(".driver-popover");
+ if (!popover || popover.querySelector(".tutorial-show-details-btn")) return;
+
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "tutorial-show-details-btn";
+ btn.textContent = "Learn more";
+ btn.addEventListener("click", () => {
+ if (this.currentStepIndex !== stepIdx) return;
+ this.driverObj?.moveNext();
+ });
+
+ // Prefer sitting in the footer alongside Next/Skip; fall back to the
+ // description block if driver.js's footer markup changes in the future.
+ const navBtns = popover.querySelector(".driver-popover-navigation-btns");
+ if (navBtns) {
+ navBtns.insertBefore(btn, navBtns.firstChild);
+ return;
+ }
+ const description = popover.querySelector(".driver-popover-description");
+ description?.appendChild(btn);
+ }
+
+ /** True if the box is inside the viewport AND every ancestor scroll container's clip rect. */
+ private isFullyVisible(el: HTMLElement): boolean {
+ const r = el.getBoundingClientRect();
+ if (r.top < 0 || r.left < 0 || r.bottom > window.innerHeight || r.right > window.innerWidth) {
+ return false;
+ }
+ let p: HTMLElement | null = el.parentElement;
+ while (p && p !== document.body) {
+ const style = getComputedStyle(p);
+ const scrollsY = /auto|scroll|hidden|clip/.test(style.overflowY);
+ const scrollsX = /auto|scroll|hidden|clip/.test(style.overflowX);
+ if (scrollsY || scrollsX) {
+ const pr = p.getBoundingClientRect();
+ if (
+ (scrollsY && (r.top < pr.top || r.bottom > pr.bottom)) ||
+ (scrollsX && (r.left < pr.left || r.right > pr.right))
+ ) {
+ return false;
+ }
+ }
+ p = p.parentElement;
+ }
+ return true;
+ }
+
+ private updateProgress(updater: (p: TutorialProgress) => TutorialProgress): void {
+ const next = updater(this.progressSubject.getValue());
+ this.progressSubject.next(next);
+ saveProgress(next);
+ }
+
+ private handleHighlight(el: Element | undefined, opts: { state?: { activeIndex?: number } } | undefined): void {
+ const idx = opts?.state?.activeIndex ?? 0;
+ this.lastActiveIndex = idx;
+ this.ngZone.run(() => this.currentStepSubject.next(idx));
+ this.detachClickAdvance();
+ const step = this.currentFlow?.steps[idx];
+ if (!step || !this.currentFlow) return;
+
+ // Collapse expanded operator-menu categories before highlighting a TOP-LEVEL category, so prior
+ // expansions don't push the target down. Skip for nested sub-groups (the parent must stay open,
+ // otherwise the sub-group rect goes 0×0 and auto-skip jumps past the step). Refresh after the
+ // ~300ms collapse animation since driver.js measures synchronously.
+ if (typeof step.element === "string" && /nz-collapse-panel\[data-group-name=/.test(step.element)) {
+ const target = document.querySelector(step.element) as HTMLElement | null;
+ const isTopLevel = target?.getAttribute("data-depth") === "0";
+ if (isTopLevel) {
+ this.resetOperatorMenuCategories();
+ setTimeout(() => this.driverObj?.refresh(), 350);
+ }
+ }
+
+ // Persist the new position so refresh mid-tour resumes here.
+ this.updateProgress(p => ({
+ ...p,
+ current: { flowId: this.currentFlow!.id, stepIndex: idx },
+ }));
+
+ if (step.showDetailsButton) {
+ setTimeout(() => this.injectShowDetailsButton(idx), 0);
+ }
+
+ // Scroll only when not already fully visible — keeps fixed toolbar icons flicker-free.
+ if (el && typeof (el as HTMLElement).scrollIntoView === "function") {
+ if (!this.isFullyVisible(el as HTMLElement)) {
+ (el as HTMLElement).scrollIntoView({ block: "center", inline: "nearest" });
+ setTimeout(() => this.driverObj?.refresh(), 60);
+ }
+ }
+
+ // Auto-skip steps whose target is missing / zero-size. Skip the final step (moveNext ends the tour).
+ const isLastStep = idx === this.currentFlow.steps.length - 1;
+ if (typeof step.element === "string" && !isLastStep) {
+ const selector = step.element;
+ setTimeout(() => {
+ if (this.currentStepIndex !== idx || !this.driverObj) return;
+ const reEl = document.querySelector(selector) as HTMLElement | null;
+ const rect = reEl?.getBoundingClientRect();
+ const missing = !reEl || !rect || (rect.width === 0 && rect.height === 0);
+ if (missing) {
+ this.ngZone.run(() => this.driverObj?.moveNext());
+ }
+ }, 250);
+ }
+
+ // Fire a micro-reward (sparkles + XP) — but only the first time the user
+ // hits this step. Replays don't keep stacking XP.
+ this.fireMicroRewardIfFirstTime(idx);
+
+ if (!el && step.element) {
+ // eslint-disable-next-line no-console
+ console.warn(`[tutorial] step ${idx + 1} highlight: NO ELEMENT MATCHED for`, step.element);
+ }
+ if (step.advanceOnClick && el) this.attachClickAdvance(el);
+ }
+
+ private handleDestroyed(): void {
+ this.detachClickAdvance();
+ this.ngZone.run(() => {
+ const flow = this.currentFlow;
+ const wasOnLast = flow ? this.lastActiveIndex === flow.steps.length - 1 : false;
+ this.driverObj = null;
+ this.isActiveSubject.next(false);
+ this.currentFlowSubject.next(null);
+ if (wasOnLast && flow) this.finalizeFlowCompletion(flow);
+ else {
+ // User skipped mid-flow — keep `current` so the resume toast can fire later.
+ }
+ this.currentFlow = null;
+ });
+ }
+
+ private finalizeFlowCompletion(flow: TutorialFlow): void {
+ const durationMs = Date.now() - this.flowStartedAt;
+ const isFirstCompletion = !this.progress.completed[flow.id];
+
+ this.updateProgress(p => {
+ const completed = { ...p.completed, [flow.id]: { completedAt: Date.now(), durationMs } };
+ const earned = new Set(p.earnedBadges);
+ const newlyEarned: BadgeDef[] = [];
+
+ if (isFirstCompletion && !earned.has(flow.badge.id)) {
+ earned.add(flow.badge.id);
+ newlyEarned.push(flow.badge);
+ }
+ // Speed runner bonus
+ if (durationMs < SPEED_RUNNER_THRESHOLD_MS && !earned.has(SPEED_RUNNER_BADGE.id)) {
+ earned.add(SPEED_RUNNER_BADGE.id);
+ newlyEarned.push(SPEED_RUNNER_BADGE);
+ }
+ // Graduate bonus — only when every primary flow badge is earned.
+ const flowBadgeIds = FLOWS.map(f => f.badge.id);
+ const hasAllFlowBadges = flowBadgeIds.every(id => earned.has(id));
+ if (hasAllFlowBadges && !earned.has(GRADUATE_BADGE.id)) {
+ earned.add(GRADUATE_BADGE.id);
+ newlyEarned.push(GRADUATE_BADGE);
+ }
+
+ // Fire badge-unlocked notifications after the state has been written.
+ setTimeout(() => {
+ newlyEarned.forEach(badge => this.badgeUnlockedSubject.next({ badge, flowName: flow.name }));
+ }, 0);
+
+ return {
+ ...p,
+ completed,
+ earnedBadges: Array.from(earned),
+ current: undefined,
+ };
+ });
+
+ this.completedSubject.next({ flowId: flow.id });
+ }
+
+ private fireMicroRewardIfFirstTime(stepIdx: number): void {
+ if (!this.currentFlow) return;
+ const flowId = this.currentFlow.id;
+ const microsForFlow = this.progress.microsEarned[flowId] ?? [];
+ if (microsForFlow.includes(stepIdx)) return; // already rewarded
+
+ this.updateProgress(p => ({
+ ...p,
+ xp: p.xp + XP_PER_STEP,
+ microsEarned: {
+ ...p.microsEarned,
+ [flowId]: [...(p.microsEarned[flowId] ?? []), stepIdx],
+ },
+ }));
+ this.spawnSparkleBurst();
+ }
+
+ /** Pure-DOM sparkle + XP burst, anchored top-right so it never overlaps the spotlight. */
+ private spawnSparkleBurst(): void {
+ const burst = document.createElement("div");
+ burst.className = "tutorial-sparkle-burst";
+
+ // Bias particle trajectories to fly DOWN-LEFT (into the workspace),
+ // not up-right (off-screen) since the burst sits in the top-right corner.
+ const angles = [110, 140, 170, 200, 230, 260];
+ angles.forEach((angle, i) => {
+ const radius = 18 + Math.random() * 8;
+ const dx = Math.cos((angle * Math.PI) / 180) * radius;
+ const dy = Math.sin((angle * Math.PI) / 180) * radius;
+ const sp = document.createElement("span");
+ sp.className = "sparkle-particle";
+ sp.textContent = i % 2 === 0 ? "✦" : "✧";
+ sp.style.setProperty("--dx", `${dx}px`);
+ sp.style.setProperty("--dy", `${dy}px`);
+ sp.style.animationDelay = `${i * 30}ms`;
+ burst.appendChild(sp);
+ });
+
+ const xp = document.createElement("div");
+ xp.className = "xp-float";
+ xp.textContent = `+${XP_PER_STEP} XP`;
+ burst.appendChild(xp);
+
+ document.body.appendChild(burst);
+ setTimeout(() => burst.remove(), 1300);
+ }
+
+ private attachClickAdvance(el: Element): void {
+ this.clickAdvanceEl = el;
+ this.clickAdvanceHandler = () => {
+ setTimeout(() => this.ngZone.run(() => this.driverObj?.moveNext()), 500);
+ };
+ el.addEventListener("click", this.clickAdvanceHandler, { once: true });
+ }
+
+ private detachClickAdvance(): void {
+ if (this.clickAdvanceEl && this.clickAdvanceHandler) {
+ this.clickAdvanceEl.removeEventListener("click", this.clickAdvanceHandler);
+ }
+ this.clickAdvanceEl = null;
+ this.clickAdvanceHandler = null;
+ }
+
+ private autoAdvanceIfMatches(trigger: AutoAdvanceTrigger): void {
+ if (!this.isActive || !this.driverObj || !this.currentFlow) return;
+ const step = this.currentFlow.steps[this.currentStepIndex];
+ if (step?.autoAdvanceOn !== trigger) return;
+ // When a drag-to-canvas finishes, fold the operator menu category back
+ // immediately — waiting until the next category step is too late: the
+ // category visually stays open during the entire intermediate steps
+ // (click operator, open property panel, configure, ...).
+ if (trigger === "operatorAdded") {
+ this.resetOperatorMenuCategories();
+ }
+ setTimeout(() => this.ngZone.run(() => this.driverObj?.moveNext()), 700);
+ }
+
+ private wireEventListeners(): void {
+ this.workflowActionService
+ .getTexeraGraph()
+ .getOperatorAddStream()
+ .pipe(takeUntil(this.destroy$))
+ .subscribe(() => this.autoAdvanceIfMatches("operatorAdded"));
+
+ this.workflowActionService
+ .getTexeraGraph()
+ .getLinkAddStream()
+ .pipe(takeUntil(this.destroy$))
+ .subscribe(() => this.autoAdvanceIfMatches("linkAdded"));
+
+ this.workflowActionService
+ .getTexeraGraph()
+ .getOperatorPropertyChangeStream()
+ .pipe(takeUntil(this.destroy$))
+ .subscribe(() => this.autoAdvanceIfMatches("propertyChanged"));
+
+ this.workflowActionService
+ .getJointGraphWrapper()
+ .getJointOperatorHighlightStream()
+ .pipe(
+ filter(ids => ids.length > 0),
+ takeUntil(this.destroy$)
+ )
+ .subscribe(() => this.autoAdvanceIfMatches("operatorSelected"));
+
+ this.executeWorkflowService
+ .getExecutionStateStream()
+ .pipe(
+ filter(({ current }) => current.state === ExecutionState.Running || current.state === ExecutionState.Completed),
+ takeUntil(this.destroy$)
+ )
+ .subscribe(() => this.autoAdvanceIfMatches("executionStarted"));
+
+ this.computingUnitStatusService
+ .getStatus()
+ .pipe(
+ filter(state => state === ComputingUnitState.Running),
+ takeUntil(this.destroy$)
+ )
+ .subscribe(() => this.autoAdvanceIfMatches("computingUnitConnected"));
+ }
+
+ ngOnDestroy(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
+ this.detachClickAdvance();
+ this.driverObj?.destroy();
+ }
+}
diff --git a/frontend/src/assets/tutorial-workflows/movies-filter.json b/frontend/src/assets/tutorial-workflows/movies-filter.json
new file mode 100644
index 00000000000..5a00a1acab0
--- /dev/null
+++ b/frontend/src/assets/tutorial-workflows/movies-filter.json
@@ -0,0 +1,102 @@
+{
+ "name": "Tutorial — Movies (filtered)",
+ "description": "Pre-built starter workflow used by the chart-building tour. CSV → Filter(year > 1960).",
+ "wid": null,
+ "creationTime": null,
+ "lastModifiedTime": null,
+ "isPublished": 0,
+ "readonly": false,
+ "content": {
+ "operators": [
+ {
+ "operatorID": "CSVFileScan-operator-171d5ebd-a2cb-4a8c-997c-135eae5a4978",
+ "operatorType": "CSVFileScan",
+ "operatorVersion": "N/A",
+ "operatorProperties": {
+ "fileEncoding": "UTF_8",
+ "customDelimiter": ",",
+ "hasHeader": true,
+ "fileName": "/texera/test/v1/movies (1).csv"
+ },
+ "inputPorts": [],
+ "outputPorts": [
+ {
+ "portID": "output-0",
+ "displayName": "",
+ "disallowMultiInputs": false,
+ "isDynamicPort": false
+ }
+ ],
+ "showAdvanced": false,
+ "isDisabled": false,
+ "customDisplayName": "CSV File Scan",
+ "dynamicInputPorts": false,
+ "dynamicOutputPorts": false
+ },
+ {
+ "operatorID": "Filter-operator-83c55041-a9f9-4a0c-8ceb-c9b63e5e27bc",
+ "operatorType": "Filter",
+ "operatorVersion": "N/A",
+ "operatorProperties": {
+ "predicates": [
+ {
+ "attribute": "year",
+ "condition": ">",
+ "value": "1960"
+ }
+ ]
+ },
+ "inputPorts": [
+ {
+ "portID": "input-0",
+ "displayName": "",
+ "disallowMultiInputs": false,
+ "isDynamicPort": false,
+ "dependencies": []
+ }
+ ],
+ "outputPorts": [
+ {
+ "portID": "output-0",
+ "displayName": "",
+ "disallowMultiInputs": false,
+ "isDynamicPort": false
+ }
+ ],
+ "showAdvanced": false,
+ "isDisabled": false,
+ "customDisplayName": "Filter",
+ "dynamicInputPorts": false,
+ "dynamicOutputPorts": false
+ }
+ ],
+ "operatorPositions": {
+ "CSVFileScan-operator-171d5ebd-a2cb-4a8c-997c-135eae5a4978": {
+ "x": 350,
+ "y": 200
+ },
+ "Filter-operator-83c55041-a9f9-4a0c-8ceb-c9b63e5e27bc": {
+ "x": 500,
+ "y": 200
+ }
+ },
+ "links": [
+ {
+ "linkID": "link-3d2aef7b-0cb5-49b4-9811-71f74e871841",
+ "source": {
+ "operatorID": "CSVFileScan-operator-171d5ebd-a2cb-4a8c-997c-135eae5a4978",
+ "portID": "output-0"
+ },
+ "target": {
+ "operatorID": "Filter-operator-83c55041-a9f9-4a0c-8ceb-c9b63e5e27bc",
+ "portID": "input-0"
+ }
+ }
+ ],
+ "commentBoxes": [],
+ "settings": {
+ "dataTransferBatchSize": 400,
+ "executionMode": "PIPELINED"
+ }
+ }
+}
diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss
index 5f4a00952a9..c060d3b8815 100644
--- a/frontend/src/styles.scss
+++ b/frontend/src/styles.scss
@@ -19,6 +19,13 @@
@import "@ali-hm/angular-tree-component/css/angular-tree-component.css";
+/* Tutorial mode — driver.js popover styling, body.driver-active overrides,
+ sparkle-burst animation. All rules are gated by `body.driver-active` or
+ the `.tutorial-*` / `.driver-popover.tutorial-*` namespace, so they have
+ no effect when no tour is running. See the partial for why these rules
+ must live at the global root rather than in component stylesheets. */
+@import "tutorial-driver-overrides";
+
.ant-menu-item,
.ant-menu-submenu-title,
img {
diff --git a/frontend/yarn.lock b/frontend/yarn.lock
index 6a4ae4330c4..699d39f1ad8 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -6002,6 +6002,13 @@ __metadata:
languageName: node
linkType: hard
+"@types/canvas-confetti@npm:^1.9.0":
+ version: 1.9.0
+ resolution: "@types/canvas-confetti@npm:1.9.0"
+ checksum: 10c0/ffe2c674d466b8e13472c81ab2a97056f3433fd40a3513dbc1bb76764e4e7c3ff0a2a58d37b16ea6a245c831077c553db321b069dda6572eab59f2be61137b2e
+ languageName: node
+ linkType: hard
+
"@types/chai@npm:^5.2.2":
version: 5.2.3
resolution: "@types/chai@npm:5.2.3"
@@ -8224,6 +8231,13 @@ __metadata:
languageName: node
linkType: hard
+"canvas-confetti@npm:^1.9.4":
+ version: 1.9.4
+ resolution: "canvas-confetti@npm:1.9.4"
+ checksum: 10c0/08f11b5fa20b365922108828d7488f9b3eac2afdf39bc478339268c1250b5215df82682d7074520a3f0f72f4728f9703bbc937dcfe793f196b44e6e8ac0b1f7b
+ languageName: node
+ linkType: hard
+
"case-anything@npm:^2.1.13":
version: 2.1.13
resolution: "case-anything@npm:2.1.13"
@@ -9401,6 +9415,13 @@ __metadata:
languageName: node
linkType: hard
+"driver.js@npm:^1.4.0":
+ version: 1.4.0
+ resolution: "driver.js@npm:1.4.0"
+ checksum: 10c0/f868e85037d4180d55a883ec5bdad7a59e3b16e84438af4285ad605cab651b21fe565839a1607992362b0c028309d4ae6d052926d1c4ab67b2c62142a579b529
+ languageName: node
+ linkType: hard
+
"dunder-proto@npm:1.0.1, dunder-proto@npm:^1.0.1":
version: 1.0.1
resolution: "dunder-proto@npm:1.0.1"
@@ -11035,6 +11056,7 @@ __metadata:
"@nx/angular": "npm:22.7.0"
"@schematics/angular": "npm:21.2.8"
"@types/backbone": "npm:1.4.15"
+ "@types/canvas-confetti": "npm:^1.9.0"
"@types/concaveman": "npm:1.1.6"
"@types/d3-shape": "npm:2.1.2"
"@types/dagre": "npm:0.7.47"
@@ -11057,10 +11079,12 @@ __metadata:
"@vitest/coverage-v8": "npm:4.1.5"
ai: "npm:5.0.93"
ajv: "npm:8.10.0"
+ canvas-confetti: "npm:^1.9.4"
concaveman: "npm:2.0.0"
concurrently: "npm:7.4.0"
d3-shape: "npm:2.1.0"
dagre: "npm:0.8.5"
+ driver.js: "npm:^1.4.0"
eslint: "npm:8.57.0"
eslint-plugin-rxjs: "npm:5.0.3"
eslint-plugin-rxjs-angular: "npm:2.0.1"