diff --git a/backend/alembic/versions/f2b7c9d1e4a6_normalize_progress_status_enum_values.py b/backend/alembic/versions/f2b7c9d1e4a6_normalize_progress_status_enum_values.py new file mode 100644 index 0000000..94f2e41 --- /dev/null +++ b/backend/alembic/versions/f2b7c9d1e4a6_normalize_progress_status_enum_values.py @@ -0,0 +1,76 @@ +"""Normalize progress status enum values + +Revision ID: f2b7c9d1e4a6 +Revises: d4e5f6a7b8c9 +Create Date: 2026-07-02 08:45:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision = "f2b7c9d1e4a6" +down_revision = "d4e5f6a7b8c9" +branch_labels = None +depends_on = None + + +def _rename_progress_status_values(pairs: list[tuple[str, str]]) -> None: + conn = op.get_bind() + for old_value, new_value in pairs: + conn.execute( + sa.text( + """ + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_enum e + JOIN pg_type t ON t.oid = e.enumtypid + WHERE t.typname = 'progress_status_enum' + AND e.enumlabel = :old_value + ) + AND NOT EXISTS ( + SELECT 1 + FROM pg_enum e + JOIN pg_type t ON t.oid = e.enumtypid + WHERE t.typname = 'progress_status_enum' + AND e.enumlabel = :new_value + ) THEN + EXECUTE format( + 'ALTER TYPE progress_status_enum RENAME VALUE %L TO %L', + :old_value, + :new_value + ); + END IF; + END $$; + """ + ), + {"old_value": old_value, "new_value": new_value}, + ) + + +def upgrade() -> None: + _rename_progress_status_values( + [ + ("NOT_STARTED", "not_started"), + ("IN_PROGRESS", "in_progress"), + ("SKIPPED", "skipped"), + ("DELIVERED", "delivered"), + ("COMPLETED", "completed"), + ] + ) + + +def downgrade() -> None: + _rename_progress_status_values( + [ + ("not_started", "NOT_STARTED"), + ("in_progress", "IN_PROGRESS"), + ("skipped", "SKIPPED"), + ("delivered", "DELIVERED"), + ("completed", "COMPLETED"), + ] + ) diff --git a/backend/app/seed_demo.py b/backend/app/seed_demo.py index 24b8b27..7ab5cfa 100644 --- a/backend/app/seed_demo.py +++ b/backend/app/seed_demo.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from datetime import datetime, timedelta from sqlalchemy import or_ @@ -40,6 +41,7 @@ StudentCourse, StudentProgramProgress, StudentUnitProgress, + StorybookPage, Thread, Unit, User, @@ -49,6 +51,66 @@ DEMO_PASSWORD = "password" DEMO_ORG_NAME = "EchoEd Demo School" DEMO_COURSE_TITLE = "Introduction to Africa" +DEMO_STORYBOOK_BASE_URL = "http://127.0.0.1:8000/storybook" +DEMO_COLORINGS_BASE_URL = "http://127.0.0.1:8000/colorings" +DEMO_MAP_REFERENCE_URL = "/assets/maps/africa-reference.svg" +DEMO_WORLD_CONTEXT_URL = "/assets/maps/world-context.svg" +DEMO_STORYBOOK_PAGES = [ + "08ef622b-2b8a-41f0-8e43-74f4ee11d216.png", + "1ca7b568-f7eb-4877-bd9e-22919ec90a36.png", + "268b14ab-3b76-4153-84ac-5238658d1f72.png", + "1d7d06af-ad31-4ac4-ba83-82a720079317.png", +] +DEMO_COLORING_PAGE = "4b4d875a-0f94-4408-9048-ab69d013d957.png" +DEMO_AFRICA_MAP_EXPLORER = { + "title": "Africa Regions Explorer", + "prompt": "Tap each region to see a map-reading clue. Look for coastlines, deserts, rivers, highlands, and routes that connect people across the continent.", + "contextImageUrl": DEMO_WORLD_CONTEXT_URL, + "referenceImageUrl": DEMO_MAP_REFERENCE_URL, + "referenceImageAlt": "Africa reference map with five regions labeled", + "regions": [ + { + "id": "north", + "label": "North Africa", + "fact": "North Africa sits near the Mediterranean coast and the Sahara. It connects African, Arab, and Mediterranean history.", + "focus": ["coastline", "desert edge", "trade routes"], + "x": 50, + "y": 23, + }, + { + "id": "west", + "label": "West Africa", + "fact": "West Africa is a place of rivers, markets, languages, and strong trading traditions across many communities.", + "focus": ["river systems", "market routes", "languages"], + "x": 33, + "y": 53, + }, + { + "id": "central", + "label": "Central Africa", + "fact": "Central Africa includes forested regions and river networks that help people travel, farm, and stay connected.", + "focus": ["forest", "river", "community"], + "x": 51, + "y": 53, + }, + { + "id": "east", + "label": "East Africa", + "fact": "East Africa includes highlands, lakes, and routes that link inland communities with the coast and the wider region.", + "focus": ["highlands", "lakes", "routes"], + "x": 68, + "y": 50, + }, + { + "id": "south", + "label": "Southern Africa", + "fact": "Southern Africa has coastlines, plains, and mountain regions where people have long built rich community life.", + "focus": ["coastline", "plains", "mountains"], + "x": 55, + "y": 83, + }, + ], +} LEGACY_DEMO_COURSE_TITLES = { DEMO_COURSE_TITLE, "K-5 Introduction to Africa", @@ -104,22 +166,53 @@ } ], "activities": [ + { + "type": "image", + "title": "Africa in the World", + "order": 1, + "content": DEMO_WORLD_CONTEXT_URL, + }, + { + "type": "interactive_map", + "title": "Africa Regions Explorer", + "order": 2, + "content": json.dumps(DEMO_AFRICA_MAP_EXPLORER), + }, + { + "type": "storybook", + "title": "Meet Children of Africa Storybook", + "order": 3, + "content": "Read the picture story first. Notice the children, places, and details that show Africa as full of many communities and everyday stories.", + "pages": [ + { + "image_url": f"{DEMO_STORYBOOK_BASE_URL}/{filename}", + "order": index + 1, + } + for index, filename in enumerate(DEMO_STORYBOOK_PAGES) + ], + }, + { + "type": "coloring", + "title": "Living Places Coloring Page", + "order": 4, + "content": f"{DEMO_COLORINGS_BASE_URL}/{DEMO_COLORING_PAGE}", + }, { "type": "story", "title": "Map Window", - "order": 1, + "order": 5, "content": "Imagine opening a giant classroom map and noticing that Africa stretches across many regions, climates, and communities. As you read, look for clues that show Africa is a continent full of many stories rather than a single place.", }, { "type": "quiz", "title": "Key Concept Check", - "order": 2, + "order": 6, "content": "{\"question\":\"Which word best describes Africa?\",\"options\":[\"A single country\",\"A diverse continent\",\"Only a desert\"]}", }, { "type": "reflection", "title": "Place and Perspective Reflection", - "order": 3, + "order": 7, "content": "Write or say one new idea you learned about Africa and one reason it matters to learn about places with care and accuracy.", }, ], @@ -155,22 +248,41 @@ } ], "activities": [ + { + "type": "storybook", + "title": "Regions and Rivers Storybook", + "order": 1, + "content": "Read this storybook before the map work. Notice how children, homes, and places connect across Africa as communities move along rivers and routes.", + "pages": [ + { + "image_url": f"{DEMO_STORYBOOK_BASE_URL}/{filename}", + "order": index + 1, + } + for index, filename in enumerate(DEMO_STORYBOOK_PAGES) + ], + }, + { + "type": "coloring", + "title": "River Communities Coloring Page", + "order": 2, + "content": f"{DEMO_COLORINGS_BASE_URL}/{DEMO_COLORING_PAGE}", + }, { "type": "story", "title": "River Journey Prompt", - "order": 1, + "order": 3, "content": "Picture a family traveling beside a great river. What might they see, grow, carry, or trade along the way? Use the lesson clues to imagine the journey.", }, { "type": "checkpoint", "title": "Geography Exploration Prompt", - "order": 2, + "order": 4, "content": "Point to one river or route on your class map and explain how it could help people move, gather food, or share ideas.", }, { "type": "reflection", "title": "Community Connection", - "order": 3, + "order": 5, "content": "How do rivers, roads, or paths help communities in your world stay connected? Share one comparison with the lesson.", }, ], @@ -1660,7 +1772,31 @@ def _sync_lesson_sources(db, lesson: Lesson, sources: list[dict[str, str]]) -> N db.delete(stale_source) -def _sync_lesson_activities(db, lesson: Lesson, activities: list[dict[str, str | int]]) -> None: +def _sync_storybook_pages(db, activity: Activity, pages: list[dict[str, object]]) -> None: + existing_pages = sorted( + list(activity.storybook_pages or []), + key=lambda page: ( + page.order is None, + page.order if page.order is not None else 0, + str(page.id), + ), + ) + + for index, page_seed in enumerate(pages): + if index < len(existing_pages): + page = existing_pages[index] + else: + page = StorybookPage(activity=activity) + db.add(page) + + page.image_url = str(page_seed["image_url"]) + page.order = int(page_seed.get("order") or index + 1) + + for stale_page in existing_pages[len(pages) :]: + db.delete(stale_page) + + +def _sync_lesson_activities(db, lesson: Lesson, activities: list[dict[str, object]]) -> None: existing_activities = sorted( list(lesson.activities or []), key=lambda activity: ( @@ -1681,6 +1817,7 @@ def _sync_lesson_activities(db, lesson: Lesson, activities: list[dict[str, str | activity.title = str(activity_seed["title"]) activity.content = str(activity_seed["content"]) activity.order = int(activity_seed["order"]) + _sync_storybook_pages(db, activity, list(activity_seed.get("pages") or [])) for stale_activity in existing_activities[len(activities) :]: db.delete(stale_activity) diff --git a/backend/tests/test_demo_seed_phase1.py b/backend/tests/test_demo_seed_phase1.py index 14e4a45..c202e13 100644 --- a/backend/tests/test_demo_seed_phase1.py +++ b/backend/tests/test_demo_seed_phase1.py @@ -92,6 +92,41 @@ def test_demo_seed_creates_canonical_org_users_and_flagship_relationships(db_eng seeded_assessment_titles = {seed["title"] for seed in seed_demo.DEMO_FLAGSHIP_ASSESSMENTS} actual_assessment_titles = {row[0] for row in session.query(Assessment.title).all()} assert seeded_assessment_titles.issubset(actual_assessment_titles) + + intro_course = session.query(Course).filter(Course.title == seed_demo.DEMO_COURSE_TITLE).one() + regions_lesson = next( + lesson + for unit in intro_course.units + for lesson in unit.lessons + if lesson.title == "Regions, Rivers, and Routes" + ) + regions_lesson_activities = sorted(regions_lesson.activities, key=lambda activity: activity.order or 0) + assert [activity.type for activity in regions_lesson_activities[:2]] == ["storybook", "coloring"] + + intro_lesson = next( + lesson + for unit in intro_course.units + for lesson in unit.lessons + if lesson.title == "Introduction to Africa" + ) + intro_lesson_activities = sorted(intro_lesson.activities, key=lambda activity: activity.order or 0) + assert [activity.type for activity in intro_lesson_activities[:4]] == [ + "image", + "interactive_map", + "storybook", + "coloring", + ] + storybook_activity = next(activity for activity in intro_lesson_activities if activity.type == "storybook") + assert [page.order for page in sorted(storybook_activity.storybook_pages, key=lambda page: page.order or 0)] == [1, 2, 3, 4] + assert [ + page.image_url.rsplit("/", 1)[-1] + for page in sorted(storybook_activity.storybook_pages, key=lambda page: page.order or 0) + ] == [ + "08ef622b-2b8a-41f0-8e43-74f4ee11d216.png", + "1ca7b568-f7eb-4877-bd9e-22919ec90a36.png", + "268b14ab-3b76-4153-84ac-5238658d1f72.png", + "1d7d06af-ad31-4ac4-ba83-82a720079317.png", + ] finally: session.close() diff --git a/backend/tests/test_flagship_pathway_phase1.py b/backend/tests/test_flagship_pathway_phase1.py index 9c560c4..78e847b 100644 --- a/backend/tests/test_flagship_pathway_phase1.py +++ b/backend/tests/test_flagship_pathway_phase1.py @@ -50,7 +50,7 @@ def test_flagship_lessons_include_ordered_meaningful_existing_activities(db_engi session_factory = _build_session_factory(db_engine) _seed_with_factory(monkeypatch, session_factory) - allowed_types = {"story", "reflection", "checkpoint", "quiz"} + allowed_types = {"image", "interactive_map", "storybook", "coloring", "story", "reflection", "checkpoint", "quiz"} session = session_factory() try: diff --git a/frontend/angular.json b/frontend/angular.json index 95af24f..03b903d 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -29,6 +29,11 @@ { "glob": "**/*", "input": "public" + }, + { + "glob": "**/*", + "input": "src/assets", + "output": "assets" } ], "styles": [ @@ -91,6 +96,11 @@ { "glob": "**/*", "input": "public" + }, + { + "glob": "**/*", + "input": "src/assets", + "output": "assets" } ], "styles": [ diff --git a/frontend/src/app/app.routes.spec.ts b/frontend/src/app/app.routes.spec.ts index a4404da..f5ad093 100644 --- a/frontend/src/app/app.routes.spec.ts +++ b/frontend/src/app/app.routes.spec.ts @@ -8,6 +8,7 @@ import { WorkspaceDashboardComponent } from './pages/workspace-dashboard/workspa import { DemoReadinessComponent } from './pages/demo-readiness/demo-readiness.component'; import { PublicProductsComponent } from './pages/public-products/public-products.component'; import { PublicProductDetailComponent } from './pages/public-products/public-product-detail.component'; +import { RoleGuard } from './guards/role.guard'; function findRoute(routeList: Routes, path: string): Route | undefined { const exactRoute = routeList.find(candidate => candidate.path === path); @@ -69,6 +70,10 @@ describe('app routes', () => { ].forEach(path => expect(findRoute(routes, path)).withContext(path).toBeTruthy()); expect(findRoute(routes, 'workspace/')?.component).toBe(WorkspaceDashboardComponent); expect(findRoute(routes, 'workspace/demo-readiness')?.component).toBe(DemoReadinessComponent); + expect(findRoute(routes, 'workspace/')?.canActivate).toContain(RoleGuard); + expect(findRoute(routes, 'workspace/products')?.canActivate).toContain(RoleGuard); + expect(findRoute(routes, 'workspace/products/:productId')?.canActivate).toContain(RoleGuard); + expect(findRoute(routes, 'workspace/learners')?.canActivate).toContain(RoleGuard); }); it('adds learner portal aliases without removing existing learner pages', () => { diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 135f81f..ceb609b 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -136,6 +136,8 @@ export const routes: Routes = [ { path: '', component: WorkspaceDashboardComponent, + canActivate: [RoleGuard], + data: { roles: creatorRoles } }, { path: 'projects', @@ -208,7 +210,9 @@ export const routes: Routes = [ { path: 'products', component: V2CollectionPageComponent, + canActivate: [RoleGuard], data: { + roles: creatorRoles, eyebrow: 'Products', title: 'Product catalog', status: 'Real V2 product wrappers. Course-backed products link to the existing education runtime.', @@ -219,6 +223,8 @@ export const routes: Routes = [ { path: 'products/:productId', component: ProductDetailComponent, + canActivate: [RoleGuard], + data: { roles: creatorRoles } }, { path: 'products/manage', @@ -273,6 +279,8 @@ export const routes: Routes = [ { path: 'learners', component: UserDashboardComponent, + canActivate: [RoleGuard], + data: { roles: creatorRoles }, children: [ { path: '', component: EchoedRoleSelectorComponent }, { path: 'products', component: AvailableCoursesComponent }, @@ -316,7 +324,12 @@ export const routes: Routes = [ canActivate: [RoleGuard], data: { roles: creatorRoles } }, - { path: 'settings', component: PreferencesComponent }, + { + path: 'settings', + component: PreferencesComponent, + canActivate: [RoleGuard], + data: { roles: creatorRoles } + }, { path: 'settings/invites', component: OrgInvitesComponent, diff --git a/frontend/src/app/components/africa-map-explorer/africa-map-explorer.component.ts b/frontend/src/app/components/africa-map-explorer/africa-map-explorer.component.ts new file mode 100644 index 0000000..0e94682 --- /dev/null +++ b/frontend/src/app/components/africa-map-explorer/africa-map-explorer.component.ts @@ -0,0 +1,132 @@ +import { Component, Input } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +export interface AfricaMapRegion { + id: string; + label: string; + fact: string; + focus?: string[]; + x: number; + y: number; +} + +export interface AfricaMapExplorerData { + title?: string; + prompt?: string; + contextImageUrl?: string; + referenceImageUrl?: string; + referenceImageAlt?: string; + regions?: AfricaMapRegion[]; +} + +@Component({ + selector: 'app-africa-map-explorer', + standalone: true, + imports: [CommonModule], + template: ` +
+
+
+
+

+ {{ data?.title || 'Africa map explorer' }} +

+

+ Explore Africa by region +

+
+ Interactive map +
+

+ {{ data?.prompt }} +

+
+
+

World context

+ +
+ +
+

Reference map

+
+ + +
+
+
+
+ +
+
+

Regions

+
+ +
+
+ +
+

Map reading note

+
+ {{ selectedRegion?.label || 'Choose a region' }} +
+

+ {{ selectedRegion?.fact || 'Select a region to see one map-reading clue and a question to discuss.' }} +

+
    +
  • + {{ item }} +
  • +
+
+
+
+ `, +}) +export class AfricaMapExplorerComponent { + @Input() data: AfricaMapExplorerData | null = null; + + get regions(): AfricaMapRegion[] { + return this.data?.regions || []; + } + + get contextImageUrl(): string { + return this.data?.contextImageUrl || '/assets/maps/world-context.svg'; + } + + get referenceImageUrl(): string { + return this.data?.referenceImageUrl || '/assets/maps/africa-reference.svg'; + } + + selectedRegion: AfricaMapRegion | null = null; + + selectRegion(regionId: string): void { + this.selectedRegion = this.regions.find((region) => region.id === regionId) || null; + } +} diff --git a/frontend/src/app/components/demo-tour-overlay/demo-tour-overlay.component.html b/frontend/src/app/components/demo-tour-overlay/demo-tour-overlay.component.html index 8e72203..0b349c1 100644 --- a/frontend/src/app/components/demo-tour-overlay/demo-tour-overlay.component.html +++ b/frontend/src/app/components/demo-tour-overlay/demo-tour-overlay.component.html @@ -1,15 +1,15 @@
-
+
-

Demo tour {{ stepIndex }} / {{ totalSteps }}

+

Demo tour {{ stepIndex }} / {{ totalSteps }}

@if (step) {

{{ step.title }}

-

{{ step.description }}

+

{{ step.description }}

}
- Guided investor-ready walkthrough + Guided investor-ready walkthrough +

{{ message }}

-
+

Existing grants

- +
-
No access grants found.
-
+
No access grants found.Create a manual grant when a learner should receive product access.
+

{{ productTitle(grant.product_id) }}

{{ grant.user_id }}

+ {{ label(grant.status) }}
@@ -83,31 +94,25 @@ import { V2PlatformService } from '../../services/v2-platform.service';
{{ grant.source }}
- +
`, styles: [` - .access { color: #102033; display: grid; gap: 1rem; padding: clamp(1rem, 3vw, 2rem); } - header, .panel { background: #fff; border: 1px solid #d8e1ea; border-radius: 8px; box-shadow: 0 14px 32px rgba(16,32,51,.08); padding: clamp(1rem, 3vw, 1.5rem); } - header p { color: #0f766e; font-size: .76rem; font-weight: 900; letter-spacing: .12em; margin: 0 0 .35rem; text-transform: uppercase; } h1, h2, h3 { letter-spacing: 0; margin: 0; } h1 { font-size: clamp(2rem, 5vw, 3rem); line-height: 1; } - header span, .message, .empty, .grant p, dd { color: #526273; } + .message, .empty, .grant p, dd { color: var(--ee-text-soft); } form.panel { display: grid; gap: .85rem; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); } - form h2, form button, .message { grid-column: 1 / -1; } - label { color: #334155; display: grid; font-weight: 900; gap: .35rem; } - input, select { border: 1px solid #cbd5e1; border-radius: 6px; color: #102033; font: inherit; padding: .65rem .75rem; } - button { background: #102033; border: 1px solid #102033; border-radius: 6px; color: #fff; cursor: pointer; font: inherit; font-weight: 900; padding: .65rem .85rem; width: fit-content; } - button:disabled { background: #e2e8f0; border-color: #cbd5e1; color: #64748b; cursor: default; } + form .panel__heading, form button, .message { grid-column: 1 / -1; } + button { cursor: pointer; width: fit-content; } + button:disabled { opacity: .58; cursor: default; } .panel { display: grid; gap: .9rem; } .panel__heading { align-items: center; display: flex; justify-content: space-between; gap: 1rem; } - .grant { border: 1px solid #d8e1ea; border-radius: 8px; display: grid; gap: .75rem; padding: 1rem; } + .grant { display: grid; gap: .75rem; } dl { display: grid; gap: .55rem; grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); margin: 0; } - dt { color: #334155; font-size: .75rem; font-weight: 900; text-transform: uppercase; } + dt { color: var(--ee-text-muted); font-size: .75rem; font-weight: 900; text-transform: uppercase; } dd { margin: .15rem 0 0; } - input:focus-visible, select:focus-visible, button:focus-visible { outline: 3px solid rgba(15,118,110,.25); outline-offset: 3px; } `] }) export class AccessGrantsComponent implements OnInit { diff --git a/frontend/src/app/pages/commercial-dashboard/commercial-dashboard.component.ts b/frontend/src/app/pages/commercial-dashboard/commercial-dashboard.component.ts index df93665..510cadc 100644 --- a/frontend/src/app/pages/commercial-dashboard/commercial-dashboard.component.ts +++ b/frontend/src/app/pages/commercial-dashboard/commercial-dashboard.component.ts @@ -55,7 +55,7 @@ import { V2PlatformService } from '../../services/v2-platform.service'; No billing execution
-
{{ section }}Placeholder configuration area for future commercialization.
+
{{ section }}Placeholder configuration area for future commercialization.
@@ -68,8 +68,9 @@ import { V2PlatformService } from '../../services/v2-platform.service'; Products
-
+
+ {{ label(product.status) }}

{{ product.title }}

{{ label(product.visibility || 'draft') }} - {{ label(product.pricing_model || 'internal') }} - {{ label(product.product_type) }}

@@ -82,12 +83,13 @@ import { V2PlatformService } from '../../services/v2-platform.service'; styles: [` h2, h3 { margin: 0; } dl { display: grid; gap: .55rem; margin: 0; } - dl div { border-top: 1px solid #edf2f7; display: grid; gap: .2rem; padding-top: .55rem; } - dt { color: #334155; font-size: .72rem; font-weight: 900; text-transform: uppercase; } - dd { color: #526273; margin: 0; } + dl div { border-top: 1px solid var(--ee-border); display: grid; gap: .2rem; padding-top: .55rem; } + dt { color: var(--ee-text-muted); font-size: .72rem; font-weight: 900; text-transform: uppercase; } + dd { color: var(--ee-text-soft); margin: 0; } .settings { display: grid; gap: .75rem; grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); } - .settings article { border: 1px solid #d8e1ea; border-radius: 8px; display: grid; gap: .3rem; padding: .9rem; } - .settings span, p { color: #526273; } + .settings article { background: rgba(255,255,255,.045); border: 1px solid var(--ee-border); border-radius: 18px; display: grid; gap: .3rem; padding: .9rem; } + .settings strong, h3 { color: var(--ee-text); } + .settings span, p { color: var(--ee-text-soft); } `] }) export class CommercialDashboardComponent implements OnInit { diff --git a/frontend/src/app/pages/home/home.component.html b/frontend/src/app/pages/home/home.component.html index 1313cdf..e9e02a3 100644 --- a/frontend/src/app/pages/home/home.component.html +++ b/frontend/src/app/pages/home/home.component.html @@ -1,4 +1,4 @@ -
+
-
-
+
+
-

Workspace: AI-powered knowledge products

-

Use the V2 shell to move between products, learner delivery, review workflows, and platform settings.

+

Workspace OS

+

Move between product creation, knowledge intake, review governance, learner access, analytics, and settings.

-
-
+
diff --git a/frontend/src/app/pages/home/home.component.scss b/frontend/src/app/pages/home/home.component.scss index e2d35f7..155270d 100644 --- a/frontend/src/app/pages/home/home.component.scss +++ b/frontend/src/app/pages/home/home.component.scss @@ -27,6 +27,14 @@ isolation: isolate; } +.ee-shell { + background: + radial-gradient(circle at 16% 8%, rgba(15, 118, 110, 0.1), transparent 28rem), + radial-gradient(circle at 88% 12%, rgba(2, 132, 199, 0.08), transparent 30rem), + linear-gradient(135deg, #ffffff, #f8fafc 48%, #eef6ff); + color: var(--ee-text); +} + .home-shell--lesson { grid-template-columns: minmax(0, 1fr); } @@ -44,6 +52,52 @@ min-width: 0; } +.ee-shell__workspace { + max-width: 1440px; +} + +.ee-shell__surface, +.ee-command-strip { + background: #ffffff; + border: 1px solid rgba(15, 23, 42, 0.12); + border-radius: 24px; + box-shadow: 0 14px 36px rgba(15, 23, 42, 0.08); + backdrop-filter: none; +} + +.ee-shell__surface { + min-height: calc(100vh - 12rem); + padding: 0.35rem; +} + +.ee-command-strip { + overflow: hidden; + position: relative; +} + +.ee-command-strip::after { + background: linear-gradient(90deg, var(--ee-purple), var(--ee-gold), var(--ee-azure)); + content: ''; + height: 1px; + inset: auto 1.25rem 0; + position: absolute; +} + +.ee-command-strip__eyebrow { + color: var(--echo-primary); + font-size: 0.72rem; + font-weight: 900; + letter-spacing: 0.18em; + margin: 0; + text-transform: uppercase; +} + +.ee-command-strip__copy { + color: var(--ee-text-soft); + font-size: 0.95rem; + margin: 0.25rem 0 0; +} + .home-shell__content:focus { outline: none; } diff --git a/frontend/src/app/pages/landing/landing.component.scss b/frontend/src/app/pages/landing/landing.component.scss index 7627a3a..35446e6 100644 --- a/frontend/src/app/pages/landing/landing.component.scss +++ b/frontend/src/app/pages/landing/landing.component.scss @@ -4,13 +4,12 @@ .landing-shell { background: - radial-gradient(circle at 82% 8%, rgba(137, 44, 119, 0.55), transparent 20rem), - radial-gradient(circle at 54% 38%, rgba(56, 28, 118, 0.5), transparent 28rem), - linear-gradient(180deg, rgba(5, 8, 30, 0.1) 0 58%, rgba(255, 169, 76, 0.18) 59%, rgba(11, 7, 20, 0.96) 72%), - linear-gradient(135deg, #05091d, #09072a 46%, #16072a); - border: 1px solid rgba(128, 151, 224, 0.44); + radial-gradient(circle at 82% 8%, rgba(15, 118, 110, 0.08), transparent 20rem), + radial-gradient(circle at 54% 38%, rgba(2, 132, 199, 0.06), transparent 28rem), + linear-gradient(135deg, #ffffff, #f7f9fc 46%, #eef4fb); + border: 1px solid rgba(15, 23, 42, 0.08); border-radius: 18px; - color: #f8f4ff; + color: var(--ee-text, #102033); display: grid; gap: 2rem; margin: min(1.6vw, 1.75rem); @@ -30,16 +29,16 @@ .landing-shell::before { background: - radial-gradient(circle at 18% 16%, rgba(255, 255, 255, 0.6) 0 1px, transparent 2px), - radial-gradient(circle at 74% 9%, rgba(255, 188, 120, 0.8) 0 1px, transparent 2px), - linear-gradient(160deg, transparent 0 37%, rgba(134, 77, 214, 0.2) 38%, transparent 48%); - opacity: 0.5; + radial-gradient(circle at 18% 16%, rgba(15, 118, 110, 0.12) 0 1px, transparent 2px), + radial-gradient(circle at 74% 9%, rgba(2, 132, 199, 0.1) 0 1px, transparent 2px), + linear-gradient(160deg, transparent 0 37%, rgba(15, 118, 110, 0.04) 38%, transparent 48%); + opacity: 0.35; } .landing-shell::after { background: - radial-gradient(ellipse at 50% 73%, rgba(244, 128, 67, 0.52), transparent 11rem), - linear-gradient(180deg, transparent 63%, rgba(6, 7, 18, 0.32) 64%, #050713 84%); + radial-gradient(ellipse at 50% 73%, rgba(15, 118, 110, 0.05), transparent 11rem), + linear-gradient(180deg, transparent 63%, rgba(255, 255, 255, 0.24) 64%, #ffffff 84%); } .landing-nav, @@ -70,16 +69,16 @@ } .brand { - color: #fff; + color: var(--ee-text, #102033); gap: 0.7rem; text-decoration: none; } .brand span { - background: linear-gradient(135deg, #6d4be8, #261b5e); - border: 1px solid rgba(185, 166, 255, 0.24); + background: var(--echo-primary, #0f766e); + border: 1px solid rgba(15, 118, 110, 0.24); border-radius: 10px; - box-shadow: 0 10px 24px rgba(89, 50, 212, 0.32); + box-shadow: 0 10px 24px rgba(15, 118, 110, 0.18); display: grid; font-size: 0.9rem; font-weight: 900; @@ -101,14 +100,14 @@ .nav-cta, .button, .tour-link { - color: #fff; + color: var(--ee-text, #102033); font-size: 0.88rem; font-weight: 800; text-decoration: none; } .nav-links a::after { - content: 'v'; + content: 'â–¾'; font-size: 0.7rem; margin-left: 0.4rem; } @@ -131,16 +130,16 @@ .ghost-link, .button--secondary { - background: rgba(12, 12, 38, 0.58); - border: 1px solid rgba(213, 202, 255, 0.35); + background: #ffffff; + border: 1px solid rgba(15, 23, 42, 0.12); } .nav-cta, .button--primary { - background: linear-gradient(135deg, #ffe28e, #f3ae3d); - border: 1px solid rgba(255, 230, 160, 0.8); - box-shadow: 0 16px 32px rgba(240, 160, 52, 0.24); - color: #160d22; + background: var(--echo-primary, #0f766e); + border: 1px solid rgba(15, 118, 110, 0.24); + box-shadow: 0 16px 32px rgba(15, 118, 110, 0.18); + color: #ffffff; } .hero { @@ -159,7 +158,7 @@ .eyebrow { align-items: center; - color: #c4a9ff; + color: var(--echo-primary, #0f766e); display: flex; font-size: 0.78rem; font-weight: 900; @@ -184,7 +183,7 @@ p { } h1 { - color: #fff; + color: #102033; font-size: clamp(3.4rem, 4.35vw, 5rem); letter-spacing: 0; line-height: 0.93; @@ -192,12 +191,12 @@ h1 { } h1 em { - color: #c49cff; + color: var(--echo-primary, #0f766e); font-style: normal; } .hero-lede { - color: #eee9ff; + color: #475569; font-size: 1.05rem; line-height: 1.65; max-width: 58ch; @@ -216,7 +215,7 @@ h1 em { } .tour-link { - color: #f5bd63; + color: var(--echo-secondary, #2563eb); width: fit-content; } @@ -224,10 +223,10 @@ h1 em { .feature-grid article, .demo-section, .demo-card { - background: linear-gradient(145deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.035)); - backdrop-filter: blur(24px) saturate(1.2); - border: 1px solid rgba(184, 197, 255, 0.18); - box-shadow: 0 24px 70px rgba(0, 0, 0, 0.34); + background: #ffffff; + backdrop-filter: none; + border: 1px solid rgba(15, 23, 42, 0.08); + box-shadow: 0 18px 42px rgba(15, 23, 42, 0.08); } .feature-panel { @@ -245,7 +244,7 @@ h1 em { .panel-header span, .card-kicker { - color: #b69aff; + color: var(--echo-secondary, #2563eb); font-size: 0.75rem; font-weight: 900; letter-spacing: 0.12em; @@ -254,9 +253,9 @@ h1 em { .panel-header strong, .audience-row span { - border: 1px solid rgba(246, 178, 75, 0.58); + border: 1px solid rgba(15, 118, 110, 0.18); border-radius: 999px; - color: #f6bd64; + color: #0f766e; font-size: 0.78rem; padding: 0.35rem 0.65rem; } @@ -268,8 +267,8 @@ h1 em { } .signal-grid article { - background: rgba(255, 255, 255, 0.045); - border: 1px solid rgba(184, 197, 255, 0.14); + background: #f8fafc; + border: 1px solid rgba(15, 23, 42, 0.08); border-radius: 10px; display: grid; gap: 0.35rem; @@ -281,10 +280,10 @@ h1 em { .feature-icon, .roadmap-list span, .trust-strip > span { - background: linear-gradient(135deg, #7047d9, #2b225c); - border: 1px solid rgba(195, 172, 255, 0.25); + background: linear-gradient(135deg, #0f766e, #2563eb); + border: 1px solid rgba(15, 118, 110, 0.25); border-radius: 10px; - box-shadow: 0 10px 24px rgba(90, 62, 200, 0.26); + box-shadow: 0 10px 24px rgba(15, 118, 110, 0.18); } .mini-icon { @@ -295,7 +294,7 @@ h1 em { .signal-grid strong, .trust-strip strong, .demo-card h3 { - color: #ffd36c; + color: #102033; font-size: 1.08rem; } @@ -305,13 +304,14 @@ h1 em { .demo-section p, .demo-card li, .roadmap-list p { - color: #e5ddff; + color: #475569; line-height: 1.5; } .trust-strip { align-items: start; - background: rgba(255, 255, 255, 0.045); + background: #f8fafc; + border: 1px solid rgba(15, 23, 42, 0.08); border-radius: 14px; display: grid; gap: 0.8rem; @@ -356,7 +356,7 @@ h1 em { } .feature-grid h2 { - color: #fff; + color: #102033; font-size: 1.1rem; line-height: 1.2; } @@ -376,7 +376,7 @@ h1 em { } .demo-heading h2 { - color: #fff; + color: #102033; font-size: clamp(1.8rem, 4vw, 3rem); line-height: 1; margin: 0.5rem 0; @@ -443,7 +443,7 @@ h1 em { } .roadmap-list strong { - color: #c4a9ff; + color: #102033; font-size: 1.1rem; } diff --git a/frontend/src/app/pages/lesson-view.component.html b/frontend/src/app/pages/lesson-view.component.html index 291496c..d26f2c2 100644 --- a/frontend/src/app/pages/lesson-view.component.html +++ b/frontend/src/app/pages/lesson-view.component.html @@ -14,8 +14,8 @@

{{ lesson?.title {{ lessonActivityCount }} activitiesy - - {{ lesson?.duration_minutes }} min + + {{ lesson.duration_minutes }} min

diff --git a/frontend/src/app/pages/lesson-view.component.spec.ts b/frontend/src/app/pages/lesson-view.component.spec.ts index 01543c8..0e9f0ce 100644 --- a/frontend/src/app/pages/lesson-view.component.spec.ts +++ b/frontend/src/app/pages/lesson-view.component.spec.ts @@ -1,5 +1,6 @@ import { CommonModule } from '@angular/common'; import { ActivatedRoute } from '@angular/router'; +import { Router } from '@angular/router'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; import { of, Subject, throwError } from 'rxjs'; @@ -18,6 +19,7 @@ class MockCoursesService { markSegmentCompleted = jasmine.createSpy('markSegmentCompleted'); getCourses = jasmine.createSpy('getCourses'); getCourseById = jasmine.createSpy('getCourseById'); + getStudentCourses = jasmine.createSpy('getStudentCourses'); } describe('LessonViewComponent', () => { @@ -112,4 +114,82 @@ describe('LessonViewComponent', () => { expect(compiled.textContent).toContain('Retry'); expect(compiled.textContent).not.toContain('Course completed!'); }); + + it('recovers from a stale segment id by restoring the active student course progress', async () => { + const coursesService = TestBed.inject(CoursesService) as unknown as MockCoursesService; + const router = TestBed.inject(Router); + spyOn(router, 'navigate').and.resolveTo(true); + + coursesService.getCurrentSegment.and.returnValues( + throwError(() => ({ status: 404 })), + of({ + delivery_state: 'governed_available', + lesson_id: 'lesson-2', + unit_progress_id: 'student-unit-2', + }), + ); + coursesService.getStudentCourses.and.returnValue(of([ + { + unit_progress_id: 'student-unit-2', + course: { title: 'Introduction to Africa' }, + }, + ] as any)); + coursesService.getLessonById.and.returnValue(of({ + id: 'lesson-2', + title: 'Recovered lesson', + activities: [], + } as any)); + + fixture = TestBed.createComponent(LessonViewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + expect(coursesService.getCurrentSegment).toHaveBeenCalledWith('student-unit-1'); + expect(coursesService.getCurrentSegment).toHaveBeenCalledWith('student-unit-2'); + expect(router.navigate).toHaveBeenCalledWith(['../', 'student-unit-2'], { relativeTo: TestBed.inject(ActivatedRoute) }); + expect(component.loadErrorMessage).toBe(''); + expect(component.lesson?.title).toBe('Recovered lesson'); + }); + + it('prefers the Introduction to Africa course for demo lessons', async () => { + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [CommonModule, RouterTestingModule, LessonViewComponent], + providers: [ + { provide: CoursesService, useClass: MockCoursesService }, + { + provide: ActivatedRoute, + useValue: { + snapshot: { + paramMap: { + get: () => 'demo', + }, + }, + }, + }, + ], + }).compileComponents(); + + const demoCoursesService = TestBed.inject(CoursesService) as unknown as MockCoursesService; + demoCoursesService.getCourses.and.returnValue(of([ + { id: 'course-a', title: 'C', standards_metadata: {} }, + { id: 'course-b', title: 'Introduction to Africa', standards_metadata: { pathway_key: 'introduction-to-africa' } }, + ] as any)); + demoCoursesService.getCourseById.and.returnValue(of({ + units: [{ + lessons: [{ + id: 'lesson-1', + title: 'Introduction to Africa', + activities: [ + { title: 'Storybook', type: 'storybook', content: '', pages: [] }, + ], + }], + }], + } as any)); + + const demoFixture = TestBed.createComponent(LessonViewComponent); + demoFixture.detectChanges(); + + expect(demoCoursesService.getCourseById).toHaveBeenCalledWith('course-b'); + }); }); diff --git a/frontend/src/app/pages/lesson-view.component.ts b/frontend/src/app/pages/lesson-view.component.ts index f3791a9..6b63ce6 100644 --- a/frontend/src/app/pages/lesson-view.component.ts +++ b/frontend/src/app/pages/lesson-view.component.ts @@ -8,6 +8,7 @@ import { LessonViewerComponent } from "../shared/lesson-viewer.component"; import { EchoModalComponent } from "../components/echo-modal/echo-modal.component"; import { EchoStatePanelComponent } from '../components/echo-state-panel/echo-state-panel.component'; import { EchoLoadingStateComponent } from '../components/echo-loading-state/echo-loading-state.component'; +import { StudentCourseWithDetails } from '../models/student-course-with-details.model'; @Component({ selector: 'echoed-lesson-view', @@ -89,7 +90,30 @@ export class LessonViewComponent implements OnInit { this.fetchLesson(segment.lesson_id); }, error: (err) => { - console.warn('Failed to restore the current governed lesson segment.', err); + if (err?.status === 404) { + this.recoverFromStaleSegment(); + return; + } + + console.warn('Failed to restore the current governed lesson segment.', err); + this.segment = undefined; + this.lesson = undefined; + this.courseCompleted = false; + this.governedDeliveryState = null; + this.governedDeliveryDetail = ''; + this.loadErrorMessage = 'We could not restore your lesson path right now. Retry to continue from your dashboard-safe progress point.'; + this.loading = false; + } + }); + } + + private recoverFromStaleSegment(): void { + this.coursesService.getStudentCourses().subscribe({ + next: (studentCourses: StudentCourseWithDetails[]) => { + const activeCourse = studentCourses.find((course) => course.unit_progress_id); + const recoveredUnitProgressId = activeCourse?.unit_progress_id; + + if (!recoveredUnitProgressId || recoveredUnitProgressId === this.unitProgressId) { this.segment = undefined; this.lesson = undefined; this.courseCompleted = false; @@ -97,6 +121,22 @@ export class LessonViewComponent implements OnInit { this.governedDeliveryDetail = ''; this.loadErrorMessage = 'We could not restore your lesson path right now. Retry to continue from your dashboard-safe progress point.'; this.loading = false; + return; + } + + this.unitProgressId = recoveredUnitProgressId; + this.router.navigate(['../', recoveredUnitProgressId], { relativeTo: this.route }); + this.loadSegmentAndLesson(); + }, + error: (err) => { + console.warn('Failed to recover the governed lesson segment from student courses.', err); + this.segment = undefined; + this.lesson = undefined; + this.courseCompleted = false; + this.governedDeliveryState = null; + this.governedDeliveryDetail = ''; + this.loadErrorMessage = 'We could not restore your lesson path right now. Retry to continue from your dashboard-safe progress point.'; + this.loading = false; } }); } @@ -166,12 +206,17 @@ export class LessonViewComponent implements OnInit { loadDemoLesson(): void { this.coursesService.getCourses().subscribe({ next: (courses) => { - const firstCourse = courses[0]; - if (!firstCourse) { + const introCourse = + courses.find((course) => { + const pathwayKey = course.standards_metadata?.['pathway_key']; + return pathwayKey === 'introduction-to-africa' || course.title === 'Introduction to Africa'; + }) || courses[0]; + + if (!introCourse) { this.courseCompleted = true; return; } - this.coursesService.getCourseById(firstCourse.id).subscribe({ + this.coursesService.getCourseById(introCourse.id).subscribe({ next: (course) => { const firstUnit = course.units?.[0]; const firstLesson = firstUnit?.lessons?.[0]; diff --git a/frontend/src/app/pages/login/login.component.scss b/frontend/src/app/pages/login/login.component.scss index 08eb008..7cdc6df 100644 --- a/frontend/src/app/pages/login/login.component.scss +++ b/frontend/src/app/pages/login/login.component.scss @@ -4,17 +4,18 @@ .login-shell { background: - radial-gradient(circle at 82% 88%, rgba(116, 53, 188, 0.35), transparent 22rem), - radial-gradient(circle at 15% 16%, rgba(58, 49, 141, 0.34), transparent 22rem), - linear-gradient(135deg, #05091d, #070a24 52%, #100724); - color: #f8f4ff; + radial-gradient(circle at 82% 88%, rgba(15, 118, 110, 0.12), transparent 22rem), + radial-gradient(circle at 15% 16%, rgba(2, 132, 199, 0.1), transparent 22rem), + linear-gradient(135deg, #ffffff, #f8fafc 52%, #eef6ff); + color: var(--ee-text, #102033); min-height: 100vh; padding: clamp(1rem, 3vw, 2rem); } .login-stage { align-items: center; - border: 1px solid rgba(128, 151, 224, 0.44); + background: rgba(255, 255, 255, 0.82); + border: 1px solid rgba(15, 23, 42, 0.12); border-radius: 18px; display: grid; gap: clamp(1.5rem, 4vw, 4rem); @@ -29,8 +30,8 @@ .login-stage::before { background: - radial-gradient(circle at 70% 34%, rgba(124, 58, 237, 0.28), transparent 22rem), - linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.035)); + radial-gradient(circle at 70% 34%, rgba(15, 118, 110, 0.08), transparent 22rem), + linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.45)); content: ''; inset: 0; pointer-events: none; @@ -51,7 +52,7 @@ .brand-link { align-items: center; - color: #fff; + color: var(--ee-text, #102033); display: inline-flex; gap: 0.9rem; text-decoration: none; @@ -59,8 +60,8 @@ } .brand-link span { - background: linear-gradient(135deg, #6d4be8, #261b5e); - border: 1px solid rgba(185, 166, 255, 0.24); + background: var(--echo-primary, #0f766e); + border: 1px solid rgba(15, 118, 110, 0.24); border-radius: 12px; display: grid; font-size: 1.15rem; @@ -86,7 +87,7 @@ p { } h1 { - color: #eee8ff; + color: var(--ee-text, #102033); font-size: clamp(1.8rem, 3.3vw, 2.45rem); line-height: 1.05; max-width: 13ch; @@ -95,12 +96,12 @@ h1 { .story-copy p, .story-list p, .login-footer p { - color: #d8d1ed; + color: var(--ee-text-soft, #334155); line-height: 1.55; } .story-list { - border-top: 1px solid rgba(184, 197, 255, 0.08); + border-top: 1px solid rgba(15, 23, 42, 0.1); display: grid; gap: 1.2rem; padding-top: 1.7rem; @@ -114,10 +115,10 @@ h1 { } .story-list span { - background: rgba(151, 117, 255, 0.16); - border: 1px solid rgba(184, 197, 255, 0.16); + background: rgba(15, 118, 110, 0.1); + border: 1px solid rgba(15, 118, 110, 0.2); border-radius: 999px; - color: #d8c6ff; + color: var(--echo-primary, #0f766e); display: grid; font-size: 0.72rem; font-weight: 900; @@ -127,10 +128,10 @@ h1 { } .login-card { - background: linear-gradient(145deg, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.035)); - border: 1px solid rgba(184, 197, 255, 0.16); + background: #ffffff; + border: 1px solid rgba(15, 23, 42, 0.12); border-radius: 20px; - box-shadow: 0 24px 70px rgba(0, 0, 0, 0.36); + box-shadow: 0 24px 70px rgba(15, 23, 42, 0.12); display: grid; gap: 1.45rem; padding: clamp(1.4rem, 3vw, 2.4rem); @@ -143,15 +144,15 @@ h1 { } h2 { - color: #f6f0ff; + color: var(--ee-text, #102033); font-size: 1.35rem; } .demo-badge { - background: rgba(124, 58, 237, 0.18); - border: 1px solid rgba(180, 146, 255, 0.3); + background: rgba(15, 118, 110, 0.1); + border: 1px solid rgba(15, 118, 110, 0.2); border-radius: 999px; - color: #c7a8ff; + color: var(--echo-primary, #0f766e); font-size: 0.78rem; font-weight: 850; padding: 0.42rem 0.72rem; @@ -167,7 +168,7 @@ h2 { } .field { - color: #f8f4ff; + color: var(--ee-text, #102033); font-size: 0.88rem; font-weight: 850; gap: 0.5rem; @@ -180,10 +181,10 @@ h2 { } input { - background: rgba(2, 6, 23, 0.62); - border: 1px solid rgba(184, 197, 255, 0.12); + background: #ffffff; + border: 1px solid rgba(15, 23, 42, 0.16); border-radius: 8px; - color: #f8f4ff; + color: var(--ee-text, #102033); font: inherit; min-height: 3.45rem; padding: 0.9rem 1rem; @@ -191,14 +192,14 @@ input { } input::placeholder { - color: rgba(216, 209, 237, 0.55); + color: #64748b; } .text-toggle, .forgot-link { background: none; border: 0; - color: #e2b96f; + color: var(--echo-primary, #0f766e); cursor: pointer; font: inherit; font-size: 0.78rem; @@ -212,12 +213,12 @@ input::placeholder { .field-error, .auth-error { - color: #ffc4d2; + color: #be123c; } .auth-error { - background: rgba(255, 122, 154, 0.12); - border: 1px solid rgba(255, 122, 154, 0.3); + background: #fff1f2; + border: 1px solid #fecdd3; border-radius: 10px; font-weight: 800; padding: 0.8rem 0.9rem; @@ -225,10 +226,10 @@ input::placeholder { .primary-cta { align-items: center; - background: linear-gradient(135deg, #8b4df4, #5b2dff); - border: 1px solid rgba(210, 190, 255, 0.24); + background: var(--echo-primary, #0f766e); + border: 1px solid rgba(15, 118, 110, 0.24); border-radius: 8px; - box-shadow: 0 16px 34px rgba(97, 51, 240, 0.22); + box-shadow: 0 16px 34px rgba(15, 118, 110, 0.18); color: #fff; cursor: pointer; display: inline-flex; @@ -253,17 +254,17 @@ input::placeholder { } .login-footer p:first-child { - color: #fff; + color: var(--ee-text, #102033); } .login-footer a { - color: #fff; + color: var(--echo-primary, #0f766e); font-weight: 900; text-decoration: underline; } :where(a, button, input):focus-visible { - outline: 2px solid rgba(132, 92, 255, 0.94); + outline: 2px solid rgba(15, 118, 110, 0.88); outline-offset: 3px; } diff --git a/frontend/src/app/pages/preferences/preferences.component.html b/frontend/src/app/pages/preferences/preferences.component.html index 83b1cf3..1cdb4eb 100644 --- a/frontend/src/app/pages/preferences/preferences.component.html +++ b/frontend/src/app/pages/preferences/preferences.component.html @@ -1,73 +1,100 @@ -
-
-

Preferences & Accessibility

-

Tune EchoEd to your learning style and accessibility needs.

-
- -
-
-

Language & Region

- - - - +
+
+

Settings

+

Preferences & accessibility

+

Tune EchoEd workspace behavior, learning preferences, organization details, and accessibility controls.

+
+ + Keyboard and focus ready
+
-
-

Learning Mode

- - +
+
+
+

Language

+

Language & region

+
+ + +
- - -
+
+
+

Learning

+

Learning mode

+
+ + +
-
-

Accessibility

-
+
-
-

Theme

- - -
+
+
+

Workspace

+

Theme

+
+ +
-
-

Organization

-

Update the organization name shown in the header and invites.

- - - -
-
- -
- +
-
+ diff --git a/frontend/src/app/pages/preferences/preferences.component.scss b/frontend/src/app/pages/preferences/preferences.component.scss index 5d4e87f..ccfab37 100644 --- a/frontend/src/app/pages/preferences/preferences.component.scss +++ b/frontend/src/app/pages/preferences/preferences.component.scss @@ -1,3 +1,34 @@ :host { display: block; } + +.settings-grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(min(24rem, 100%), 1fr)); +} + +.settings-grid__wide { + grid-column: 1 / -1; +} + +h2 { + color: var(--ee-text); + letter-spacing: 0; + margin: 0; +} + +.check-row { + align-items: center; + display: flex; + gap: 0.75rem; +} + +.check-row input { + accent-color: var(--ee-purple); + width: auto; +} + +.check-row span { + color: var(--ee-text-soft); +} diff --git a/frontend/src/app/pages/product-studio/product-studio.component.ts b/frontend/src/app/pages/product-studio/product-studio.component.ts index c5f82b8..f09c3f2 100644 --- a/frontend/src/app/pages/product-studio/product-studio.component.ts +++ b/frontend/src/app/pages/product-studio/product-studio.component.ts @@ -65,6 +65,27 @@ const PRODUCT_TYPES = [ +
+
+
+

Product types

+

Choose the wrapper you are building

+
+ Runtime links stay governed +
+
+ +
+
+
Knowledge InConnect project sources

Product work starts from traceable workspace knowledge.

AI UnderstandingPrepare analysis safely

Generation remains marked as future work in this phase.

@@ -73,7 +94,7 @@ const PRODUCT_TYPES = [
-
+

Step 1

Create Project Shell

@@ -103,7 +124,7 @@ const PRODUCT_TYPES = [ -
+

Step 2

Create Product Shell

@@ -239,6 +260,13 @@ const PRODUCT_TYPES = [ .workflow li.disabled { background: rgba(255,255,255,.035); border-style: dashed; opacity: .82; position: relative; } .workflow li.disabled::after { background: rgba(244,201,93,.16); border: 1px solid rgba(244,201,93,.28); border-radius: 999px; color: #ffe5a2; content: 'Locked'; font-size: .68rem; font-weight: 900; padding: .22rem .48rem; position: absolute; right: .65rem; top: .65rem; } .future-pill { background: rgba(244,201,93,.13); border: 1px solid rgba(244,201,93,.34); border-radius: 999px; color: #ffe5a2; font-weight: 900; padding: .35rem .65rem; } + .type-picker { overflow: hidden; } + .type-grid { display: grid; gap: .75rem; grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); } + .type-grid button { background: rgba(255,255,255,.045); border: 1px solid var(--ee-border); border-radius: 18px; color: var(--ee-text-soft); cursor: pointer; display: grid; gap: .25rem; min-height: 5.5rem; padding: .9rem; text-align: left; transition: transform var(--ee-duration-base, 180ms) var(--ee-easing-standard, ease), border-color var(--ee-duration-base, 180ms) var(--ee-easing-standard, ease), background var(--ee-duration-base, 180ms) var(--ee-easing-standard, ease); } + .type-grid button:hover { transform: var(--ee-hover-lift, translateY(-3px)); } + .type-grid button span { color: var(--ee-gold); font-size: .76rem; font-weight: 900; text-transform: uppercase; } + .type-grid button strong { color: var(--ee-text); } + .type-grid .type-card--active { background: rgba(124,58,237,.2); border-color: rgba(167,139,250,.42); box-shadow: inset 0 0 0 1px rgba(167,139,250,.18); } .pitch-cards { grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); } .pitch-cards article { animation: ee-motion-card-enter var(--ee-duration-slow, 260ms) var(--ee-easing-standard, ease) both; border: 1px solid var(--ee-border); border-radius: 18px; display: grid; gap: .3rem; padding: .9rem; transition: transform var(--ee-duration-base, 180ms) var(--ee-easing-standard, ease); } .pitch-cards article:hover { transform: var(--ee-hover-lift, translateY(-3px)); } diff --git a/frontend/src/app/pages/registration/registration.component.scss b/frontend/src/app/pages/registration/registration.component.scss index 26f8851..cef76eb 100644 --- a/frontend/src/app/pages/registration/registration.component.scss +++ b/frontend/src/app/pages/registration/registration.component.scss @@ -4,17 +4,18 @@ .registration-shell { background: - radial-gradient(circle at 82% 88%, rgba(126, 58, 194, 0.44), transparent 22rem), - radial-gradient(circle at 18% 16%, rgba(58, 49, 141, 0.3), transparent 24rem), - linear-gradient(135deg, #05091d, #080a24 52%, #170a2d); - color: #f8f4ff; + radial-gradient(circle at 82% 88%, rgba(15, 118, 110, 0.12), transparent 22rem), + radial-gradient(circle at 18% 16%, rgba(2, 132, 199, 0.1), transparent 24rem), + linear-gradient(135deg, #ffffff, #f8fafc 52%, #eef6ff); + color: var(--ee-text, #102033); min-height: 100vh; padding: clamp(1rem, 3vw, 2rem); } .registration-stage { align-items: stretch; - border: 1px solid rgba(128, 151, 224, 0.44); + background: rgba(255, 255, 255, 0.82); + border: 1px solid rgba(15, 23, 42, 0.12); border-radius: 18px; display: grid; gap: clamp(1.4rem, 4vw, 3.5rem); @@ -29,8 +30,8 @@ .registration-stage::before { background: - radial-gradient(circle at 76% 54%, rgba(124, 58, 237, 0.32), transparent 24rem), - linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.035)); + radial-gradient(circle at 76% 54%, rgba(15, 118, 110, 0.08), transparent 24rem), + linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.45)); content: ''; inset: 0; pointer-events: none; @@ -49,14 +50,14 @@ } .card-heading { - border-bottom: 1px solid rgba(184, 197, 255, 0.08); + border-bottom: 1px solid rgba(15, 23, 42, 0.1); display: grid; gap: 0.45rem; padding-bottom: 1rem; } .eyebrow { - color: #d7c4ff; + color: var(--echo-primary, #0f766e); font-size: 0.75rem; font-weight: 900; letter-spacing: 0.08em; @@ -70,7 +71,7 @@ p { } h1 { - color: #fff; + color: var(--ee-text, #102033); font-size: clamp(1.8rem, 3.3vw, 2.5rem); line-height: 1.02; } @@ -80,7 +81,7 @@ h1 { .check-row span, .registration-story p, .login-link { - color: #d8d1ed; + color: var(--ee-text-soft, #334155); line-height: 1.45; } @@ -96,7 +97,7 @@ h1 { } .field { - color: #f8f4ff; + color: var(--ee-text, #102033); display: grid; font-size: 0.82rem; font-weight: 850; @@ -111,10 +112,10 @@ h1 { input, select { - background: rgba(2, 6, 23, 0.62); - border: 1px solid rgba(184, 197, 255, 0.14); + background: #ffffff; + border: 1px solid rgba(15, 23, 42, 0.16); border-radius: 8px; - color: #f8f4ff; + color: var(--ee-text, #102033); font: inherit; min-height: 2.75rem; padding: 0.72rem 0.85rem; @@ -122,17 +123,17 @@ select { } input::placeholder { - color: rgba(216, 209, 237, 0.55); + color: #64748b; } select option { - background: #080a24; - color: #f8f4ff; + background: #ffffff; + color: var(--ee-text, #102033); } .org-inline { - background: rgba(255, 255, 255, 0.035); - border: 1px solid rgba(184, 197, 255, 0.12); + background: #f8fafc; + border: 1px solid rgba(15, 23, 42, 0.1); border-radius: 10px; display: grid; gap: 0.75rem; @@ -147,21 +148,21 @@ select option { } .check-row input { - accent-color: #8b4df4; + accent-color: var(--echo-primary, #0f766e); min-height: auto; margin-top: 0.1rem; width: auto; } .check-row strong { - color: #fff; + color: var(--ee-text, #102033); display: block; } .text-toggle { background: none; border: 0; - color: #d6b26b; + color: var(--echo-primary, #0f766e); cursor: pointer; font: inherit; font-size: 0.72rem; @@ -172,12 +173,12 @@ select option { .field-error, .auth-error, .warning { - color: #ffc4d2; + color: #be123c; } .auth-error { - background: rgba(255, 122, 154, 0.12); - border: 1px solid rgba(255, 122, 154, 0.3); + background: #fff1f2; + border: 1px solid #fecdd3; border-radius: 10px; font-weight: 800; padding: 0.75rem 0.85rem; @@ -185,10 +186,10 @@ select option { .registration-submit { align-items: center; - background: linear-gradient(135deg, #8b4df4, #5b2dff); - border: 1px solid rgba(210, 190, 255, 0.24); + background: var(--echo-primary, #0f766e); + border: 1px solid rgba(15, 118, 110, 0.24); border-radius: 8px; - box-shadow: 0 16px 34px rgba(97, 51, 240, 0.22); + box-shadow: 0 16px 34px rgba(15, 118, 110, 0.18); color: #fff; cursor: pointer; display: inline-flex; @@ -211,29 +212,29 @@ select option { } .login-link a { - color: #fff; + color: var(--echo-primary, #0f766e); font-weight: 900; text-decoration: underline; } .registration-story { - background: linear-gradient(145deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.025)); - border: 1px solid rgba(184, 197, 255, 0.14); + background: #ffffff; + border: 1px solid rgba(15, 23, 42, 0.12); border-radius: 10px; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05); + box-shadow: 0 14px 36px rgba(15, 23, 42, 0.08); display: grid; gap: 1.2rem; padding: clamp(1.2rem, 3vw, 2rem); } .story-title { - color: #fff; + color: var(--ee-text, #102033); font-weight: 900; } .registration-story article { align-items: center; - border-bottom: 1px solid rgba(184, 197, 255, 0.08); + border-bottom: 1px solid rgba(15, 23, 42, 0.1); display: grid; gap: 1rem; grid-template-columns: auto 1fr; @@ -246,10 +247,10 @@ select option { } .registration-story span { - background: linear-gradient(135deg, #7047d9, #2b225c); - border: 1px solid rgba(195, 172, 255, 0.25); + background: linear-gradient(135deg, #0f766e, #2563eb); + border: 1px solid rgba(15, 118, 110, 0.25); border-radius: 10px; - color: #f7d27e; + color: #ffffff; display: grid; font-size: 0.78rem; font-weight: 900; @@ -259,11 +260,11 @@ select option { } .registration-story strong { - color: #eee8ff; + color: var(--ee-text, #102033); } :where(a, button, input, select):focus-visible { - outline: 2px solid rgba(132, 92, 255, 0.94); + outline: 2px solid rgba(15, 118, 110, 0.88); outline-offset: 3px; } diff --git a/frontend/src/app/pages/user-dashboard/student-view/student-view.component.html b/frontend/src/app/pages/user-dashboard/student-view/student-view.component.html index ad07c41..3b0d3d4 100644 --- a/frontend/src/app/pages/user-dashboard/student-view/student-view.component.html +++ b/frontend/src/app/pages/user-dashboard/student-view/student-view.component.html @@ -1,25 +1,25 @@
-
+
-

Welcome back

+

Welcome back

{{userInfo.fullname}}

-

Here is a guided overview of your EchoEd learning journey.

+

Here is a guided overview of your EchoEd learning journey.

- K-5 Demo + K-5 Demo
-
-

Active courses

+
+

Active courses

{{ studentCourses.length }}

-
-

Streak

+
+

Streak

{{ streakDays }} days

-
-

Badges

+
+

Badges

{{ studentBadges.length }} earned

diff --git a/frontend/src/app/pages/user-dashboard/student-view/student-view.component.spec.ts b/frontend/src/app/pages/user-dashboard/student-view/student-view.component.spec.ts index 9026289..1326d8b 100644 --- a/frontend/src/app/pages/user-dashboard/student-view/student-view.component.spec.ts +++ b/frontend/src/app/pages/user-dashboard/student-view/student-view.component.spec.ts @@ -161,6 +161,7 @@ describe('StudentViewComponent', () => { }); it('renders error and retry state when course data fails to load', () => { + spyOn(console, 'error'); coursesService.studentCoursesResponse = throwError(() => new Error('load failed')); fixture.detectChanges(); @@ -172,6 +173,7 @@ describe('StudentViewComponent', () => { }); it('retries course loading from the error state action', () => { + spyOn(console, 'error'); coursesService.studentCoursesResponse = throwError(() => new Error('load failed')); fixture.detectChanges(); diff --git a/frontend/src/app/pages/user-dashboard/teacher-view/teacher-view.component.html b/frontend/src/app/pages/user-dashboard/teacher-view/teacher-view.component.html index 3b313f7..b9da936 100644 --- a/frontend/src/app/pages/user-dashboard/teacher-view/teacher-view.component.html +++ b/frontend/src/app/pages/user-dashboard/teacher-view/teacher-view.component.html @@ -1,54 +1,54 @@
-
+
-

Teacher Control Center

+

Teacher Control Center

Welcome, {{userInfo.fullname}}

-

+

Monitor the flagship class story, review governance signals, and keep the live demo on a stable path.

- Demo ready + Demo ready
-
-

Active courses

+
+

Active courses

{{ courses.length }}

-
-

In progress lessons

+
+

In progress lessons

{{ inProgressLessons }}

-
-

Average completion

+
+

Average completion

{{ averageCompletion }}%

-
`, styles: [` - h2 { font-size: 1.05rem; margin: 0; } - article p { color: #526273; margin: .35rem 0 .55rem; } + .collection-grid { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(min(26rem, 100%), 1fr)); } + .ee-list-card { align-items: stretch; } + h2 { color: var(--ee-text); font-size: 1.25rem; letter-spacing: 0; margin: .45rem 0 0; } + article p { color: var(--ee-text-soft); margin: .35rem 0 .55rem; } + .trace { color: var(--ee-text-muted); font-size: .86rem; } + .card-main { display: grid; gap: .65rem; min-width: 0; } + .card-actions { align-content: space-between; display: grid; gap: .75rem; justify-items: end; min-width: 9rem; } + .card-actions span { color: var(--ee-gold); font-size: .76rem; font-weight: 900; text-align: right; text-transform: uppercase; } + @media (max-width: 720px) { + .ee-list-card { grid-template-columns: 1fr; } + .card-actions { justify-items: start; } + .card-actions span { text-align: left; } + } `] }) export class V2CollectionPageComponent implements OnInit { @@ -81,6 +110,15 @@ export class V2CollectionPageComponent implements OnInit { title: item.name, subtitle: item.description || 'Project shell', status: item.status, + typeLabel: 'Knowledge pipeline', + traceLabel: `Workspace ${item.workspace_id}`, + metrics: [ + { label: 'Sources', value: this.metadataValue(item.metadata, 'source_count') }, + { label: 'Artifacts', value: this.metadataValue(item.metadata, 'artifact_count') }, + { label: 'Products', value: this.metadataValue(item.metadata, 'product_count') }, + { label: 'Runs', value: this.metadataValue(item.metadata, 'generation_run_count') }, + ], + nextAction: 'Build pipeline', detailRoute: `/workspace/projects/${item.id}`, }))))); case 'products': @@ -89,6 +127,14 @@ export class V2CollectionPageComponent implements OnInit { title: item.title, subtitle: `${this.label(item.product_type)}${item.course_id ? ' - course-backed runtime link' : ''}`, status: `${item.status} - ${item.review_state}`, + typeLabel: this.label(item.product_type), + traceLabel: item.course_id ? 'Traceable to existing course runtime' : 'V2 product wrapper only', + metrics: [ + { label: 'Access', value: this.label(item.access_state || 'private') }, + { label: 'Visibility', value: this.label(item.visibility || 'draft') }, + { label: 'Pricing', value: this.label(item.pricing_model || 'internal') }, + ], + nextAction: item.course_id ? 'Validate runtime link' : 'Connect runtime', detailRoute: `/workspace/products/${item.id}`, }))))); case 'knowledge-sources': @@ -97,6 +143,13 @@ export class V2CollectionPageComponent implements OnInit { title: item.title, subtitle: item.citation || item.uri || this.label(item.source_type), status: item.status, + typeLabel: this.label(item.source_type), + traceLabel: item.project_id ? `Linked to project ${item.project_id}` : 'Awaiting project linkage', + metrics: [ + { label: 'Availability', value: this.label(item.status) }, + { label: 'Import', value: 'Coming soon' }, + ], + nextAction: 'Prepare intake', }))))); case 'artifacts': return this.v2Platform.getArtifacts().pipe(switchMap(items => of(items.map(item => ({ @@ -104,6 +157,13 @@ export class V2CollectionPageComponent implements OnInit { title: item.title, subtitle: this.label(item.artifact_type), status: `${item.status} - ${item.review_state}`, + typeLabel: this.label(item.artifact_type), + traceLabel: item.knowledge_source_id ? `Source ${item.knowledge_source_id}` : `Project ${item.project_id}`, + metrics: [ + { label: 'Review', value: this.label(item.review_state) }, + { label: 'Status', value: this.label(item.status) }, + ], + nextAction: 'Review traceability', detailRoute: `/workspace/artifacts/${item.id}`, }))))); case 'generation-runs': @@ -112,6 +172,13 @@ export class V2CollectionPageComponent implements OnInit { title: item.model_name || 'Generation run', subtitle: item.output_summary || item.prompt || 'AI execution metadata placeholder', status: item.status, + typeLabel: 'Generation metadata', + traceLabel: `Project ${item.project_id}`, + metrics: [ + { label: 'Status', value: this.label(item.status) }, + { label: 'Execution', value: 'Future' }, + ], + nextAction: 'Inspect run', detailRoute: `/workspace/product-studio/generation-runs/${item.id}`, }))))); } @@ -134,4 +201,9 @@ export class V2CollectionPageComponent implements OnInit { } return ''; } + + metadataValue(metadata: Record, key: string): string | number { + const value = metadata?.[key]; + return typeof value === 'number' || typeof value === 'string' ? value : 'Not tracked'; + } } diff --git a/frontend/src/app/services/permissions.service.spec.ts b/frontend/src/app/services/permissions.service.spec.ts index d80522d..e3a80a3 100644 --- a/frontend/src/app/services/permissions.service.spec.ts +++ b/frontend/src/app/services/permissions.service.spec.ts @@ -221,4 +221,26 @@ describe('PermissionsService', () => { expect(service.hasAnyRole('org_admin')).toBeTrue(); expect(service.hasAnyRole('teacher')).toBeFalse(); }); + + it('does not advertise workspace navigation for plain student sessions', async () => { + organizationService.activeOrgId = 'org-1'; + organizationService.setOrganizations([ + { id: 'org-1', type: 'school', role: 'student' }, + ]); + authService.payload = { + sub: 'student', + user_id: 'user-1', + fullname: 'Student User', + role: 'student', + exp: Math.floor(Date.now() / 1000) + 3600, + }; + + await service.bootstrapSession(); + + expect(service.hasPermission('nav:dashboard')).toBeTrue(); + expect(service.hasPermission('nav:courses')).toBeTrue(); + expect(service.hasPermission('nav:workspace')).toBeFalse(); + expect(service.hasPermission('nav:products')).toBeFalse(); + expect(service.hasPermission('nav:analytics')).toBeFalse(); + }); }); diff --git a/frontend/src/app/services/permissions.service.ts b/frontend/src/app/services/permissions.service.ts index 79603cf..40febce 100644 --- a/frontend/src/app/services/permissions.service.ts +++ b/frontend/src/app/services/permissions.service.ts @@ -244,12 +244,12 @@ export class PermissionsService { permissions.add('nav:programs'); permissions.add('nav:certifications'); permissions.add('nav:preferences'); - permissions.add('nav:workspace'); - permissions.add('nav:products'); - permissions.add('nav:learners'); - permissions.add('nav:settings'); if (this.hasRole(roleSet, ['admin', 'teacher', 'content_admin', 'org_admin', 'instructor'])) { + permissions.add('nav:workspace'); + permissions.add('nav:products'); + permissions.add('nav:learners'); + permissions.add('nav:settings'); permissions.add('nav:projects'); permissions.add('nav:product-studio'); permissions.add('nav:knowledge-sources'); diff --git a/frontend/src/app/shared/lesson-viewer.component.html b/frontend/src/app/shared/lesson-viewer.component.html index b4ca482..943bfcb 100644 --- a/frontend/src/app/shared/lesson-viewer.component.html +++ b/frontend/src/app/shared/lesson-viewer.component.html @@ -10,7 +10,7 @@

{{ lesson.title }}

{{ lesson.duration_minutes }} min - {{ activityCount }} activitiesy + {{ stepCount }} steps
@@ -18,11 +18,11 @@

{{ lesson.title }}

-

Activity progress

+

Lesson progress

- Activity {{ currentActivityIndex + 1 }} of {{ activityCount }}. - Move at your own pace and continue when you are ready. - You are on the final activity for this lesson. + Step {{ currentStepIndex + 1 }} of {{ stepCount }}: {{ currentStepLabel }}. + Use Next when you are ready to keep going. + This is the final step before the lesson is saved as complete.

@@ -33,7 +33,7 @@

{{ lesson.title }}

{{ lesson.title }}
-
-
-

Learning Objectives

-

{{ lesson.learning_objectives }}

-
-
-

Key Concepts

-
- - {{ concept }} - -
-
-
- -
-
+
-
+ {{ index + 1 }} + {{ step.label }} + +

Teacher Notes

{{ lesson.teacher_notes }}

- -
-

Discussion Questions

-
    -
  • {{ question }}
  • -
-
-
+
-

Current activity

-

{{ currentActivity.title }}

+

{{ step.label }}

+

+ {{ currentStepTitle }} +

- Activity {{ currentActivityIndex + 1 }} of {{ lesson.activities.length }} + Step {{ currentStepIndex + 1 }} of {{ stepCount }}

- -
- - - - - - -
-
- {{ currentActivity.title }} -
-
- -
-
- -
-
- -
-
-
- {{ currentActivity.content }} + +
+
+

Learning Objectives

+

{{ lesson.learning_objectives }}

+
+
+

Key Concepts

+
+ + {{ concept }} + +
-
-

{{ currentActivity.content }}

- + +
+ {{ currentInstructionContent }}
-
-
-

Checkpoint

-

{{ currentActivity.content }}

-
+ +
+

Use these prompts to think, talk, draw, or write before you finish the lesson.

+
    +
  • {{ question }}
  • +
+
+ +
+

These sources support the lesson you just worked through.

+
-
- -
- {{ quizPayload.question }} -
-
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ {{ activity.content }} +
+
+
+

{{ activity.content }}

+ +
+
+
+

Checkpoint

+

{{ activity.content }}

+
+
+
+ +
+ {{ quizPayload.question }} +
+ +
+
+
+ +

{{ activity.content }}

+ +
+
+
+

{{ activity.content }}

+
+
- -

{{ currentActivity.content }}

- -
-
-
-

{{ currentActivity.content }}

-
+
-
diff --git a/frontend/src/app/shared/lesson-viewer.component.scss b/frontend/src/app/shared/lesson-viewer.component.scss index e211f5c..6ab1451 100644 --- a/frontend/src/app/shared/lesson-viewer.component.scss +++ b/frontend/src/app/shared/lesson-viewer.component.scss @@ -7,6 +7,51 @@ min-width: 0; } +.lesson-viewer__step-list { + display: flex; + gap: 0.5rem; + overflow-x: auto; + padding-bottom: 0.25rem; +} + +.lesson-viewer__step-pill { + align-items: center; + background: #ffffff; + border: 1px solid #d8e1ea; + border-radius: 999px; + color: #475569; + display: inline-flex; + flex: 0 0 auto; + font-size: 0.78rem; + font-weight: 800; + gap: 0.4rem; + min-height: 2.2rem; + padding: 0.45rem 0.75rem; +} + +.lesson-viewer__step-pill span { + align-items: center; + background: #f1f5f9; + border-radius: 999px; + color: #334155; + display: inline-flex; + height: 1.35rem; + justify-content: center; + width: 1.35rem; +} + +.lesson-viewer__step-pill--active { + background: rgba(14, 116, 144, 0.08); + border-color: rgba(14, 116, 144, 0.35); + color: #0f766e; +} + +.lesson-viewer__step-pill--complete { + background: #ecfdf5; + border-color: #bbf7d0; + color: #166534; +} + .lesson-viewer button, .lesson-viewer a, .lesson-viewer textarea, diff --git a/frontend/src/app/shared/lesson-viewer.component.spec.ts b/frontend/src/app/shared/lesson-viewer.component.spec.ts index 3596e38..48a0eef 100644 --- a/frontend/src/app/shared/lesson-viewer.component.spec.ts +++ b/frontend/src/app/shared/lesson-viewer.component.spec.ts @@ -28,6 +28,8 @@ describe('LessonViewerComponent', () => { it('hides educator-only notes in learner mode while preserving learner prompts', () => { component.isTeacherLed = false; fixture.detectChanges(); + component.goToStep(component.lessonSteps.length - 1); + fixture.detectChanges(); const text = fixture.nativeElement.textContent; expect(text).not.toContain('Teacher Notes'); @@ -37,27 +39,36 @@ describe('LessonViewerComponent', () => { it('shows educator-only fields in teacher-led mode', () => { component.isTeacherLed = true; fixture.detectChanges(); + component.goToStep(component.lessonSteps.length - 1); + fixture.detectChanges(); const text = fixture.nativeElement.textContent; expect(text).toContain('Teacher Notes'); expect(text).toContain('Which source is more credible?'); }); - it('keeps instructional section hierarchy visible for learner content', () => { + it('keeps instructional sections in a clear learner step sequence', () => { component.isTeacherLed = false; fixture.detectChanges(); - const sections = fixture.nativeElement.querySelectorAll('[aria-label="Instructional sections"] section'); - expect(sections.length).toBeGreaterThan(0); + const stepLabels = component.lessonSteps.map(step => step.label); + expect(stepLabels).toContain('Hook'); + expect(stepLabels).toContain('Content'); + expect(stepLabels).toContain('Guided Practice'); expect(fixture.nativeElement.textContent).toContain('Hook'); - expect(fixture.nativeElement.textContent).toContain('Content'); - expect(fixture.nativeElement.textContent).toContain('Guided Practice'); + expect(fixture.nativeElement.textContent).toContain('Open with a claim.'); + + component.goToStep(1); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain('Read the source text carefully.'); }); it('emits lesson completion from the final learner action without changing behavior', () => { spyOn(component.segmentCompleted, 'emit'); component.isTeacherLed = false; fixture.detectChanges(); + component.goToStep(component.lessonSteps.length - 1); + fixture.detectChanges(); const completeButton = Array.from( fixture.nativeElement.querySelectorAll('button'), @@ -82,6 +93,8 @@ describe('LessonViewerComponent', () => { } as any], }; + fixture.detectChanges(); + component.goToStep(component.lessonSteps.findIndex(step => step.kind === 'activity')); fixture.detectChanges(); const radiogroup = fixture.nativeElement.querySelector('[role="radiogroup"]') as HTMLElement; @@ -99,18 +112,20 @@ describe('LessonViewerComponent', () => { ], }; + fixture.detectChanges(); + component.goToStep(component.lessonSteps.findIndex(step => step.kind === 'activity')); fixture.detectChanges(); let text = fixture.nativeElement.textContent; expect(text).toContain('Story Prompt'); - expect(text).toContain('Activity 1 of 3'); + expect(text).toContain('Activity 1'); component.goToNextActivity(); fixture.detectChanges(); text = fixture.nativeElement.textContent; expect(text).toContain('Reflection Prompt'); - expect(text).toContain('Activity 2 of 3'); + expect(text).toContain('Activity 2'); }); it('renders a lesson activity progress bar for learner continuity', () => { @@ -124,9 +139,9 @@ describe('LessonViewerComponent', () => { fixture.detectChanges(); - const progressbar = fixture.nativeElement.querySelector('[aria-label="Lesson activity progress"]') as HTMLElement; + const progressbar = fixture.nativeElement.querySelector('[aria-label="Lesson progress"]') as HTMLElement; expect(progressbar).not.toBeNull(); - expect(progressbar.getAttribute('aria-valuenow')).toBe('50'); - expect(fixture.nativeElement.textContent).toContain('50% complete'); + expect(progressbar.getAttribute('aria-valuenow')).toBe('17'); + expect(fixture.nativeElement.textContent).toContain('17% complete'); }); }); diff --git a/frontend/src/app/shared/lesson-viewer.component.ts b/frontend/src/app/shared/lesson-viewer.component.ts index 397da0c..4b99b57 100644 --- a/frontend/src/app/shared/lesson-viewer.component.ts +++ b/frontend/src/app/shared/lesson-viewer.component.ts @@ -5,11 +5,19 @@ import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; import { Lesson } from '../models/lesson'; import { ColoringCanvasComponent } from '../components/coloring-canvas/coloring-canvas.component'; import { StorybookViewerComponent } from '../components/storybook-viewer/storybook-viewer.component'; +import { AfricaMapExplorerComponent, AfricaMapExplorerData } from '../components/africa-map-explorer/africa-map-explorer.component'; + +type LessonStep = + | { kind: 'overview'; label: string; title: string } + | { kind: 'instruction'; label: string; title: string; content: string } + | { kind: 'activity'; label: string; activityIndex: number } + | { kind: 'discussion'; label: string; title: string; questions: string[] } + | { kind: 'sources'; label: string; title: string }; @Component({ selector: 'echoed-lesson-viewer', standalone: true, - imports: [CommonModule, FormsModule, ColoringCanvasComponent, StorybookViewerComponent], + imports: [CommonModule, FormsModule, ColoringCanvasComponent, StorybookViewerComponent, AfricaMapExplorerComponent], templateUrl: './lesson-viewer.component.html', styleUrl: './lesson-viewer.component.scss' }) @@ -21,35 +29,132 @@ export class LessonViewerComponent implements OnChanges { @Output() segmentCompleted = new EventEmitter(); currentActivityIndex = 0; + currentStepIndex = 0; reflectionResponse = ''; selectedOption = ''; ngOnChanges(changes: SimpleChanges): void { if (changes['lesson']) { this.currentActivityIndex = 0; + this.currentStepIndex = 0; this.reflectionResponse = ''; this.selectedOption = ''; } } get currentActivity() { - return this.lesson?.activities[this.currentActivityIndex]; + if (this.currentStep?.kind !== 'activity') { + return null; + } + return this.orderedActivities[this.currentStep.activityIndex] ?? null; } get activityCount(): number { - return this.lesson?.activities?.length || 0; + return this.orderedActivities.length; + } + + get orderedActivities() { + return [...(this.lesson?.activities || [])].sort((left, right) => { + const leftOrder = left.order ?? Number.MAX_SAFE_INTEGER; + const rightOrder = right.order ?? Number.MAX_SAFE_INTEGER; + if (leftOrder !== rightOrder) { + return leftOrder - rightOrder; + } + return 0; + }); + } + + get lessonSteps(): LessonStep[] { + const steps: LessonStep[] = []; + + if (this.lesson?.objective || this.lesson?.learning_objectives || (this.lesson?.key_concepts?.length ?? 0) > 0) { + steps.push({ kind: 'overview', label: 'Goals', title: 'What you will learn' }); + } + + steps.push( + ...this.instructionalSections + .filter(section => section.content) + .map(section => ({ + kind: 'instruction' as const, + label: section.label, + title: section.label, + content: section.content || '', + })), + ); + + this.orderedActivities.forEach((activity, index) => { + steps.push({ + kind: 'activity', + label: `Activity ${index + 1}`, + activityIndex: index, + }); + }); + + if ((this.lesson?.discussion_questions?.length ?? 0) > 0) { + steps.push({ + kind: 'discussion', + label: 'Discuss', + title: 'Talk it through', + questions: this.lesson.discussion_questions || [], + }); + } + + if ((this.lesson?.sources?.length ?? 0) > 0) { + steps.push({ + kind: 'sources', + label: 'Sources', + title: 'Where this lesson comes from', + }); + } + + return steps; + } + + get currentStep(): LessonStep | null { + return this.lessonSteps[this.currentStepIndex] ?? null; + } + + get stepCount(): number { + return this.lessonSteps.length; } get activityProgressPercent(): number { - if (!this.activityCount) { + if (!this.stepCount) { return 0; } - return Math.round(((this.currentActivityIndex + 1) / this.activityCount) * 100); + return Math.round(((this.currentStepIndex + 1) / this.stepCount) * 100); } get isLastActivity(): boolean { - return this.currentActivityIndex === this.activityCount - 1; + return this.currentStepIndex === this.stepCount - 1; + } + + get isFirstStep(): boolean { + return this.currentStepIndex === 0; + } + + get currentStepLabel(): string { + return this.currentStep?.label || 'Lesson'; + } + + get currentStepTitle(): string { + const step = this.currentStep; + if (!step) { + return ''; + } + if (step.kind === 'activity') { + return this.currentActivity?.title || 'Activity'; + } + return step.title; + } + + get currentInstructionContent(): string { + return this.currentStep?.kind === 'instruction' ? this.currentStep.content : ''; + } + + get currentDiscussionQuestions(): string[] { + return this.currentStep?.kind === 'discussion' ? this.currentStep.questions : []; } get instructionalSections(): Array<{ label: string; content?: string }> { @@ -67,13 +172,45 @@ export class LessonViewerComponent implements OnChanges { } goToNextActivity(): void { - if (this.lesson && this.currentActivityIndex < this.lesson.activities.length - 1) { - this.currentActivityIndex++; - this.reflectionResponse = ''; - this.selectedOption = ''; + this.goToNextStep(); + } + + goToStep(index: number): void { + if (index < 0 || index >= this.stepCount) { + return; + } + this.currentStepIndex = index; + this.syncActivityIndex(); + this.resetStepInputs(); + } + + goToNextStep(): void { + if (this.currentStepIndex < this.stepCount - 1) { + this.currentStepIndex++; + this.syncActivityIndex(); + this.resetStepInputs(); + } + } + + goToPreviousStep(): void { + if (this.currentStepIndex > 0) { + this.currentStepIndex--; + this.syncActivityIndex(); + this.resetStepInputs(); + } + } + + private syncActivityIndex(): void { + if (this.currentStep?.kind === 'activity') { + this.currentActivityIndex = this.currentStep.activityIndex; } } + private resetStepInputs(): void { + this.reflectionResponse = ''; + this.selectedOption = ''; + } + isYouTubeLink(url: string): boolean { return url.includes('youtube.com') || url.includes('youtu.be'); } @@ -100,6 +237,22 @@ export class LessonViewerComponent implements OnChanges { return null; } + get mapPayload(): AfricaMapExplorerData | null { + const content = this.currentActivity?.content; + if (!content) { + return null; + } + try { + const parsed = JSON.parse(content); + if (parsed && Array.isArray(parsed.regions)) { + return parsed as AfricaMapExplorerData; + } + } catch { + return null; + } + return null; + } + onMarkComplete(): void { // Emit when lesson segment is completed (for updating progress) this.segmentCompleted.emit(); diff --git a/frontend/src/assets/maps/africa-reference.svg b/frontend/src/assets/maps/africa-reference.svg new file mode 100644 index 0000000..b6cc49a --- /dev/null +++ b/frontend/src/assets/maps/africa-reference.svg @@ -0,0 +1,256 @@ + + + + + + + + + + + + + + + + + + + + Africa Region Guide + Trace the continent shape, then connect regions and routes + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + North Africa + West Africa + Central Africa + East Africa + Southern Africa + + + + Map key + + - North Africa: coast and desert edge + - West Africa: trade, language, music + - East Africa: highlands, lakes, routes + - Central Africa: forests and rivers + - Southern Africa: plains and coasts + + Madagascar is shown on the main map. + diff --git a/frontend/src/assets/maps/world-context.svg b/frontend/src/assets/maps/world-context.svg new file mode 100644 index 0000000..498f99a --- /dev/null +++ b/frontend/src/assets/maps/world-context.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Africa in the World + A continent, not a country + diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index 7bd843f..91234b8 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -8,8 +8,8 @@ @include typography.echoed-typography; :root { - --echo-primary: #2E294E; - --echo-secondary: #44356B; + --echo-primary: #0F766E; + --echo-secondary: #2563EB; --echo-accent: #EFB700; --echo-ink: #0F172A; --echo-sand: #F7F3ED; @@ -426,25 +426,25 @@ textarea::placeholder { } :root { - --ee-midnight: #060715; - --ee-indigo: #0b1026; - --ee-indigo-2: #11183a; - --ee-surface: rgba(16, 20, 43, 0.72); - --ee-surface-strong: rgba(22, 27, 55, 0.88); - --ee-border: rgba(184, 197, 255, 0.16); - --ee-border-strong: rgba(239, 226, 177, 0.24); - --ee-text: #f7f3ff; - --ee-text-soft: #c8c6df; - --ee-text-muted: #9694b4; - --ee-purple: #a78bfa; - --ee-purple-strong: #7c3aed; - --ee-gold: #f4c95d; - --ee-azure: #5cc8ff; - --ee-emerald: #65e6b3; - --ee-danger: #ff7a9a; - --ee-warning: #ffd37a; - --ee-shadow: 0 28px 80px rgba(0, 0, 0, 0.42); - --ee-shadow-soft: 0 18px 50px rgba(4, 6, 22, 0.34); + --ee-midnight: #f8fafc; + --ee-indigo: #eef6ff; + --ee-indigo-2: #e2e8f0; + --ee-surface: #ffffff; + --ee-surface-strong: #f8fafc; + --ee-border: rgba(15, 23, 42, 0.12); + --ee-border-strong: rgba(15, 118, 110, 0.24); + --ee-text: #102033; + --ee-text-soft: #334155; + --ee-text-muted: #64748b; + --ee-purple: #2563eb; + --ee-purple-strong: #0f766e; + --ee-gold: #a16207; + --ee-azure: #0284c7; + --ee-emerald: #047857; + --ee-danger: #be123c; + --ee-warning: #a16207; + --ee-shadow: 0 22px 60px rgba(15, 23, 42, 0.14); + --ee-shadow-soft: 0 14px 36px rgba(15, 23, 42, 0.1); --ee-radius: 22px; --ee-radius-sm: 18px; --ee-duration-fast: 120ms; @@ -455,7 +455,7 @@ textarea::placeholder { --ee-easing-exit: cubic-bezier(0.4, 0, 1, 1); --ee-hover-lift: translateY(-3px); --ee-press-scale: scale(0.98); - --ee-focus-ring: 0 0 0 5px rgba(92, 200, 255, 0.16); + --ee-focus-ring: 0 0 0 5px rgba(15, 118, 110, 0.16); --echo-v2-bg: var(--ee-midnight); --echo-v2-panel: var(--ee-surface); --echo-v2-line: var(--ee-border); @@ -473,17 +473,16 @@ textarea::placeholder { html, body { background: - radial-gradient(circle at 12% 8%, rgba(124, 58, 237, 0.34), transparent 30rem), - radial-gradient(circle at 86% 14%, rgba(92, 200, 255, 0.2), transparent 28rem), - radial-gradient(circle at 70% 86%, rgba(244, 201, 93, 0.14), transparent 30rem), - linear-gradient(135deg, var(--ee-midnight), var(--ee-indigo) 48%, #070817); + radial-gradient(circle at 12% 8%, rgba(15, 118, 110, 0.1), transparent 28rem), + radial-gradient(circle at 86% 14%, rgba(2, 132, 199, 0.09), transparent 26rem), + linear-gradient(135deg, #ffffff, var(--ee-midnight) 48%, #f1f5f9); color: var(--ee-text); } body::before { background: - linear-gradient(rgba(255, 255, 255, 0.025) 1px, transparent 1px), - linear-gradient(90deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px); + linear-gradient(rgba(15, 23, 42, 0.035) 1px, transparent 1px), + linear-gradient(90deg, rgba(15, 23, 42, 0.025) 1px, transparent 1px); background-size: 56px 56px; content: ''; inset: 0; @@ -514,6 +513,8 @@ h6 { .learner-portal { color: var(--ee-text); min-height: 100vh; + max-width: 1380px; + margin-inline: auto; } .ee-page-header, @@ -528,13 +529,11 @@ h6 { .ee-navigation-card, .glass-panel, .surface-card { - background: - linear-gradient(145deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.035)), - var(--ee-surface); + background: var(--ee-surface); border: 1px solid var(--ee-border); border-radius: var(--ee-radius); box-shadow: var(--ee-shadow-soft); - backdrop-filter: blur(22px) saturate(1.2); + backdrop-filter: none; } .ee-page-header, @@ -547,9 +546,9 @@ h6 { .ee-page-header::before, .ee-hero::before { background: - radial-gradient(circle at 16% 20%, rgba(167, 139, 250, 0.34), transparent 17rem), - radial-gradient(circle at 92% 12%, rgba(244, 201, 93, 0.22), transparent 18rem), - radial-gradient(circle at 74% 80%, rgba(92, 200, 255, 0.18), transparent 20rem); + radial-gradient(circle at 16% 20%, rgba(15, 118, 110, 0.12), transparent 17rem), + radial-gradient(circle at 92% 12%, rgba(161, 98, 7, 0.1), transparent 18rem), + radial-gradient(circle at 74% 80%, rgba(2, 132, 199, 0.1), transparent 20rem); content: ''; inset: 0; opacity: 0.95; @@ -573,7 +572,7 @@ h6 { } .ee-eyebrow { - color: var(--ee-gold); + color: var(--echo-primary); font-size: 0.75rem; letter-spacing: 0.18em; } @@ -605,6 +604,22 @@ h6 { padding: clamp(1.1rem, 3vw, 1.6rem); } +.ee-card { + min-height: 100%; +} + +.ee-form-card, +.ee-list-card { + background: var(--ee-surface); + border: 1px solid var(--ee-border); + border-radius: var(--ee-radius); + box-shadow: var(--ee-shadow-soft); + backdrop-filter: none; + display: grid; + gap: 1rem; + padding: clamp(1.1rem, 3vw, 1.6rem); +} + .ee-card--interactive, .ee-navigation-card, .ee-button, @@ -677,10 +692,10 @@ a.ee-link-button:active { .ee-badge, .ee-tag { - background: rgba(167, 139, 250, 0.14); - border: 1px solid rgba(167, 139, 250, 0.28); + background: rgba(15, 118, 110, 0.1); + border: 1px solid rgba(15, 118, 110, 0.2); border-radius: 999px; - color: #ddd4ff; + color: #0f766e; display: inline-flex; font-size: 0.78rem; font-weight: 900; @@ -692,25 +707,25 @@ a.ee-link-button:active { .ee-badge--draft, .ee-badge--pending, .ee-badge--needs-changes { - background: rgba(244, 201, 93, 0.13); - border-color: rgba(244, 201, 93, 0.34); - color: #ffe5a2; + background: #fffbeb; + border-color: #fde68a; + color: #a16207; } .ee-badge--blocked, .ee-badge--rejected, .ee-badge--revoked { - background: rgba(255, 122, 154, 0.14); - border-color: rgba(255, 122, 154, 0.34); - color: #ffc4d2; + background: #fff1f2; + border-color: #fecdd3; + color: #be123c; } .ee-badge--approved, .ee-badge--published, .ee-badge--active { - background: rgba(101, 230, 179, 0.13); - border-color: rgba(101, 230, 179, 0.34); - color: #baf8df; + background: #ecfdf5; + border-color: #bbf7d0; + color: #047857; } .ee-button, @@ -723,15 +738,15 @@ a.ee-link-button:active { .ee-button, .ee-link-button--primary { - background: linear-gradient(135deg, var(--ee-purple-strong), var(--ee-purple) 52%, var(--ee-azure)); - border: 1px solid rgba(255, 255, 255, 0.2); - box-shadow: 0 16px 34px rgba(92, 200, 255, 0.16); + background: var(--echo-primary); + border: 1px solid var(--echo-primary); + box-shadow: 0 12px 26px rgba(15, 118, 110, 0.16); color: #fff; } .ee-button--secondary, .ee-link-button { - background: rgba(255, 255, 255, 0.055); + background: #ffffff; border: 1px solid var(--ee-border); color: var(--ee-text); } @@ -758,11 +773,48 @@ a.ee-link-button:active { } .ee-list-row { - background: rgba(255, 255, 255, 0.045); + background: #ffffff; border-color: var(--ee-border); border-radius: var(--ee-radius-sm); } +.ee-list-card { + grid-template-columns: minmax(0, 1fr) auto; +} + +.ee-list-card__meta { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.ee-kpi-strip { + display: grid; + gap: 0.65rem; + grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr)); +} + +.ee-kpi { + background: #f8fafc; + border: 1px solid var(--ee-border); + border-radius: 16px; + display: grid; + gap: 0.15rem; + padding: 0.75rem; +} + +.ee-kpi span { + color: var(--ee-text-muted); + font-size: 0.68rem; + font-weight: 900; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.ee-kpi strong { + color: var(--ee-text); +} + .ee-timeline li { border-left-color: rgba(92, 200, 255, 0.34); color: var(--ee-text-soft); @@ -790,7 +842,7 @@ a.ee-link-button:active { input, select, textarea { - background: rgba(255, 255, 255, 0.075); + background: #ffffff; border: 1px solid var(--ee-border); border-radius: 16px; color: var(--ee-text); @@ -801,11 +853,11 @@ textarea { .ee-input input::placeholder, input::placeholder, textarea::placeholder { - color: rgba(200, 198, 223, 0.72); + color: #64748b; } select option { - background: var(--ee-indigo); + background: #ffffff; color: var(--ee-text); } @@ -819,7 +871,7 @@ select option { } .ee-stepper li { - background: rgba(255, 255, 255, 0.055); + background: #f8fafc; border: 1px solid var(--ee-border); border-radius: var(--ee-radius-sm); color: var(--ee-text-soft); @@ -921,14 +973,14 @@ select option { .ee-motion-skeleton { animation: ee-motion-skeleton-pulse 1.35s var(--ee-easing-standard) infinite; - background: rgba(255, 255, 255, 0.075); + background: #e2e8f0; border-radius: 14px; } .ee-motion-shimmer { background: - linear-gradient(110deg, transparent 0%, rgba(255,255,255,.11) 35%, transparent 70%), - rgba(255, 255, 255, 0.055); + linear-gradient(110deg, transparent 0%, rgba(255,255,255,.72) 35%, transparent 70%), + #e2e8f0; background-size: 220% 100%; animation: ee-motion-shimmer 1.4s var(--ee-easing-standard) infinite; } diff --git a/openspec/changes/echoed-v2-content-admin-experience-polish/.openspec.yaml b/openspec/changes/echoed-v2-content-admin-experience-polish/.openspec.yaml new file mode 100644 index 0000000..8e26fbe --- /dev/null +++ b/openspec/changes/echoed-v2-content-admin-experience-polish/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-02 diff --git a/openspec/changes/echoed-v2-content-admin-experience-polish/design.md b/openspec/changes/echoed-v2-content-admin-experience-polish/design.md new file mode 100644 index 0000000..4dbda56 --- /dev/null +++ b/openspec/changes/echoed-v2-content-admin-experience-polish/design.md @@ -0,0 +1,48 @@ +## Context + +The public landing, login, and registration pages now establish a premium EchoEd visual direction: dark bordered canvases, purple/gold accents, frosted surfaces, confident typography, compact premium actions, and clear product-led messaging. The authenticated V2 workspace/admin area still reads closer to a generic admin dashboard in several places, with uneven spacing, raw list/form layouts, and less polished navigation. + +This change is UI/UX polish only. Existing Angular routes, service calls, authorization, organization bootstrap, data contracts, and education runtime behavior remain unchanged. + +## Goals / Non-Goals + +**Goals:** +- Extend the approved premium visual direction into workspace/admin pages. +- Establish reusable frontend utility classes for shell, page, hero, cards, metrics, status badges, action rows, empty states, forms, lists, timelines, and buttons. +- Polish the sidebar and top header without changing the navigation items users are allowed to see. +- Improve page-level hierarchy for the workspace dashboard, project/product/content/admin pages, and settings. +- Preserve existing user actions, service calls, routes, and guards. +- Honor keyboard focus states, responsive layout, and reduced-motion preferences. + +**Non-Goals:** +- No backend changes. +- No authorization, route semantic, or organization bootstrap changes. +- No new product, billing, AI generation, review, access, learner, or analytics features. +- No changes to lesson runtime governance or education delivery behavior. +- No dependency additions. + +## Decisions + +1. **Use shared SCSS utility classes instead of a new component library.** + - Rationale: The affected pages already mix standalone Angular components and local templates. Shared classes in existing global/component styles provide broad consistency with lower risk than introducing a new component hierarchy. + - Alternative considered: Create new Angular primitives for every UI element. Rejected for this polish phase because it would create more behavioral and template churn. + +2. **Keep page data bindings and actions intact while upgrading visual structure.** + - Rationale: Acceptance criteria require existing functionality unchanged. Templates can be reorganized into premium cards/forms/lists, but should keep the same event handlers, router links, and service-backed collections. + - Alternative considered: Add computed view models or new service aggregation. Rejected because it risks changing data behavior. + +3. **Polish shell navigation through presentation-only metadata and CSS.** + - Rationale: Sidebar icons, active states, grouping, and collapsed layout can improve without altering permission decisions or route availability. + - Alternative considered: Replace routing/navigation semantics. Rejected by scope. + +4. **Use a light content surface inside a premium gradient shell where appropriate.** + - Rationale: The admin workspace needs readability for dense operational content while still inheriting the public-page premium feel. + - Alternative considered: Fully dark dashboards everywhere. Rejected because table/form-heavy workspace pages need high readability and clear contrast. + +## Risks / Trade-offs + +- **Large page surface area** -> Mitigation: prioritize shared classes and targeted templates/styles for the listed workspace routes. +- **Visual polish could accidentally alter behavior** -> Mitigation: preserve existing bindings, service calls, router links, and action handlers; run existing frontend tests and build. +- **Component style budgets may be stressed** -> Mitigation: centralize reusable styles and keep local CSS focused. +- **Dense pages can become overly decorative** -> Mitigation: keep admin pages product-led and professional, with content surfaces optimized for scanning. +- **Screens may vary by available seeded data** -> Mitigation: improve empty states and card/list layouts without assuming new backend fields. diff --git a/openspec/changes/echoed-v2-content-admin-experience-polish/proposal.md b/openspec/changes/echoed-v2-content-admin-experience-polish/proposal.md new file mode 100644 index 0000000..184011f --- /dev/null +++ b/openspec/changes/echoed-v2-content-admin-experience-polish/proposal.md @@ -0,0 +1,30 @@ +## Why + +The V2 workspace/admin area does not yet match the premium visual direction established by the redesigned landing, login, and registration pages. This polish phase makes the authenticated content/admin workspace feel investor-ready, product-led, and cohesive without changing backend behavior, authorization, routing semantics, or education runtime behavior. + +## What Changes + +- Apply a shared premium admin design language to the V2 workspace shell and workspace pages. +- Refine the sidebar into a polished workspace rail with icon-led navigation, clearer active states, better grouping, non-clipped labels, improved collapsed behavior, and a refined profile footer. +- Refine the top command header with stronger breadcrumb/title hierarchy, cleaner organization selector styling, premium tour CTA treatment, and improved avatar presentation. +- Replace gray/washed workspace backgrounds with a premium shell using subtle gradients, frosted panels, clean content surfaces, and consistent max-width containers. +- Add consistent hero panels, cards, status badges, action rows, empty states, forms, lists, metrics, and timelines for workspace/admin pages. +- Polish `/workspace`, `/workspace/projects`, `/workspace/product-studio`, `/workspace/products`, `/workspace/knowledge-sources`, `/workspace/artifacts`, `/workspace/review-center`, `/workspace/access`, `/workspace/analytics`, `/workspace/commercial`, `/workspace/learners`, and `/workspace/settings`. +- Preserve all existing data flows, service calls, permissions, route behavior, and runtime lesson governance. + +## Capabilities + +### New Capabilities + +### Modified Capabilities +- `design-system-governance`: Extend the reusable design-system contract to cover premium authenticated workspace/admin primitives and motion/accessibility behavior. +- `shell-bootstrap-and-navigation-readiness`: Extend shell/navigation visual requirements for a premium workspace rail and command-center header while preserving deterministic role-aware navigation. + +## Impact + +- Affected code is limited to frontend Angular workspace/admin components, shared EchoEd design-system styles, and OpenSpec artifacts. +- No backend API changes. +- No authorization changes. +- No route semantic changes. +- No education runtime behavior changes. +- Existing frontend tests and production build remain the verification boundary. diff --git a/openspec/changes/echoed-v2-content-admin-experience-polish/specs/design-system-governance/spec.md b/openspec/changes/echoed-v2-content-admin-experience-polish/specs/design-system-governance/spec.md new file mode 100644 index 0000000..2fc8855 --- /dev/null +++ b/openspec/changes/echoed-v2-content-admin-experience-polish/specs/design-system-governance/spec.md @@ -0,0 +1,34 @@ +## ADDED Requirements + +### Requirement: Premium workspace primitives +The platform SHALL provide reusable premium workspace/admin UI primitives for shell, page, hero, card, panel, grid, metric, status badge, action row, empty state, form card, list card, timeline, and button patterns. + +#### Scenario: Workspace page uses shared primitives +- **WHEN** a workspace/admin page renders a hero, metric, list, form, or empty state +- **THEN** it uses shared premium primitives or equivalent shared class patterns rather than isolated ad hoc styling + +#### Scenario: Workspace cards are interactive +- **WHEN** a user hovers, focuses, or activates a workspace card or action +- **THEN** the interaction follows consistent hover lift, focus ring, and press feedback behavior + +### Requirement: Premium workspace motion and accessibility +The platform SHALL apply EchoEd motion and accessibility conventions to workspace/admin polish without reducing readability or keyboard operability. + +#### Scenario: User prefers reduced motion +- **WHEN** the user's environment requests reduced motion +- **THEN** workspace/admin page entrance, card stagger, hover lift, and decorative motion are disabled or simplified + +#### Scenario: User navigates by keyboard +- **WHEN** the user tabs through workspace/admin navigation, forms, and actions +- **THEN** focus states remain visible, high-contrast, and aligned with the premium design language + +### Requirement: Workspace content hierarchy +The platform SHALL use a consistent workspace/admin content hierarchy with page heroes, meaningful status badges, premium cards/lists, and clear primary and secondary action styling. + +#### Scenario: User opens any V2 workspace route +- **WHEN** a user opens a workspace/admin route +- **THEN** the page presents a clear eyebrow, title, value statement, and relevant actions or status badges before detailed content + +#### Scenario: Page has no data +- **WHEN** a workspace/admin collection is empty +- **THEN** the page shows a polished empty state with explanatory copy and relevant available actions instead of a raw blank region diff --git a/openspec/changes/echoed-v2-content-admin-experience-polish/specs/shell-bootstrap-and-navigation-readiness/spec.md b/openspec/changes/echoed-v2-content-admin-experience-polish/specs/shell-bootstrap-and-navigation-readiness/spec.md new file mode 100644 index 0000000..6166797 --- /dev/null +++ b/openspec/changes/echoed-v2-content-admin-experience-polish/specs/shell-bootstrap-and-navigation-readiness/spec.md @@ -0,0 +1,30 @@ +## ADDED Requirements + +### Requirement: Premium workspace navigation presentation +The authenticated shell SHALL present V2 workspace navigation as a premium workspace rail with icon-led items, clear active states, readable labels, refined grouping, and a polished profile footer while preserving deterministic role-aware navigation. + +#### Scenario: Sidebar renders workspace navigation +- **WHEN** the authenticated shell renders the workspace sidebar after bootstrap has resolved +- **THEN** navigation items show meaningful icons, unclipped labels, clear grouping, and a visible active state for the current route + +#### Scenario: Sidebar is collapsed +- **WHEN** the sidebar is displayed in a collapsed or narrow state +- **THEN** the rail remains usable with icon-first affordances, accessible labels, and no clipped visible text + +### Requirement: Premium command-center header presentation +The authenticated shell SHALL present a polished command-center header with clear breadcrumb/title hierarchy, refined organization selector styling, premium guided-tour action treatment, and improved user avatar presentation. + +#### Scenario: Workspace route renders header +- **WHEN** a user opens a workspace/admin route in the authenticated shell +- **THEN** the header clearly communicates the current workspace context and primary command actions without changing route or authorization semantics + +#### Scenario: Organization selector is available +- **WHEN** the organization selector is rendered +- **THEN** it is visually integrated with the premium header and remains keyboard accessible + +### Requirement: Premium workspace shell background +The authenticated workspace shell SHALL use a refined premium background and content container system that supports readability, responsive layouts, and consistent page max-widths. + +#### Scenario: Workspace page loads +- **WHEN** a workspace/admin page renders inside the authenticated shell +- **THEN** the background, content max-width, spacing, and surface hierarchy feel visually consistent with the approved premium EchoEd direction diff --git a/openspec/changes/echoed-v2-content-admin-experience-polish/tasks.md b/openspec/changes/echoed-v2-content-admin-experience-polish/tasks.md new file mode 100644 index 0000000..c2f3e69 --- /dev/null +++ b/openspec/changes/echoed-v2-content-admin-experience-polish/tasks.md @@ -0,0 +1,34 @@ +## 1. Shared Premium Workspace System + +- [x] 1.1 Add or refine shared `ee-*` workspace/admin classes for shell, page, hero, cards, panels, grids, metrics, status badges, action rows, empty states, forms, lists, timelines, and button variants. +- [x] 1.2 Add motion, hover, press, focus, responsive, and reduced-motion behavior for the shared workspace/admin classes. + +## 2. Shell Navigation and Header + +- [x] 2.1 Polish the authenticated sidebar into an icon-led premium workspace rail with clearer grouping, active states, collapsed behavior, readable labels, and a refined profile footer. +- [x] 2.2 Polish the top command-center header with breadcrumb/title hierarchy, organization selector styling, premium guided-tour CTA treatment, and improved avatar presentation. +- [x] 2.3 Apply the premium workspace shell background and consistent content container behavior to authenticated workspace routes. + +## 3. Workspace Command Center + +- [x] 3.1 Polish `/workspace` as a command center with a clear Knowledge Sources -> Artifacts -> Products -> Review -> Learners -> Analytics flow. +- [x] 3.2 Improve `/workspace` product health, knowledge pipeline, review queue, learner activity, quick actions, and recent activity sections. + +## 4. Content and Product Workspace Pages + +- [x] 4.1 Polish `/workspace/product-studio` as the flagship product creation studio with workflow stepper, product type cards, improved forms, draft checklist, disabled AI generation messaging, and current products section. +- [x] 4.2 Polish `/workspace/projects` so project cards communicate source count, artifact count, product count, generation run count, status, and next action where existing data supports it. +- [x] 4.3 Polish `/workspace/products`, `/workspace/knowledge-sources`, and `/workspace/artifacts` with premium cards/lists, type badges, status badges, traceability copy, actions, and empty states. + +## 5. Governance, Access, Analytics, Commercial, Learners, and Settings + +- [x] 5.1 Polish `/workspace/review-center` as a trust and governance command center with queue panels, guardrail copy, and approval/rejection action styling while preserving review behavior. +- [x] 5.2 Polish `/workspace/access` with improved grant form layout, product access explanation, existing grants list, and revoke action styling. +- [x] 5.3 Polish `/workspace/analytics` and `/workspace/commercial` with SaaS-style metric cards, grouped panels, product health/readiness sections, placeholder billing messaging, and clear hierarchy. +- [x] 5.4 Polish `/workspace/learners` and `/workspace/settings` with modern section cards, list layouts, settings forms, save actions, accessibility controls, spacing, and hierarchy. + +## 6. Verification + +- [x] 6.1 Run OpenSpec validation for `echoed-v2-content-admin-experience-polish`. +- [ ] 6.2 Run relevant frontend tests. +- [x] 6.3 Run the frontend production build.