diff --git a/.github/workflows/package-build-check.yml b/.github/workflows/package-build-check.yml
new file mode 100644
index 00000000..c027f9f4
--- /dev/null
+++ b/.github/workflows/package-build-check.yml
@@ -0,0 +1,53 @@
+name: Package build check
+
+on:
+ pull_request:
+ paths:
+ - 'apps/dashboard/**'
+ - 'apps/api/**'
+ - 'packages/**'
+ - 'pnpm-lock.yaml'
+ - 'package.json'
+ - 'turbo.json'
+ - '.nvmrc'
+ - '.github/workflows/package-build-check.yml'
+ push:
+ branches:
+ - main
+ paths:
+ - 'apps/dashboard/**'
+ - 'apps/api/**'
+ - 'packages/**'
+ - 'pnpm-lock.yaml'
+ - 'package.json'
+ - 'turbo.json'
+ - '.nvmrc'
+ - '.github/workflows/package-build-check.yml'
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'pnpm'
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Prepare dashboard env
+ run: cp apps/dashboard/.env.example apps/dashboard/.env
+
+ - name: Build dashboard
+ run: pnpm --filter @cio/dashboard^... build && pnpm --filter @cio/dashboard build
+
+ - name: Build API
+ run: pnpm --filter @cio/api^... build && pnpm --filter @cio/api build
diff --git a/AGENTS.md b/AGENTS.md
index 67252006..f47f2431 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -20,6 +20,29 @@ When a task requires factual information (API specifications, context window siz
- Commit messages must follow the Conventional Commits 1.0.0 spec: https://www.conventionalcommits.org/en/v1.0.0/
- Never include agent-provider attribution in commits or commit trailers. Do not add `Co-authored-by` lines for Cursor, Claude, Codex, or any other tool.
+## Verification before commit/PR
+
+Before committing or opening/updating a PR, run the build checks that match the areas you changed. Use Node 20 (the repo's `.nvmrc` version):
+
+```bash
+export PATH="$HOME/.nvm/versions/node/v20.19.3/bin:$PATH"
+```
+
+- **Dashboard changes** (`apps/dashboard/**`, or shared packages consumed by the dashboard):
+
+ ```bash
+ test -f apps/dashboard/.env || cp apps/dashboard/.env.example apps/dashboard/.env
+ pnpm --filter @cio/dashboard^... build && pnpm --filter @cio/dashboard build
+ ```
+
+- **API changes** (`apps/api/**`, or shared packages consumed by the API):
+
+ ```bash
+ pnpm --filter @cio/api^... build && pnpm --filter @cio/api build
+ ```
+
+Also run `pnpm format:check` (see Translation, Formatting, and Git Workflow above). Do not commit if any verification step fails.
+
## Naming Convention
- Use kebab-case for files (e.g. `user-profile.svelte`, `org.svelte.ts`).
diff --git a/apps/dashboard/src/lib/features/course/components/lesson/content-navigation-actions.svelte b/apps/dashboard/src/lib/features/course/components/lesson/content-navigation-actions.svelte
index 0b6c8300..9ec965d8 100644
--- a/apps/dashboard/src/lib/features/course/components/lesson/content-navigation-actions.svelte
+++ b/apps/dashboard/src/lib/features/course/components/lesson/content-navigation-actions.svelte
@@ -7,8 +7,10 @@
import { Button } from '@cio/ui/base/button';
import * as Tooltip from '@cio/ui/base/tooltip';
import { PercentRingProgress } from '@cio/ui/custom/percent-ring-progress';
+ import { get } from 'svelte/store';
import { isCourseLearnerView } from '$lib/utils/store/app';
import { getStudentContentLockReason } from '$features/ai-assistant/utils/content-ask-ai-bar';
+ import { getStudentContentLockTitleKey } from '$features/course/utils/content-lock-utils';
import { t } from '$lib/utils/functions/translations';
import { courseApi, lessonApi } from '$features/course/api';
import { getOrderedNavigableContent, getContentRoute } from '$features/course/utils/content';
@@ -50,16 +52,21 @@
};
});
+ function getNavigableLockReason(target: CourseContentItem | null) {
+ if (!target) return null;
+
+ const targetType = target.type === ContentType.Lesson ? ContentType.Lesson : ContentType.Exercise;
+ return getStudentContentLockReason(courseApi.course, target.id, targetType);
+ }
+
function goToContent(target: CourseContentItem | null) {
if (!target) return;
- if ($isCourseLearnerView) {
- const targetType = target.type === ContentType.Lesson ? ContentType.Lesson : ContentType.Exercise;
- const lockReason = getStudentContentLockReason(courseApi.course, target.id, targetType);
+ const lockReason = getNavigableLockReason(target);
- if (lockReason) {
- return;
- }
+ if (get(isCourseLearnerView) && lockReason) {
+ snackbar.error(getStudentContentLockTitleKey(lockReason));
+ return;
}
const courseIdResolved = courseApi.course?.id;
@@ -71,6 +78,20 @@
const isPrevDisabled = $derived(!prevNextContent.prev);
const isNextDisabled = $derived(!prevNextContent.next);
+ const prevNavLockReason = $derived($isCourseLearnerView ? getNavigableLockReason(prevNextContent.prev) : null);
+ const nextNavLockReason = $derived($isCourseLearnerView ? getNavigableLockReason(prevNextContent.next) : null);
+ const isPrevNavBlocked = $derived(Boolean(prevNavLockReason));
+ const isNextNavBlocked = $derived(Boolean(nextNavLockReason));
+ const prevNavTooltip = $derived(
+ prevNavLockReason
+ ? $t(getStudentContentLockTitleKey(prevNavLockReason))
+ : $t('course.navItem.lessons.prev_shortcut')
+ );
+ const nextNavTooltip = $derived(
+ nextNavLockReason
+ ? $t(getStudentContentLockTitleKey(nextNavLockReason))
+ : $t('course.navItem.lessons.next_shortcut')
+ );
const isLessonComplete = $derived.by(() => {
if (!lessonId) return false;
const lesson = lessonItems.find((l) => l.id === lessonId);
@@ -145,7 +166,7 @@
updateCourseContentCompletion(currentLessonId, isComplete);
const allComplete =
- $isOrgStudent &&
+ get(isCourseLearnerView) &&
isComplete &&
navigableContentItems.length > 0 &&
navigableContentItems.every((item) => item.isComplete);
@@ -231,14 +252,14 @@
size="icon-sm"
variant="outline"
onclick={() => goToContent(prevNextContent.prev)}
- disabled={isPrevDisabled}
+ disabled={isPrevDisabled || isPrevNavBlocked}
aria-label={$t('course.navItem.lessons.prev')}
>
- {$t('course.navItem.lessons.prev_shortcut')}
+ {prevNavTooltip}
@@ -248,14 +269,14 @@
size="icon-sm"
variant="outline"
onclick={() => goToContent(prevNextContent.next)}
- disabled={isNextDisabled}
+ disabled={isNextDisabled || isNextNavBlocked}
aria-label={$t('course.navItem.lessons.next')}
>
- {$t('course.navItem.lessons.next_shortcut')}
+ {nextNavTooltip}