From 7b341baea56697c4d4f4f2492805b76899821f8e Mon Sep 17 00:00:00 2001 From: Anthony Wright Date: Mon, 13 Jul 2026 20:54:48 -0500 Subject: [PATCH] content studio changes for content admin --- docs/ux-overhaul/backend-gap-register.md | 9 ++ .../ux-overhaul/production-route-migration.md | 16 ++- docs/ux-overhaul/shared-component-status.md | 10 ++ .../studio-accessibility-review.md | 25 ++++ docs/ux-overhaul/studio-action-risk-matrix.md | 16 +++ .../studio-activity-authoring-matrix.md | 23 ++++ .../ux-overhaul/studio-asset-data-contract.md | 21 ++++ .../studio-content-governance-review.md | 25 ++++ docs/ux-overhaul/studio-content-validation.md | 23 ++++ .../ux-overhaul/studio-curriculum-ordering.md | 22 ++++ .../studio-lesson-data-contract.md | 25 ++++ docs/ux-overhaul/studio-production-audit.md | 80 ++++++++++++ .../studio-production-implementation.md | 66 ++++++++++ .../studio-program-data-contract.md | 25 ++++ .../studio-publishing-data-contract.md | 35 ++++++ docs/ux-overhaul/studio-responsive-review.md | 17 +++ docs/ux-overhaul/studio-review-workflow.md | 26 ++++ .../studio-source-data-contract.md | 27 ++++ docs/ux-overhaul/studio-terminology-map.md | 23 ++++ frontend/package.json | 3 +- frontend/src/app/app.routes.spec.ts | 28 +++++ frontend/src/app/app.routes.ts | 27 ++++ .../echo-sidebar.component.spec.ts | 11 +- .../studio-content-detail.component.html | 14 +++ .../studio-content-detail.component.scss | 6 + .../studio-content-detail.component.spec.ts | 37 ++++++ .../studio-content-detail.component.ts | 58 +++++++++ .../studio-create.component.html | 31 +++++ .../studio-create.component.scss | 7 ++ .../studio-create/studio-create.component.ts | 107 ++++++++++++++++ .../studio-draft-detail.component.html | 1 + .../studio-draft-detail.component.scss | 7 ++ .../studio-draft-detail.component.ts | 18 +++ .../studio-library.component.html | 53 ++++++++ .../studio-library.component.scss | 1 + .../studio-library.component.spec.ts | 45 +++++++ .../studio-library.component.ts | 118 ++++++++++++++++++ .../studio-overview.component.html | 73 +++++++++++ .../studio-overview.component.scss | 1 + .../studio-overview.component.spec.ts | 42 +++++++ .../studio-overview.component.ts | 57 +++++++++ .../studio-project-detail.component.html | 18 +++ .../studio-project-detail.component.scss | 11 ++ .../studio-project-detail.component.ts | 70 +++++++++++ .../studio-review.component.html | 18 +++ .../studio-review.component.scss | 3 + .../studio-review.component.spec.ts | 33 +++++ .../studio-review/studio-review.component.ts | 70 +++++++++++ .../services/shell-navigation.service.spec.ts | 7 +- .../app/services/shell-navigation.service.ts | 24 ++-- frontend/src/styles/_studio-production.scss | 104 +++++++++++++++ .../tests/demo/studio-content-smoke.spec.ts | 69 ++++++++++ .../tasks.md | 2 +- 53 files changed, 1666 insertions(+), 22 deletions(-) create mode 100644 docs/ux-overhaul/studio-accessibility-review.md create mode 100644 docs/ux-overhaul/studio-action-risk-matrix.md create mode 100644 docs/ux-overhaul/studio-activity-authoring-matrix.md create mode 100644 docs/ux-overhaul/studio-asset-data-contract.md create mode 100644 docs/ux-overhaul/studio-content-governance-review.md create mode 100644 docs/ux-overhaul/studio-content-validation.md create mode 100644 docs/ux-overhaul/studio-curriculum-ordering.md create mode 100644 docs/ux-overhaul/studio-lesson-data-contract.md create mode 100644 docs/ux-overhaul/studio-production-audit.md create mode 100644 docs/ux-overhaul/studio-production-implementation.md create mode 100644 docs/ux-overhaul/studio-program-data-contract.md create mode 100644 docs/ux-overhaul/studio-publishing-data-contract.md create mode 100644 docs/ux-overhaul/studio-responsive-review.md create mode 100644 docs/ux-overhaul/studio-review-workflow.md create mode 100644 docs/ux-overhaul/studio-source-data-contract.md create mode 100644 docs/ux-overhaul/studio-terminology-map.md create mode 100644 frontend/src/app/pages/studio-content-detail/studio-content-detail.component.html create mode 100644 frontend/src/app/pages/studio-content-detail/studio-content-detail.component.scss create mode 100644 frontend/src/app/pages/studio-content-detail/studio-content-detail.component.spec.ts create mode 100644 frontend/src/app/pages/studio-content-detail/studio-content-detail.component.ts create mode 100644 frontend/src/app/pages/studio-create/studio-create.component.html create mode 100644 frontend/src/app/pages/studio-create/studio-create.component.scss create mode 100644 frontend/src/app/pages/studio-create/studio-create.component.ts create mode 100644 frontend/src/app/pages/studio-draft-detail/studio-draft-detail.component.html create mode 100644 frontend/src/app/pages/studio-draft-detail/studio-draft-detail.component.scss create mode 100644 frontend/src/app/pages/studio-draft-detail/studio-draft-detail.component.ts create mode 100644 frontend/src/app/pages/studio-library/studio-library.component.html create mode 100644 frontend/src/app/pages/studio-library/studio-library.component.scss create mode 100644 frontend/src/app/pages/studio-library/studio-library.component.spec.ts create mode 100644 frontend/src/app/pages/studio-library/studio-library.component.ts create mode 100644 frontend/src/app/pages/studio-overview/studio-overview.component.html create mode 100644 frontend/src/app/pages/studio-overview/studio-overview.component.scss create mode 100644 frontend/src/app/pages/studio-overview/studio-overview.component.spec.ts create mode 100644 frontend/src/app/pages/studio-overview/studio-overview.component.ts create mode 100644 frontend/src/app/pages/studio-project-detail/studio-project-detail.component.html create mode 100644 frontend/src/app/pages/studio-project-detail/studio-project-detail.component.scss create mode 100644 frontend/src/app/pages/studio-project-detail/studio-project-detail.component.ts create mode 100644 frontend/src/app/pages/studio-review/studio-review.component.html create mode 100644 frontend/src/app/pages/studio-review/studio-review.component.scss create mode 100644 frontend/src/app/pages/studio-review/studio-review.component.spec.ts create mode 100644 frontend/src/app/pages/studio-review/studio-review.component.ts create mode 100644 frontend/src/styles/_studio-production.scss create mode 100644 frontend/tests/demo/studio-content-smoke.spec.ts diff --git a/docs/ux-overhaul/backend-gap-register.md b/docs/ux-overhaul/backend-gap-register.md index fb8a649..4d6068d 100644 --- a/docs/ux-overhaul/backend-gap-register.md +++ b/docs/ux-overhaul/backend-gap-register.md @@ -15,3 +15,12 @@ Date: 2026-07-13 | Admin | Audit high-impact actions | Confirmation only | No audit log/event contract | Destructive/privilege changes lack accountability | No, but operational risk | Administrative audit events | High | | Admin | Badge lifecycle | Create/read only | No edit/archive/delete/criteria relation | Credential meaning cannot be governed | Partial | Badge lifecycle and criteria contract | Medium | | Admin/super admin | Consistent privilege model | Role-accurate limitation state | Exact checks conflict with presumed super-admin inheritance | Name can cause unsafe assumptions | Partial | Explicit platform permission policy replacing name inference | High | +| Content admin | Manage complete course curriculum | Canonical Studio shows course wrappers and readiness context only | Course list/detail and unit/lesson/activity CRUD exclude `content_admin`; isolated authoring/version endpoints do not form a scoped complete editor | Joining incompatible endpoints could expose or mutate another organization’s course | Yes for full course authoring | Organization-scoped curriculum authoring contract with ownership checks | Critical | +| Content admin | Manage programs and course order | Program/path wrappers are read/governance only | Program create is admin/teacher-only; no program update, membership-edit, reorder, archive, or delete API | Unscoped program reads and guessed ordering risk cross-org disclosure or corruption | Yes | Scoped program and learning-path authoring contract | High | +| Content admin | Edit and preview lessons safely | Content drafts preview without runtime calls | Lesson CRUD excludes content admin; no author preview endpoint isolated from learner progress/session behavior | Reusing learner runtime could create progress or reveal unpublished/teacher-only content | Yes | Content-admin lesson editor and stateless preview contract | Critical | +| Content admin | Author activities and assessments | No unsupported authoring controls | Activity/assessment mutations exclude content admin; activity content schema and assessment edit/delete are incomplete | Incorrect answer exposure and inconsistent scoring/publishing | Yes | Versioned activity and assessment authoring contracts | High | +| Content admin | Maintain bibliographic and rights metadata | Source wrappers support title/type/reference/citation only | No author/publisher/date/classification/rights/license/edit/delete/usage lookup | Missing attribution and rights context; cannot prove unused state | Partial | Source bibliography, rights, association, and usage contract | High | +| Content admin | Manage learner-facing assets | No asset/upload navigation | Uploads exclude content admin and have no library, type/size policy, alt text, rights, usage, replace, or delete APIs | Unsafe files, inaccessible images, unclear rights, destructive dependency risk | Yes | Scoped asset library and governed upload contract | Critical | +| Content admin | Review with accountability | Confirmed wrapper state changes; no comments/history claims | No reviewer assignment, comments, decision rationale, role separation, or audit events | Approval cannot be attributed or reconstructed | Partial | Content review assignments, rationale, and audit events | High | +| Content admin | Safely unpublish or roll back | Public publishing only; no unpublish control | Product publish has no unpublish/rollback/dependency validation endpoint | Public visibility may not be reversibly corrected through Studio | Yes for emergency correction | Reversible publishing lifecycle with dependency validation | Critical | +| Content admin | Collaborate and recover versions | No collaborative/version-history UI | No content ownership, locking, autosave, merge, or broadly usable version-history contract | Lost updates and unclear accountability | Yes for collaboration, not current wrapper work | Content ownership and version-history proposal | Medium | diff --git a/docs/ux-overhaul/production-route-migration.md b/docs/ux-overhaul/production-route-migration.md index 93453cb..c64dd45 100644 --- a/docs/ux-overhaul/production-route-migration.md +++ b/docs/ux-overhaul/production-route-migration.md @@ -1,6 +1,6 @@ # Production Route Migration -Phase 1 preserves route compatibility and establishes canonical role destinations without deleting deep links. Phase 2 begins the student `/learn` migration while keeping legacy student deep links available. Phase 3 adds canonical teacher `/teach` routes while preserving `/home` and workspace cohort deep links. Phase 4 adds the guarded `/admin` family and retains legacy Admin deep links. +Phase 1 preserves route compatibility and establishes canonical role destinations without deleting deep links. Phase 2 begins the student `/learn` migration while keeping legacy student deep links available. Phase 3 adds canonical teacher `/teach` routes while preserving `/home` and workspace cohort deep links. Phase 4 adds the guarded `/admin` family and retains legacy Admin deep links. Phase 5 adds the guarded `/studio` family over the supported V2 content-operations APIs while retaining all `/workspace/**` routes. | Current route | Canonical route | Role | Redirect behavior | Guard behavior | Migration phase | Removal criteria | Deep-link risk | | --- | --- | --- | --- | --- | --- | --- | --- | @@ -20,6 +20,15 @@ Phase 1 preserves route compatibility and establishes canonical role destination | `/learn/resources` | `/learn/resources` | student | No redirect | `HomeSessionGuard`; no API | Phase 2 partial | Remove only if a supported resources surface is not planned | Low | | `/learn/lesson/:id` | `/learn/lesson/:id` | student | No redirect; `:id` remains a student unit progress id | `HomeSessionGuard`; governed segment restore unchanged | Phase 2 | None planned | Low | | `/workspace` | `/workspace` | content_admin | No redirect | `HomeSessionGuard` plus existing role guards | Phase 1 | None planned | Medium | +| `/workspace` | `/studio` | content_admin | No redirect; login and canonical navigation now use `/studio` | `HomeSessionGuard` plus `RoleGuard` content-admin-only on canonical route | Phase 5 | Keep legacy until bookmarks and broad creator-role use are migrated | Medium | +| `/workspace/projects` and `/:projectId` | `/studio/projects` and `/:projectId` | content_admin | Legacy routes remain; canonical links use `/studio` | Canonical routes are content-admin-only; V2 backend enforces workspace scope | Phase 5 | Add redirects only after non-Studio creator usage is separated | Medium | +| `/workspace/product-studio` | `/studio/create` | content_admin | Legacy route remains | Canonical route excludes unsupported runtime course selector and uses V2 create APIs | Phase 5 | Legacy Product Studio remains for compatibility | High | +| `/workspace/products` and `/:productId` | `/studio/content` and `/:productId` | content_admin | Legacy routes remain | Canonical routes are content-admin-only; V2 backend scope remains authoritative | Phase 5 | Remove only after all creator roles have appropriate destinations | Medium | +| `/workspace/knowledge-sources` | `/studio/sources` | content_admin | Legacy route remains | Canonical read is content-admin-only; creation remains project-scoped | Phase 5 | Keep until legacy navigation usage is retired | Low | +| `/workspace/artifacts` and `/:artifactId` | `/studio/drafts` and `/:artifactId` | content_admin | Legacy route remains | Canonical route uses content-draft terminology and V2 organization scope | Phase 5 | Keep until legacy technical deep links are migrated | Medium | +| `/workspace/review-center` | `/studio/review` | content_admin | Legacy route remains | Canonical route confirms mutations and waits for API success | Phase 5 | Keep until broad creator-role review ownership is resolved | Medium | +| `/workspace/products` | `/studio/publishing` | content_admin | No redirect; this is a filtered state view | Public publish mutation remains on canonical content detail | Phase 5 | None planned | Low | +| `/workspace/product-studio/courses` | No canonical route | content_admin/org_admin legacy guard | Legacy broken screen retained | Backend course list/create dependencies reject these roles | Phase 5 audited | Remove only after a complete authorized course authoring API and replacement exist | High | | `/workspace/learners` | `/workspace/learners` | org_admin | No redirect | Existing creator-role guard | Phase 1 | Replace only if organization home becomes a dedicated route | Medium | | `/workspace/commercial` | `/workspace/commercial` with visible label `Community` | admin/content_admin/org_admin/teacher/instructor via existing creator role set | No redirect | Existing creator-role guard | Phase 1 | Add alias and deprecation only after community surface is redesigned | Medium | | `/home/admin/users` and `/workspace/learners/users` | `/admin/users` | admin | Legacy routes remain; canonical navigation uses `/admin/users` | `HomeSessionGuard` plus `RoleGuard` admin-only | Phase 4 | Add explicit redirects after bookmark/usage validation | Medium | @@ -42,7 +51,7 @@ Phase 1 preserves route compatibility and establishes canonical role destination - `student`: `/learn` - `teacher`: `/teach` - `instructor`: `/teach` -- `content_admin`: `/workspace` +- `content_admin`: `/studio` - `org_admin`: `/workspace/learners` - `admin`: `/admin` - `super_admin`: `/admin` with partial compatibility state @@ -59,3 +68,6 @@ Phase 1 preserves route compatibility and establishes canonical role destination - Teacher course preview intentionally avoids `/api/start-course`; it uses `/api/courses/{id}` only. - Phase 4 does not add moderation or platform-settings routes because no safely authorized capability exists. - `super_admin` is not treated as inherited `admin`; unsupported child routes stay guarded and hidden. +- Phase 5 keeps `/workspace/**` intact because those routes are shared by several creator roles and demo tooling. +- Canonical Studio intentionally omits lesson, assessment, asset, upload, and curriculum-ordering routes because the current content-admin APIs do not safely support those workflows. +- Studio draft preview reads only V2 artifact content and does not call learner runtime, enrollment, lesson-session, or progress APIs. diff --git a/docs/ux-overhaul/shared-component-status.md b/docs/ux-overhaul/shared-component-status.md index cd54b90..f4f2bb6 100644 --- a/docs/ux-overhaul/shared-component-status.md +++ b/docs/ux-overhaul/shared-component-status.md @@ -65,3 +65,13 @@ - Organization invite revocation. - Access grant revoke flows. - Product publish/review transitions. +## Phase 5 Studio adoption + +- `EchoLoadingStateComponent`: Studio overview, libraries, creation, project, content, and draft detail. +- `EchoStatePanelComponent`: Studio empty, partial/error, retry, and missing-record states. +- `EchoConfirmationDialogComponent`: wrapper publishing and all review-state decisions with focus trapping/restoration. +- Shared shell/navigation: canonical `/studio` destination and content-admin-only Studio section. +- Shared semantic tokens: Studio production layout, forms, records, status text, focus, and mobile targets. +- Existing Admin responsive table styles were not reused as a separate Studio system; Studio uses content-oriented responsive records and forms. + +Studio does not add an asset picker, reorder control, block editor, tab system, or upload-progress component because no verified content-admin API requires those controls. diff --git a/docs/ux-overhaul/studio-accessibility-review.md b/docs/ux-overhaul/studio-accessibility-review.md new file mode 100644 index 0000000..582fbec --- /dev/null +++ b/docs/ux-overhaul/studio-accessibility-review.md @@ -0,0 +1,25 @@ +# Studio Accessibility Review + +Date: 2026-07-13 + +## Implemented + +- One page `h1` with logical section headings on canonical Studio screens. +- Labeled search, select, text, textarea, and file-independent forms with native required semantics. +- Minimum 44px controls and visible tokenized focus rings. +- Result counts, creation success, review success, and errors use live status/alert semantics. +- Shared loading, empty, error, and confirmation components provide consistent announcements. +- Publish and review dialogs trap focus, start on Cancel, support Escape, lock while saving, and restore focus. +- Status meaning is written in text and not conveyed by color alone. +- Responsive records preserve the same information and actions without a pointer-only table. +- Preview is a normal document flow with pre-wrapped text and no inaccessible authoring canvas. +- No drag-only reorder interaction is present. +- Reduced-motion behavior comes from the shared shell and production component foundations. + +## Review results + +Unit coverage verifies confirmation-before-mutation, failure preservation, visible empty/error states, labeled filters, and role-guarded routes. The seeded smoke includes keyboard focus and a 390px overflow assertion when executable. + +## Remaining manual work + +Screen-reader announcement timing, browser zoom at 200%, contrast under user overrides, long real citations, and mobile virtual-keyboard behavior require browser/manual validation. Upload progress, image alt-text forms, and accessible reordering are deferred because no supported Studio APIs exist for those controls. diff --git a/docs/ux-overhaul/studio-action-risk-matrix.md b/docs/ux-overhaul/studio-action-risk-matrix.md new file mode 100644 index 0000000..cb5c6e9 --- /dev/null +++ b/docs/ux-overhaul/studio-action-risk-matrix.md @@ -0,0 +1,16 @@ +# Studio Action Risk Matrix + +Date: 2026-07-13 + +| Action | Object | Backend behavior | Risk | Reversible | Confirmation | Permission and recovery | +| --- | --- | --- | --- | --- | --- | --- | +| Create project | Project | Inserts organization-scoped project | Consequential | No delete API | No; explicit submit | Manage visible workspace; preserve form on failure | +| Create learning offering | Product wrapper | Inserts draft wrapper | Consequential | No delete API | No; form explains wrapper-only scope | Manage visible workspace; preserve form on failure | +| Add source record | KnowledgeSource | Inserts project-scoped metadata | Consequential | No edit/delete API | No; explicit submit | Project must belong to workspace; preserve form on failure | +| Create content draft | Artifact | Inserts reviewable wrapper, optionally linked to source/product | Consequential | No delete API | No; explicit submit | Same-project relationships; preserve form on failure | +| Change review state | Artifact or Product | Replaces `status` and `review_state` | Privilege/content-governance changing | Can set another supported state, but no audit | Yes for every state | Manage visible workspace; dialog remains with error after failure | +| Reject draft | Artifact | Sets rejected state; does not delete | Consequential | Another state can be set | Danger confirmation | Explain content and sources remain | +| Archive offering | Product | Sets archived state via review endpoint | Consequential | No dedicated restore semantics | Danger confirmation | Explain audit/restore gap | +| Publish publicly | Product | Sets published/public wrapper state and timestamps | High impact | No unpublish endpoint | Publish confirmation | Explain public visibility and unchanged lesson/access rules | + +No V2 project, product, source, or artifact delete endpoint exists. Studio displays no delete buttons and does not imply archive is deletion. diff --git a/docs/ux-overhaul/studio-activity-authoring-matrix.md b/docs/ux-overhaul/studio-activity-authoring-matrix.md new file mode 100644 index 0000000..6feca7e --- /dev/null +++ b/docs/ux-overhaul/studio-activity-authoring-matrix.md @@ -0,0 +1,23 @@ +# Studio Activity Authoring Matrix + +Date: 2026-07-13 + +The legacy draft model names these activity identifiers. Current CRUD is restricted to admin/teacher, so canonical Studio exposes none as editable controls. + +| Type | Stored authoring fields | Learner rendering evidence | Validation/scoring | Preview | Accessibility concern | +| --- | --- | --- | --- | --- | --- | +| `text`, `story` | title, content, order | Text presentation | Non-empty content is not centrally validated | Student renderer exists | Heading/list semantics and readable line length | +| `video`, `audio` | title, content URL/reference, order | Media presentation varies | No verified rights/caption/transcript validation | Partial | Captions, transcript, controls, autoplay | +| `storybook` | title, content, ordered image pages | Storybook pages | Page/image validation is limited | Partial | Image alternative text is not modeled | +| `coloring` | title, uploaded/reference content | Image activity | Upload accepts file without documented type/size validation | Partial | Alt text and non-pointer alternative absent | +| `quiz` | title, serialized content, order | Lesson viewer validates answers before advance | Separate assessment system also exists; no stable Studio authoring schema verified | Learner renderer only | Labels, errors, answer exposure | +| `reflection`, `discussion` | title, content, order | Instruction/prompt presentation | No persistence contract verified | Presentation only | Clear instructions; do not imply saved responses | +| `checkpoint` | title, content, order | Runtime-dependent | No verified authoring/scoring contract | Unsupported in Studio | State announcement | + +## Assessment model + +Assessments expose title, description, scope relationships, assessment/availability/delivery states, passing score, maximum attempts, policy/lifecycle metadata, and ordered questions. Questions expose prompt, type, choices, explanation, points, and order. Creation is admin/teacher-only; no edit/delete authoring endpoints are present. + +## Decision + +Studio does not invent rubrics, manual grading, partial credit, retries, answer configuration, or activity editors. Content drafts may use artifact types `assessment_seed` or `lesson_draft`, but those records are not described as executable assessments or activities. diff --git a/docs/ux-overhaul/studio-asset-data-contract.md b/docs/ux-overhaul/studio-asset-data-contract.md new file mode 100644 index 0000000..6026a3f --- /dev/null +++ b/docs/ux-overhaul/studio-asset-data-contract.md @@ -0,0 +1,21 @@ +# Studio Asset and Upload Data Contract + +Date: 2026-07-13 + +## Current uploads + +| Endpoint | Purpose | Authorized roles | Response | +| --- | --- | --- | --- | +| `POST /api/upload/coloring` | Coloring image | admin, teacher | Public `file_path` | +| `POST /api/upload/storybook` | Storybook page image | admin, teacher | Public `file_path` | +| `POST /api/upload/badge` | Badge image | admin | Public `file_path` | + +The endpoints generate UUID filenames and copy bytes to configured directories. They do not expose a content-admin asset library, search, metadata, alt text, caption, rights/license, usage lookup, replacement, deletion, documented accepted types, documented size limits, or upload progress. + +## Studio decision + +Canonical Studio has no Assets navigation or upload control. A `KnowledgeSource` may record `source_type: upload`, but this is metadata only and does not upload a file. + +## Required future contract + +A future asset capability must define MIME allowlists, size limits, malware/content handling, organization scope, alt text, rights notes, usage relationships, safe replacement/deletion semantics, and response errors before learner-facing uploads can be exposed to content administrators. diff --git a/docs/ux-overhaul/studio-content-governance-review.md b/docs/ux-overhaul/studio-content-governance-review.md new file mode 100644 index 0000000..8ed3af0 --- /dev/null +++ b/docs/ux-overhaul/studio-content-governance-review.md @@ -0,0 +1,25 @@ +# Studio Content Governance Review + +Date: 2026-07-13 + +## Stewardship decisions + +- Source titles, citations, and references remain visible in content context; missing bibliographic fields are not fabricated. +- Source records receive no trust, reliability, cultural-appropriateness, or historical-accuracy score. +- Draft content remains organization-scoped and is clearly separated from learner-visible lessons. +- Public wrapper publishing states that lesson governance, enrollment, and access remain unchanged. +- Learner and class analytics are omitted from canonical Studio navigation because they are not required for content stewardship. +- Raw workspace, project, source, product, and artifact identifiers are not used as primary visible labels. +- Generation prompts/errors remain on legacy technical screens and are not promoted into Studio. + +## Rights and media + +Current source and asset models do not include rights/license, creator attribution, alt text, caption, sensitive-imagery warning, or usage lookup. Studio cannot make legal or rights-clearance claims. Asset upload is withheld from content administrators until those controls and file-policy contracts exist. + +## Historical and cultural integrity + +Reviewers should be able to inspect source identity and citation before approving a draft wrapper. The UI encourages source association but does not automate historical interpretation. Community contribution attribution, culturally sensitive review, age-appropriateness review, and teacher-only note separation require future explicit data contracts. + +## Privacy + +V2 records remain scoped by backend organization membership. Lists minimize unpublished body content; full draft content appears only on detail. Source URI/citation and draft bodies may contain sensitive information and are not included in errors or client logging added by this phase. diff --git a/docs/ux-overhaul/studio-content-validation.md b/docs/ux-overhaul/studio-content-validation.md new file mode 100644 index 0000000..df22913 --- /dev/null +++ b/docs/ux-overhaul/studio-content-validation.md @@ -0,0 +1,23 @@ +# Studio Content Validation + +Date: 2026-07-13 + +## Verified validation + +The lesson-governance evaluator identifies concrete missing fields: title; objective or learning objectives; key concepts; hook; content; guided practice; independent practice; assessment; and at least one source with a citation and valid optional HTTP(S) URL. + +Course publish-readiness and safe-publish endpoints return explicit blocking issues and warnings, and the V2 review center summarizes their counts. Content administrators can see that summary when the course is organization-visible, but the canonical Studio cannot open a complete authorized lesson editor to resolve it. + +V2 create endpoints also validate supported product type, visibility, access model, source type, artifact type, workspace/project relationships, and linked source/product ownership. + +## Presentation rules + +- Studio shows only API-returned readiness text or concrete form requirements. +- Missing source citation is presented as a factual attention state, not a credibility score. +- API rejection is an error; the frontend does not reclassify it as a warning. +- Review and wrapper status are not treated as proof that lessons are publish-ready. +- No numeric readiness or completeness score is calculated. + +## Current limitations + +There is no content-admin validation endpoint spanning wrapper, source, asset, curriculum, assessment, and publishing dependencies. Alt-text, rights, file safety, program order, and wrapper-to-runtime coherence cannot be validated comprehensively. diff --git a/docs/ux-overhaul/studio-curriculum-ordering.md b/docs/ux-overhaul/studio-curriculum-ordering.md new file mode 100644 index 0000000..8ddb420 --- /dev/null +++ b/docs/ux-overhaul/studio-curriculum-ordering.md @@ -0,0 +1,22 @@ +# Studio Curriculum Ordering + +Date: 2026-07-13 + +## Verified ordering fields + +- `ProgramCourse.order` orders courses in a program. +- `Unit.order` orders units. +- `Lesson.order` orders lessons. +- `Activity.order` and `StorybookPage.order` order lesson content. + +## API and role boundary + +Legacy unit, lesson, activity, and program creation APIs are restricted to `admin` or `teacher`; the program API has no update/reorder endpoint. Course-version unit APIs also exclude `content_admin`. The V2 wrapper APIs do not expose curriculum ordering. + +## Production behavior + +Canonical Studio preserves backend ordering when records are returned, but does not expose drag-and-drop, move controls, or optimistic ordering. No order is persisted from the frontend because no complete authorized API exists. + +## Failure recovery requirement for future work + +Any future reorder contract must accept explicit ordered identifiers, validate organization/content ownership, return the confirmed order, preserve the previous order after failure, announce success/failure, and provide keyboard move controls. Drag-and-drop may only be an enhancement to those controls. diff --git a/docs/ux-overhaul/studio-lesson-data-contract.md b/docs/ux-overhaul/studio-lesson-data-contract.md new file mode 100644 index 0000000..7e5bc24 --- /dev/null +++ b/docs/ux-overhaul/studio-lesson-data-contract.md @@ -0,0 +1,25 @@ +# Studio Lesson Data Contract + +Date: 2026-07-13 + +## Existing lesson model + +The current lesson response can include: `id`, `title`, `objective`, `learning_objectives`, `key_concepts`, `teacher_notes`, `discussion_questions`, `hook`, `content`, `guided_practice`, `independent_practice`, `assessment`, `review_status`, `reviewed_by`, `order`, `duration_minutes`, sources, activities, readiness fields, revision metadata, and timestamps. + +Activities include `id`, `type`, `title`, `content`, `order`, and storybook pages. Lesson sources include `id`, `citation`, optional URL, and timestamp. + +## Authorization + +`GET /api/lessons/{id}` accepts admin, teacher, and student. Lesson list/create/update/delete accept admin and/or teacher. They do not authorize `content_admin`. The nested legacy course editor also uses admin/teacher mutations. + +## Studio behavior + +- Canonical Studio does not expose a false lesson editor. +- `lesson_draft` artifacts are labeled **Content drafts**, not learner lessons. +- Draft preview uses only artifact content and never calls start-course, lesson-session, enrollment, or progress APIs. +- Teacher-only versus learner-visible fields are not presented as editable until an authorized response/mutation contract exists. +- Technical identifiers are omitted from visible draft relationships. + +## Verified gap + +Content administrators need organization-scoped lesson read/write APIs, ownership checks, a stable content model, source association, activity/assessment association, save-failure recovery, and a preview response that cannot create runtime state. The existing endpoints do not satisfy that workflow. diff --git a/docs/ux-overhaul/studio-production-audit.md b/docs/ux-overhaul/studio-production-audit.md new file mode 100644 index 0000000..a7e8d49 --- /dev/null +++ b/docs/ux-overhaul/studio-production-audit.md @@ -0,0 +1,80 @@ +# Content Studio Production Audit + +Date: 2026-07-13 + +## Scope and conclusion + +The verified `content_admin` production surface is the organization-scoped V2 content-operations model: workspaces, projects, product wrappers, knowledge-source wrappers, artifacts, review state, wrapper publishing, and read-only V2 analytics. The legacy curriculum CRUD surface is not a coherent `content_admin` capability: course list/detail, units, lessons, activities, assessments, and uploads authorize `admin` and/or `teacher`, while the isolated course-authoring/version endpoints use organization roles but do not provide a safe complete editor contract. + +Phase 5 therefore migrates supported V2 content work into the visible **Studio** area and documents legacy curriculum authoring as a backend gap. It does not grant `content_admin` teacher or platform-admin capabilities. + +## Role and authorization findings + +| Finding | Code evidence | Production decision | +| --- | --- | --- | +| V2 records are scoped through visible workspace IDs derived from active organization membership. | `v2_platform.py::_visible_workspace_ids`, `_scope_platform_query` | Preserve backend scoping and do not infer broader visibility in the UI. | +| V2 create/review/publish mutations call workspace-management checks. | `_require_manage_workspace`, `_require_manage_platform_record` | Expose only these verified mutations with confirmed API states. | +| `/workspace/**` is broadly exposed to `admin`, `teacher`, `content_admin`, `org_admin`, and `instructor`. | `creatorRoles` in `app.routes.ts` | Keep legacy routes, but canonical `/studio/**` is guarded to `content_admin` only in this phase. | +| `/home/studio/courses` is guarded to `content_admin`/`org_admin`, but calls `GET /api/courses`, which rejects both roles. | `StudioCoursesComponent`; `courses.py::get_courses` | Do not migrate this broken screen into canonical Studio. | +| `/workspace/product-studio/courses/new` and edit are guarded to `admin`/`teacher`, not `content_admin`. | `app.routes.ts` | Treat as legacy teacher/admin course authoring, not Studio. | +| Course authoring/version endpoints accept organization content roles, but list/detail, nested CRUD, and ownership checks are inconsistent. | `/courses/authoring`, course-version endpoints, course/unit/lesson/activity routes | Document as incomplete and do not assemble a frontend-only authoring workflow. | +| Program creation, assessment creation, unit/lesson/activity CRUD, and coloring/storybook uploads reject `content_admin`. | `programs.py`, `assessments.py`, `units.py`, `lessons.py`, `activities.py`, `uploads.py` | No create/edit/upload controls in canonical Studio. | +| Review-center wrapper decisions and product publishing are real mutations. | V2 review-status and publish endpoints | Use accurate wrapper terminology and confirmation for high-impact state changes. | + +## Route and screen audit + +| Current route or screen | User goal | Authorized roles | APIs | Visible term / backend entity | Actions and capability | Review/publishing behavior | Main risks | Migration and ownership | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| `/workspace` | Understand content operations | Broad creator roles | `GET /api/analytics/workspace`, `GET /api/review-center` | Workspace command center / V2 workspace summaries | Read | Shows wrapper review/publishing counts | Generic product/AI language; all-or-nothing loading; oversized headings; weak mobile priority | Canonical `/studio`; belongs in Studio for `content_admin`. | +| `/workspace/projects` | Browse curriculum projects | Broad creator roles | `GET /api/projects` | Project knowledge pipeline / `Project` | Read | Project status only | Internal pipeline copy; generic collection states | Alias from `/studio/projects`; Studio. | +| `/workspace/projects/:projectId` | Organize sources and draft outputs | Broad creator roles | Project detail plus project products, sources, artifacts, generation runs; POST source/artifact | Knowledge pipeline / `Project`, `KnowledgeSource`, `Artifact` | Read/write source and artifact shells | New artifacts default to review-required | Raw relationship IDs; missing error/loading; shell terminology; no destructive actions | Canonical `/studio/projects/:projectId`; Studio. | +| `/workspace/product-studio` and `/create` | Create project and product wrappers | Broad creator roles | Workspaces, projects, products, `GET /api/courses`, POST project/product | Product Studio / V2 wrappers | Project/product create; attempted runtime-course link | Creates draft wrappers only | `GET /api/courses` fails for content admin; commercial/product language; unsupported AI emphasis; no failure state | Canonical `/studio/create`; remove unusable course selector for content admin; Studio. | +| `/workspace/product-studio/courses` | Manage courses | `content_admin`, `org_admin` in frontend | `GET/POST /api/courses` | Product drafts / `Course` | Screen claims list/create | No real state mapping | Both requests are backend-incompatible for these global roles; unlabeled inputs; fake Draft label | Do not migrate. Legacy broken route retained and documented. | +| `/workspace/product-studio/courses/new` and `/:courseId/edit` | Author full curriculum | `admin`, `teacher` | Legacy course CRUD with nested units/lessons/activities | Course wizard / `CourseDto` | Create/update full graph | Lesson governance resolved during save | Not available to content admin; destructive graph replacement on update; weak save recovery | Remains legacy teacher/admin authoring; not Studio. | +| `/workspace/products` | Browse packaged learning offerings | Broad creator roles | `GET /api/products` | Product catalog / `Product` wrapper | Read | Displays wrapper state | Commercial/internal terminology and generic collection component | Canonical `/studio/content`; visible terms depend on wrapper type; Studio. | +| `/workspace/products/:productId` | Inspect and publish a wrapper | Broad creator roles | GET product, PATCH publish | Product shell / `Product` | Read and publish public | Publish sets status `published`, may set review `approved`, and creates public visibility; does not publish lessons | No confirmation or error announcement; public consequence understated | Canonical `/studio/content/:productId`; Studio with confirmation. | +| `/workspace/knowledge-sources` | Browse source records | Broad creator roles | `GET /api/knowledge-sources` | Knowledge sources / `KnowledgeSource` wrapper | Read | Source status only | No search/filter/detail; generic collection; limited bibliographic fields | Canonical `/studio/sources`; Studio. Creation stays project-scoped. | +| `/workspace/artifacts` | Browse draft content outputs | Broad creator roles | `GET /api/artifacts` | Artifacts / `Artifact` | Read | Shows status and review state | “Artifact” is too generic; no state handling | Canonical `/studio/drafts`; visible “Content drafts”; Studio. | +| `/workspace/artifacts/:artifactId` | Inspect a draft output and traceability | Broad creator roles | `GET /api/artifacts/{id}` | Artifact registry / `Artifact` | Read | Read-only; decisions occur in review | Raw related IDs; no loading/error state; hover decoration | Canonical `/studio/drafts/:artifactId`; Studio. | +| `/workspace/review-center` | Review wrappers, drafts, and lesson readiness | Broad creator roles | GET review center; PATCH artifact/product review status | Review Center / composed queue | Read/write wrapper status | Supports artifact `draft`, `in_review`, `approved`, `rejected`, `needs_changes`; product `draft`, `in_review`, `approved`, `published`, `archived` | Immediate high-impact mutations; no confirmation, pending, success, or failure states; placeholder activity is not an audit log | Canonical `/studio/review`; Studio after safety migration. | +| `/workspace/product-studio/generation-runs*` | Inspect generation metadata | Broad creator roles | GET generation runs/detail | AI generation attempts / `GenerationRun` | Read only | None | No execution exists; prompts/errors may be sensitive; distracts from curriculum work | Keep legacy; omit canonical primary navigation. | +| `/workspace/access` | Manage learner access grants | Broad creator roles | Access-grant GET/POST/revoke | Access / `AccessGrant` | Read/write/revoke | Separate from content publishing | Belongs to Organization/access administration; personal identifiers; consequential revoke | Keep legacy; do not redesign in Studio. | +| `/workspace/analytics` | Inspect V2 operational metrics | Broad creator roles | Four V2 analytics endpoints | Product intelligence / aggregate wrappers/runtime | Read | Review/publishing counts | Includes learner engagement outside content-admin primary need; all-or-nothing loading | Keep legacy; Studio overview uses only content-operation summary fields. | +| `/workspace/commercial` | View community wrapper area | Broad creator roles | V2/public wrapper APIs | Community (route remains commercial) | Read | Varies | Not Content Studio | Community ownership; unchanged. | +| `/workspace/demo-readiness` | Run seeded demo walkthrough | Broad creator roles | Multiple demo APIs | Demo guide | Read/test actions | Demo-specific | Not production Studio IA | Keep legacy demo route. | +| `/workspace/settings`, `/settings/invites` | Preferences or org invitations | Broad creator / org roles | Preferences/invites | Settings/invitations | Read/write | None | Organization self-service, not Studio | Unchanged; Organization ownership. | +| `/workspace/learners/**` | Learner/class/access work | Broad creator plus nested guards | Course/program/section/user APIs | Learners | Mixed | None | Mixes Teacher, Admin, and Organization responsibilities | Unchanged and omitted from Studio. | +| `/workspace/lesson/:id` | Open learner lesson runtime | No child role guard beyond workspace | Lesson/progress APIs | Lesson view / runtime | Runtime behavior | Governed progress may be invoked | Preview could create or depend on learner progress; unauthorized content-admin API | Do not use for Studio preview. | + +## Data sensitivity + +- Workspace, project, product, source, and artifact queries must remain constrained by the backend’s active organization membership scope. +- Artifact bodies, source URIs/citations, generation prompts, and error messages can contain unpublished or sensitive content; lists should show only task-relevant summaries. +- Learner analytics and access grants are not required for primary Studio work and are omitted from canonical Studio navigation. +- Public publishing is consequential because it changes wrapper visibility. It does not override lesson governance, create enrollment, or publish runtime lessons. +- Review-center activity currently returns a placeholder message, not attributable audit history. The UI must not describe it as an audit log. + +## Loading, empty, error, accessibility, and responsive audit + +- Current inline V2 pages frequently use all-or-nothing `forkJoin`, omit retry, and provide no permission-specific state. +- Several screens use oversized headings, viewport-scaled type, negative letter spacing, rounded pill commands, hover lift, and dense two-column forms that conflict with the production design guidance. +- Review actions execute immediately and lack accessible confirmation, pending state, focus restoration, and live success/failure messaging. +- Collection screens do not provide responsive task-oriented records, search, or announced result counts. +- Project detail exposes backend IDs as the primary relationship label and lacks associated error text for failed forms. +- No supported content-admin ordering API exists, so no drag-only or keyboard reorder UI should be invented. + +## Destructive and consequential actions + +No V2 delete endpoints are present for projects, products, knowledge sources, or artifacts. The supported high-impact actions are wrapper review-state changes, public wrapper publishing, and access-grant revocation. Only review and publishing belong in Studio; both require precise confirmation and API-confirmed state. + +## Duplicate and misplaced surfaces + +- `StudioCoursesComponent` duplicates the legacy course wizard concept but is not authorized by its backend dependencies. +- The legacy course wizard belongs to the current admin/teacher authoring contract, not the verified `content_admin` surface. +- Access grants, invitations, learners, cohorts, and settings belong to Organization or Teach. +- Admin course oversight remains under `/admin` and must not gain Studio authoring controls. +- Generation-run metadata remains a technical legacy surface and is not promoted into the curriculum-focused Studio IA. + +## Migration recommendation + +Introduce guarded `/studio/**` routes for the supported V2 content surface and update `content_admin` navigation/default landing. Preserve `/workspace/**` deep links without broad deletion. Canonical Studio links should use education terms while requests continue to use the existing V2 models and endpoints. Unsupported course graph editing, programs, lessons, assessments, assets, and uploads remain documented gaps rather than nonfunctional screens. diff --git a/docs/ux-overhaul/studio-production-implementation.md b/docs/ux-overhaul/studio-production-implementation.md new file mode 100644 index 0000000..e5d2d4c --- /dev/null +++ b/docs/ux-overhaul/studio-production-implementation.md @@ -0,0 +1,66 @@ +# Content Studio Production Implementation + +Date: 2026-07-13 + +## Delivered + +- Canonical, `content_admin`-guarded routes under `/studio` for overview, creation, projects, content, courses, programs and paths, sources, drafts, review, and publishing. +- Role-accurate Studio navigation and `/studio` as the content-admin login destination; existing `/workspace/**` routes remain available for deep-link compatibility. +- A content-focused overview using supported V2 workspace analytics, explicit partial-failure handling, recent work, concrete state counts, and editorial shortcuts. +- Searchable and filterable responsive libraries for projects, product wrappers, course/program wrapper types, sources, drafts, and publishing states. +- Project detail with supported project-scoped source and draft creation. +- Product-wrapper oversight with review state, audience visibility, source coverage, access context, confirmed public publishing, and no optimistic state mutation. +- Draft detail and preview using the stored Artifact body without enrollment, lesson sessions, or learner-progress requests. +- Review queue for the backend's reviewable wrapper states, with exact confirmation language and server-confirmed state changes. +- Shared loading, empty, error, confirmation, live-status, focus, and responsive patterns across the canonical Studio family. +- Seeded Playwright coverage for the supported content-admin workflow and unauthorized student deep links. + +## Components Created + +`StudioOverviewComponent`, `StudioLibraryComponent`, `StudioCreateComponent`, `StudioContentDetailComponent`, `StudioProjectDetailComponent`, `StudioDraftDetailComponent`, and `StudioReviewComponent`; shared Studio production layout styles in `frontend/src/styles/_studio-production.scss`. + +## Components Migrated + +Route configuration, shell navigation, canonical role destination, sidebar expectations, and shared confirmation/state usage were adapted for Studio. Legacy workspace components and routes were retained rather than broadly rewritten or deleted. + +## APIs Used + +- `GET /api/analytics/workspace` +- `GET /api/workspaces` +- `GET/POST /api/projects` and `GET /api/projects/{id}` +- `GET/POST /api/products`, `GET /api/products/{id}`, `PATCH /api/products/{id}/review-status`, and `PATCH /api/products/{id}/publish` +- `GET/POST /api/knowledge-sources` and project-scoped source reads +- `GET/POST /api/artifacts`, `GET /api/artifacts/{id}`, project-scoped artifact reads, and `PATCH /api/artifacts/{id}/review-status` +- `GET /api/review-center` + +All authorization, organization scope, review state, visibility, and publishing results remain backend-authoritative. + +## Supported Behavior Boundaries + +The backend does not provide `content_admin` with a coherent, ownership-safe course/unit/lesson/activity/assessment CRUD surface. It also lacks a supported Studio asset/upload library, curriculum ordering contract, formal approval accountability, unpublish action, version history, review comments, reviewer assignment, and collaborative editing. Canonical Studio therefore presents the supported V2 content-operation wrappers and does not expose nonfunctional or authorization-inconsistent controls. + +Program and Course views are filtered V2 product-wrapper libraries, not full learner curriculum editors. Source records expose only current wrapper metadata; the API does not contain bibliographic, rights, citation, or related-lesson fields. Preview renders the stored draft body only and never enters the student runtime. + +## Backend Changes + +None. No schema, migration, endpoint, seed, authentication, JWT, or authorization behavior changed in Phase 5. + +## Verification + +- Frontend unit tests: `256 SUCCESS` (baseline was `245 SUCCESS`). +- TypeScript application compile: passes. +- Production build: passes; existing Sass mixed-declaration warnings and the existing initial-bundle budget warning remain. +- Playwright discovery: 5 tests in 3 files. +- Seeded browser execution: a disposable model-created SQLite database was seeded because Docker/PostgreSQL were unavailable. Student, Admin, Studio, and unauthorized deep-link scenarios pass in Chromium. Result: `5 passed`. +- Studio browser checks cover project/source/content navigation, search, safe draft preview, a cancelled public-publish confirmation, review, keyboard focus, and 390px horizontal overflow. +- OpenSpec strict validation: `overhaul-role-based-ui-ux-experience` is valid. + +## Known Limitations + +- Manual screen-reader timing, 200% browser zoom, user override contrast, mobile virtual keyboards, and very long real citations remain review items. +- No safe asset upload, lesson/activity/assessment editor, accessible reorder control, approval audit trail, or reversible unpublish workflow is exposed because its required backend contract is absent. +- Legacy workspace routes retain older terminology and broader creator-role assumptions until their non-Studio consumers receive canonical replacements. + +## Recommended Next Phase + +Proceed to organization-admin self-service after separately specifying the critical scoped curriculum-authoring contract. Do not fold those backend gaps into the organization UI phase. diff --git a/docs/ux-overhaul/studio-program-data-contract.md b/docs/ux-overhaul/studio-program-data-contract.md new file mode 100644 index 0000000..88bb588 --- /dev/null +++ b/docs/ux-overhaul/studio-program-data-contract.md @@ -0,0 +1,25 @@ +# Studio Program and Learning-Path Data Contract + +Date: 2026-07-13 + +## Verified models + +The legacy `Program` model exposes `id`, `title`, `description`, `organization_id`, `created_by`, timestamps, and ordered `ProgramCourse` links containing `course_id`, `order`, and `is_required`. `GET /api/programs` and detail are authenticated reads; `POST /api/programs` is restricted to `admin` and `teacher`. No update, delete, membership-edit, or reorder endpoint is present. + +The V2 `Product` wrapper can carry `program_id` or use `product_type: learning_path`. Content administrators can create, review, and publish these organization-scoped wrappers through V2 endpoints. A wrapper does not create or modify a legacy Program. + +## Studio behavior + +- `/studio/programs` lists program-backed and `learning_path` wrappers. +- It shows title, description, wrapper status, review state, and visibility. +- Course membership, ordering, learner progress, and enrollment are not editable. +- Creation through `/studio/create` creates a wrapper only and states that consequence. +- No delete or reorder control is shown. + +## Visibility and availability + +V2 visibility supports `draft`, `private`, `workspace`, `invite_only`, `public`, and `archived`. Wrapper publishing can make the public page visible but does not enroll learners or create a program runtime. Existing enrollment and access-grant behavior remains authoritative. + +## Gap + +A complete content-admin program editor requires organization-scoped program reads plus create/update/course-membership/reorder/archive APIs with ownership validation. Until then, Studio presents supported wrapper governance only. diff --git a/docs/ux-overhaul/studio-publishing-data-contract.md b/docs/ux-overhaul/studio-publishing-data-contract.md new file mode 100644 index 0000000..e5a4350 --- /dev/null +++ b/docs/ux-overhaul/studio-publishing-data-contract.md @@ -0,0 +1,35 @@ +# Studio Publishing Data Contract + +Date: 2026-07-13 + +## Terms that are not interchangeable + +| Term | Verified meaning | +| --- | --- | +| Draft | Wrapper/content record is still being prepared. | +| In review | Wrapper review decision is pending. | +| Approved | Wrapper review status is approved; this alone does not create public visibility or publish lessons. | +| Published | V2 product wrapper has been published; public discovery also requires `visibility: public`. | +| Archived | Wrapper review/status value; no dedicated reversible archive endpoint exists. | +| Learner-visible lesson | Controlled separately by lesson review/readiness and governed runtime APIs. | +| Teacher-visible | No separate V2 wrapper field or verified Studio control exists. | +| Organization scope | V2 records are restricted to workspaces visible through active organization membership. | + +## Supported publishing mutation + +`PATCH /api/products/{product_id}/publish` accepts a supported visibility other than draft/archived. It sets wrapper status to `published`, may move review state to `approved`, creates a slug, and records publishing/update timestamps. + +It does not publish legacy CourseVersion content, override lesson readiness, enroll a learner, create progress, or grant access. + +## Studio behavior + +- `/studio/publishing` lists current wrapper states and visibility. +- Publishing is initiated from `/studio/content/:productId`. +- Confirmation states the public scope and what remains unchanged. +- UI state updates only from the successful API response. +- Failed publishing leaves the prior state visible. +- A public preview link appears only after the returned wrapper is published and has a slug. + +## Unsupported behavior + +There is no V2 unpublish endpoint, scheduled publish, dependency-validation response, organization-specific publishing mutation, rollback, or publishing audit. Studio therefore exposes no unpublish or schedule control. diff --git a/docs/ux-overhaul/studio-responsive-review.md b/docs/ux-overhaul/studio-responsive-review.md new file mode 100644 index 0000000..5b29290 --- /dev/null +++ b/docs/ux-overhaul/studio-responsive-review.md @@ -0,0 +1,17 @@ +# Studio Responsive Review + +Date: 2026-07-13 + +Target widths: 1440, 1280, 768, and 390 CSS pixels. + +- Canonical pages use the shared constrained content width and responsive page padding. +- Overview counts, attention records, shortcuts, and libraries use bounded auto-fit grids. +- Search and filters wrap and become full-width on mobile. +- Detail definition grids change from two columns to one below 768px. +- Project creation/source/draft forms stack rather than compress into a narrow two-column editor. +- Long titles, citations, references, and draft bodies use wrapping and preserve document flow. +- Review actions wrap on desktop and become full-width touch targets on mobile. +- Shared confirmation dialogs remain bounded by viewport width. +- No viewport-scaled font size, negative letter spacing, fixed authoring canvas, horizontal table, or drag-only control was introduced. + +The seeded Playwright Studio flow passed its 390px horizontal-overflow assertion in Chromium. Deep authoring at 200% zoom, mobile virtual-keyboard behavior, and representative long-citation review remain manual follow-up items. diff --git a/docs/ux-overhaul/studio-review-workflow.md b/docs/ux-overhaul/studio-review-workflow.md new file mode 100644 index 0000000..82c8f27 --- /dev/null +++ b/docs/ux-overhaul/studio-review-workflow.md @@ -0,0 +1,26 @@ +# Studio Review Workflow + +Date: 2026-07-13 + +## Reviewable entities and states + +| Entity | Read source | Supported states | Action API | Learner impact | +| --- | --- | --- | --- | --- | +| Artifact/content draft | `GET /api/review-center` | `draft`, `in_review`, `approved`, `rejected`, `needs_changes` | `PATCH /api/artifacts/{id}/review-status` | None directly; artifact is not a runtime lesson | +| Product/learning-offering wrapper | `GET /api/review-center` | `draft`, `in_review`, `approved`, `published`, `archived` | `PATCH /api/products/{id}/review-status` | Wrapper state only; public visibility uses separate publish API | +| Course lesson governance | Review-center composed item | blocked/readiness warning context | Read only in canonical Studio | Existing lesson governance remains authoritative | + +V2 creator roles can submit mutations when their workspace is visible and manageable. The API does not distinguish submitter, reviewer, or approver roles and has no final-approver separation. + +## Production behavior + +- `/studio/review` separates content drafts, learning offerings, and lesson governance. +- Every state mutation requires confirmation naming the item and consequence. +- The displayed state changes only after API success. +- Failure leaves the decision dialog open and states that no change occurred. +- Publishing is omitted from review-state controls and handled separately with public-visibility confirmation. +- Lesson governance remains read-only because content-admin lesson editing is not supported. + +## Unsupported workflow + +There is no reviewer assignment, review submission timestamp, owner/editor identity, comment thread, request-changes message, attributable activity history, or audit record. The `recent_activity` placeholder is not presented as an audit log. diff --git a/docs/ux-overhaul/studio-source-data-contract.md b/docs/ux-overhaul/studio-source-data-contract.md new file mode 100644 index 0000000..b68eed3 --- /dev/null +++ b/docs/ux-overhaul/studio-source-data-contract.md @@ -0,0 +1,27 @@ +# Studio Source Data Contract + +Date: 2026-07-13 + +## V2 source wrapper + +`KnowledgeSource` provides `id`, `workspace_id`, `project_id`, optional legacy `source_id`, `title`, `source_type`, optional `uri`, optional `citation`, optional `content_hash`, `status`, metadata, and timestamps. + +Supported types are `repository`, `document`, `pdf`, `url`, `transcript`, `upload`, `manual_note`, and `lesson_source`. + +## Legacy lesson source + +`Source` provides `id`, `lesson_id`, `citation`, optional URL, and creation timestamp. It has no independent CRUD route. Lesson readiness requires at least one non-empty citation; an optional URL must be valid HTTP(S). + +## Supported Studio operations + +- List/search/filter organization-scoped knowledge-source wrappers. +- Create a source wrapper inside an authorized project. +- Record title, type, URI/reference, and citation. +- Show whether citation text is recorded. +- Associate a source wrapper when creating an artifact in the same project. + +## Unsupported operations + +There is no source detail, edit, delete, usage-lookup, duplicate-detection, rights/license, author, publisher, date, primary/secondary classification, or file-preview API. Studio does not invent those fields, claim a source is unused, or assign trust/reliability scores. + +Removing an artifact association is also unsupported. No delete/unlink control is shown. diff --git a/docs/ux-overhaul/studio-terminology-map.md b/docs/ux-overhaul/studio-terminology-map.md new file mode 100644 index 0000000..0e07062 --- /dev/null +++ b/docs/ux-overhaul/studio-terminology-map.md @@ -0,0 +1,23 @@ +# Studio Terminology Map + +Date: 2026-07-13 + +| Internal or legacy term | Visible Studio term | Rule | +| --- | --- | --- | +| Workspace | Studio or organization workspace | “Workspace” appears only when selecting the backend scope. | +| Project | Curriculum project | Project remains meaningful as a source/draft grouping. | +| Product | Learning offering | Use the specific mapped type where possible. | +| `course` product | Course wrapper | Do not imply full Course authoring. | +| `learning_path` product | Learning path | Do not imply ordered course membership is editable. | +| Program-backed product | Program wrapper | Preserve distinction from `learning_path`. | +| `resource_pack` | Resource collection | Wrapper only. | +| `documentation_pack` | Reference collection | Wrapper only. | +| Artifact | Content draft | Show specific type: outline, guide, lesson draft, assessment seed, source map, checklist, or export bundle. | +| Knowledge source | Source record | Use “source library” for the collection. | +| Artifact review | Content review | State explicitly that it does not approve learner lessons. | +| Product publish | Publish learning-offering wrapper | State public visibility consequence and runtime limits. | +| Access state | Availability | Keep permissions/access terminology where it truly controls access. | +| Commercial metadata | Learning-offering details | The endpoint name remains internal; commercial copy is not shown. | +| Generation run | Generation metadata | Kept on legacy technical routes and omitted from primary Studio navigation. | + +This is a contextual mapping, not a blind string replacement. Backend identifiers and route compatibility remain unchanged. diff --git a/frontend/package.json b/frontend/package.json index 6cfd95f..7007f8f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,7 +8,8 @@ "watch": "ng build --watch --configuration development", "test": "ng test", "smoke:demo:student": "playwright test tests/demo/student-flagship-smoke.spec.ts", - "smoke:demo:admin": "playwright test tests/demo/admin-platform-smoke.spec.ts" + "smoke:demo:admin": "playwright test tests/demo/admin-platform-smoke.spec.ts", + "smoke:demo:studio": "playwright test tests/demo/studio-content-smoke.spec.ts" }, "private": true, "dependencies": { diff --git a/frontend/src/app/app.routes.spec.ts b/frontend/src/app/app.routes.spec.ts index d7220b7..88b6b8b 100644 --- a/frontend/src/app/app.routes.spec.ts +++ b/frontend/src/app/app.routes.spec.ts @@ -18,6 +18,8 @@ import { TeacherLearnerDetailComponent } from './pages/teacher-learner-detail/te import { AdminOverviewComponent } from './pages/admin-overview/admin-overview.component'; import { AdminUsersComponent } from './pages/admin-users/admin-users.component'; import { AdminOrganizationsComponent } from './pages/admin-organizations/admin-organizations.component'; +import { StudioOverviewComponent } from './pages/studio-overview/studio-overview.component'; +import { StudioLibraryComponent } from './pages/studio-library/studio-library.component'; function findRoute(routeList: Routes, path: string): Route | undefined { const exactRoute = routeList.find(candidate => candidate.path === path); @@ -147,6 +149,32 @@ describe('app routes', () => { expect(findRoute(routes, 'admin/organizations')?.data?.['roles']).toEqual(['admin', 'super_admin']); }); + it('adds guarded canonical Studio routes while preserving workspace deep links', () => { + [ + 'studio', + 'studio/create', + 'studio/projects', + 'studio/projects/:projectId', + 'studio/content', + 'studio/content/:productId', + 'studio/courses', + 'studio/programs', + 'studio/sources', + 'studio/drafts', + 'studio/drafts/:artifactId', + 'studio/review', + 'studio/publishing', + 'workspace/product-studio', + 'workspace/products', + 'workspace/review-center', + ].forEach(path => expect(findRoute(routes, path)).withContext(path).toBeTruthy()); + + expect(findRoute(routes, 'studio/')?.component).toBe(StudioOverviewComponent); + expect(findRoute(routes, 'studio/courses')?.component).toBe(StudioLibraryComponent); + expect(findRoute(routes, 'studio/courses')?.canActivate).toContain(RoleGuard); + expect(findRoute(routes, 'studio/courses')?.data?.['roles']).toEqual(['content_admin']); + }); + it('maps Learner Portal V2 routes to V2 pages while reusing lesson runtime', () => { const learnRoute = findRoute(routes, 'learn'); const learnerShell = learnRoute?.children?.find(route => route.path === ''); diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 7e1d717..f59ecf5 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -49,6 +49,13 @@ import { AdminOverviewComponent } from './pages/admin-overview/admin-overview.co import { AdminUserDetailComponent } from './pages/admin-user-detail/admin-user-detail.component'; import { AdminOrganizationsComponent } from './pages/admin-organizations/admin-organizations.component'; import { AdminReportsComponent } from './pages/admin-reports/admin-reports.component'; +import { StudioOverviewComponent } from './pages/studio-overview/studio-overview.component'; +import { StudioLibraryComponent } from './pages/studio-library/studio-library.component'; +import { StudioCreateComponent } from './pages/studio-create/studio-create.component'; +import { StudioProjectDetailComponent } from './pages/studio-project-detail/studio-project-detail.component'; +import { StudioContentDetailComponent } from './pages/studio-content-detail/studio-content-detail.component'; +import { StudioDraftDetailComponent } from './pages/studio-draft-detail/studio-draft-detail.component'; +import { StudioReviewComponent } from './pages/studio-review/studio-review.component'; const creatorRoles = ['admin', 'teacher', 'content_admin', 'org_admin', 'instructor']; const studioRoles = ['content_admin', 'org_admin']; @@ -78,6 +85,26 @@ export const routes: Routes = [ { path: 'reports', component: AdminReportsComponent, canActivate: [RoleGuard], data: { roles: ['admin'] } }, ], }, + { + path: 'studio', + component: HomeComponent, + canActivate: [HomeSessionGuard], + children: [ + { path: '', component: StudioOverviewComponent, canActivate: [RoleGuard], data: { roles: ['content_admin'] } }, + { path: 'create', component: StudioCreateComponent, canActivate: [RoleGuard], data: { roles: ['content_admin'] } }, + { path: 'projects', component: StudioLibraryComponent, canActivate: [RoleGuard], data: { roles: ['content_admin'], mode: 'projects' } }, + { path: 'projects/:projectId', component: StudioProjectDetailComponent, canActivate: [RoleGuard], data: { roles: ['content_admin'] } }, + { path: 'content', component: StudioLibraryComponent, canActivate: [RoleGuard], data: { roles: ['content_admin'], mode: 'content' } }, + { path: 'content/:productId', component: StudioContentDetailComponent, canActivate: [RoleGuard], data: { roles: ['content_admin'] } }, + { path: 'courses', component: StudioLibraryComponent, canActivate: [RoleGuard], data: { roles: ['content_admin'], mode: 'courses' } }, + { path: 'programs', component: StudioLibraryComponent, canActivate: [RoleGuard], data: { roles: ['content_admin'], mode: 'programs' } }, + { path: 'sources', component: StudioLibraryComponent, canActivate: [RoleGuard], data: { roles: ['content_admin'], mode: 'sources' } }, + { path: 'drafts', component: StudioLibraryComponent, canActivate: [RoleGuard], data: { roles: ['content_admin'], mode: 'drafts' } }, + { path: 'drafts/:artifactId', component: StudioDraftDetailComponent, canActivate: [RoleGuard], data: { roles: ['content_admin'] } }, + { path: 'review', component: StudioReviewComponent, canActivate: [RoleGuard], data: { roles: ['content_admin'] } }, + { path: 'publishing', component: StudioLibraryComponent, canActivate: [RoleGuard], data: { roles: ['content_admin'], mode: 'publishing' } }, + ], + }, { path: 'teach', component: HomeComponent, diff --git a/frontend/src/app/components/echo-sidebar/echo-sidebar.component.spec.ts b/frontend/src/app/components/echo-sidebar/echo-sidebar.component.spec.ts index 01e30ea..37e5c6c 100644 --- a/frontend/src/app/components/echo-sidebar/echo-sidebar.component.spec.ts +++ b/frontend/src/app/components/echo-sidebar/echo-sidebar.component.spec.ts @@ -61,10 +61,11 @@ describe('SidebarComponent', () => { const text = fixture.nativeElement.textContent; expect(text).toContain('Studio'); expect(text).toContain('Projects'); - expect(text).toContain('Content Studio'); - expect(text).toContain('Learning Products'); + expect(text).toContain('Content'); + expect(text).toContain('Courses'); + expect(text).toContain('Programs and Paths'); expect(text).toContain('Sources'); - expect(text).toContain('Settings'); + expect(text).not.toContain('Settings'); }); it('renders loading state before readiness', () => { @@ -86,8 +87,8 @@ describe('SidebarComponent', () => { const firstLink = fixture.nativeElement.querySelector('a') as HTMLAnchorElement; const icon = firstLink.querySelector('app-icon'); const svg = firstLink.querySelector('svg'); - expect(firstLink.getAttribute('aria-label')).toBe('Studio Home'); - expect(firstLink.getAttribute('title')).toBe('Studio Home'); + expect(firstLink.getAttribute('aria-label')).toBe('Studio Overview'); + expect(firstLink.getAttribute('title')).toBe('Studio Overview'); expect(firstLink.textContent?.trim()).toBe(''); expect(icon).not.toBeNull(); expect(svg).not.toBeNull(); diff --git a/frontend/src/app/pages/studio-content-detail/studio-content-detail.component.html b/frontend/src/app/pages/studio-content-detail/studio-content-detail.component.html new file mode 100644 index 0000000..0f27bdb --- /dev/null +++ b/frontend/src/app/pages/studio-content-detail/studio-content-detail.component.html @@ -0,0 +1,14 @@ +
+

Learning offering

{{ product?.title || 'Content details' }}

Review wrapper identity, approval context, and learner-facing visibility.

Return to content library
+ + + + +

{{ successMessage }}

+

Identity and state

{{ label(product.status) }}

{{ product.description || product.subtitle || 'No learner-facing description has been provided.' }}

Type
{{ label(product.product_type) }}
Review state
{{ label(product.review_state) }}
Visibility
{{ label(product.visibility) }}
Availability
{{ label(product.access_state) }}
Runtime relationship
{{ product.course_id ? 'Existing course linked' : product.program_id ? 'Existing program linked' : 'Wrapper only' }}
Last updated
{{ (product.last_updated || product.updated_at) | date:'medium' }}
+ +

Publishing

Publishing changes this wrapper’s public visibility.

It does not publish course lessons, bypass lesson readiness, create enrollments, or grant learner access.

+
+ + +
diff --git a/frontend/src/app/pages/studio-content-detail/studio-content-detail.component.scss b/frontend/src/app/pages/studio-content-detail/studio-content-detail.component.scss new file mode 100644 index 0000000..22b97ec --- /dev/null +++ b/frontend/src/app/pages/studio-content-detail/studio-content-detail.component.scss @@ -0,0 +1,6 @@ +@use '../../../styles/studio-production'; +.studio-detail-grid { display: grid; gap: var(--ee-space-3); grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; } +.studio-detail-grid div { border-bottom: 1px solid var(--ee-color-border-subtle); padding-bottom: var(--ee-space-3); } +.studio-detail-grid dt { color: var(--ee-color-text-muted); font-size: var(--ee-font-size-sm); } +.studio-detail-grid dd { font-weight: 650; margin: var(--ee-space-1) 0 0; overflow-wrap: anywhere; } +@media (max-width: 47.99rem) { .studio-detail-grid { grid-template-columns: 1fr; } } diff --git a/frontend/src/app/pages/studio-content-detail/studio-content-detail.component.spec.ts b/frontend/src/app/pages/studio-content-detail/studio-content-detail.component.spec.ts new file mode 100644 index 0000000..0eb917c --- /dev/null +++ b/frontend/src/app/pages/studio-content-detail/studio-content-detail.component.spec.ts @@ -0,0 +1,37 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, convertToParamMap } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { of, throwError } from 'rxjs'; +import { StudioContentDetailComponent } from './studio-content-detail.component'; +import { V2PlatformService } from '../../services/v2-platform.service'; + +describe('StudioContentDetailComponent', () => { + let fixture: ComponentFixture; + const platform = jasmine.createSpyObj('V2PlatformService', ['getProduct', 'publishProduct']); + const product = { id: 'product-1', title: 'History Course', product_type: 'course', description: 'Description', status: 'approved', review_state: 'approved', visibility: 'private', access_state: 'private', course_id: 'course-1', program_id: null, updated_at: '2026-07-13T00:00:00Z' } as any; + + beforeEach(async () => { + platform.getProduct.calls.reset(); platform.publishProduct.calls.reset(); + platform.getProduct.and.returnValue(of(product)); platform.publishProduct.and.returnValue(of({ ...product, status: 'published', visibility: 'public', slug: 'history-course' })); + await TestBed.configureTestingModule({ imports: [RouterTestingModule, StudioContentDetailComponent], providers: [ + { provide: V2PlatformService, useValue: platform }, { provide: ActivatedRoute, useValue: { snapshot: { paramMap: convertToParamMap({ productId: 'product-1' }) } } }, + ] }).compileComponents(); + fixture = TestBed.createComponent(StudioContentDetailComponent); fixture.detectChanges(); + }); + + it('requires confirmation before public publishing', () => { + fixture.componentInstance.requestPublish(); + expect(fixture.componentInstance.confirmPublish).toBeTrue(); + expect(platform.publishProduct).not.toHaveBeenCalled(); + fixture.componentInstance.publish(); + expect(platform.publishProduct).toHaveBeenCalledWith('product-1', { visibility: 'public' }); + expect(fixture.componentInstance.product?.status).toBe('published'); + }); + + it('does not optimistically publish after an API failure', () => { + platform.publishProduct.and.returnValue(throwError(() => new Error('failed'))); + fixture.componentInstance.requestPublish(); fixture.componentInstance.publish(); + expect(fixture.componentInstance.product?.status).toBe('approved'); + expect(fixture.componentInstance.actionError).toContain('has not changed'); + }); +}); diff --git a/frontend/src/app/pages/studio-content-detail/studio-content-detail.component.ts b/frontend/src/app/pages/studio-content-detail/studio-content-detail.component.ts new file mode 100644 index 0000000..3a0dca7 --- /dev/null +++ b/frontend/src/app/pages/studio-content-detail/studio-content-detail.component.ts @@ -0,0 +1,58 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { Subscription } from 'rxjs'; + +import { EchoConfirmationDialogComponent } from '../../components/echo-confirmation-dialog/echo-confirmation-dialog.component'; +import { EchoLoadingStateComponent } from '../../components/echo-loading-state/echo-loading-state.component'; +import { EchoStatePanelComponent } from '../../components/echo-state-panel/echo-state-panel.component'; +import { Product } from '../../models/v2-platform.model'; +import { V2PlatformService } from '../../services/v2-platform.service'; + +@Component({ + selector: 'app-studio-content-detail', + standalone: true, + imports: [CommonModule, RouterLink, EchoConfirmationDialogComponent, EchoLoadingStateComponent, EchoStatePanelComponent], + templateUrl: './studio-content-detail.component.html', + styleUrl: './studio-content-detail.component.scss', +}) +export class StudioContentDetailComponent implements OnInit, OnDestroy { + product?: Product; + loading = true; + failed = false; + publishing = false; + confirmPublish = false; + actionError = ''; + successMessage = ''; + private productId = ''; + private readonly subscriptions = new Subscription(); + + constructor(private readonly route: ActivatedRoute, private readonly platform: V2PlatformService) {} + + ngOnInit(): void { this.productId = this.route.snapshot.paramMap.get('productId') || ''; this.load(); } + ngOnDestroy(): void { this.subscriptions.unsubscribe(); } + + load(): void { + if (!this.productId) { this.failed = true; this.loading = false; return; } + this.loading = true; + this.failed = false; + this.subscriptions.add(this.platform.getProduct(this.productId).subscribe({ + next: product => { this.product = product; this.loading = false; }, + error: () => { this.failed = true; this.loading = false; }, + })); + } + + requestPublish(): void { if (this.product && this.product.status !== 'published') { this.actionError = ''; this.confirmPublish = true; } } + publish(): void { + if (!this.product || this.publishing) return; + this.publishing = true; + this.actionError = ''; + this.subscriptions.add(this.platform.publishProduct(this.product.id, { visibility: 'public' }).subscribe({ + next: product => { this.product = product; this.publishing = false; this.confirmPublish = false; this.successMessage = 'The learning-offering wrapper is now publicly visible.'; }, + error: () => { this.publishing = false; this.actionError = 'Publishing failed. The displayed state has not changed.'; }, + })); + } + + label(value: string): string { return value.replace(/_/g, ' ').replace(/\b\w/g, character => character.toUpperCase()); } + statusClass(status: string): string { return ['published', 'approved'].includes(status) ? 'studio-status--success' : ['archived', 'rejected'].includes(status) ? 'studio-status--danger' : 'studio-status--warning'; } +} diff --git a/frontend/src/app/pages/studio-create/studio-create.component.html b/frontend/src/app/pages/studio-create/studio-create.component.html new file mode 100644 index 0000000..3415d1c --- /dev/null +++ b/frontend/src/app/pages/studio-create/studio-create.component.html @@ -0,0 +1,31 @@ +
+

New content record

Create in Studio

Create a curriculum project or a governed learning-offering wrapper using the current content-operations APIs.

Return to Studio overview
+ + + + +

{{ statusMessage }}

+ +
+
+

Create curriculum project

Projects group related sources, content drafts, and learning offerings.

+
+
+
+ + +
+ +
+

Create learning offering

This creates a reviewable wrapper. It does not create units, lessons, activities, enrollments, or learner progress.

+
+
+
+
+
+
+ + +
+
+
diff --git a/frontend/src/app/pages/studio-create/studio-create.component.scss b/frontend/src/app/pages/studio-create/studio-create.component.scss new file mode 100644 index 0000000..8487460 --- /dev/null +++ b/frontend/src/app/pages/studio-create/studio-create.component.scss @@ -0,0 +1,7 @@ +@use '../../../styles/studio-production'; +.studio-form-grid { display: grid; gap: var(--ee-space-4); grid-template-columns: repeat(2, minmax(0, 1fr)); } +.studio-form { align-content: start; display: grid; gap: var(--ee-space-4); } +.studio-form p { color: var(--ee-color-text-muted); margin-bottom: 0; } +.studio-control { resize: vertical; } +.studio-error { background: var(--ee-color-status-danger-background); border: 1px solid var(--ee-color-status-danger-border); border-radius: var(--ee-radius-card-default); color: var(--ee-color-status-danger-text) !important; padding: var(--ee-space-3); } +@media (max-width: 47.99rem) { .studio-form-grid { grid-template-columns: 1fr; } } diff --git a/frontend/src/app/pages/studio-create/studio-create.component.ts b/frontend/src/app/pages/studio-create/studio-create.component.ts new file mode 100644 index 0000000..a91ca5d --- /dev/null +++ b/frontend/src/app/pages/studio-create/studio-create.component.ts @@ -0,0 +1,107 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Router, RouterLink } from '@angular/router'; +import { forkJoin, Subscription } from 'rxjs'; + +import { EchoLoadingStateComponent } from '../../components/echo-loading-state/echo-loading-state.component'; +import { EchoStatePanelComponent } from '../../components/echo-state-panel/echo-state-panel.component'; +import { Project, Workspace } from '../../models/v2-platform.model'; +import { V2PlatformService } from '../../services/v2-platform.service'; + +@Component({ + selector: 'app-studio-create', + standalone: true, + imports: [CommonModule, FormsModule, RouterLink, EchoLoadingStateComponent, EchoStatePanelComponent], + templateUrl: './studio-create.component.html', + styleUrl: './studio-create.component.scss', +}) +export class StudioCreateComponent implements OnInit, OnDestroy { + readonly contentTypes = ['course', 'learning_path', 'documentation_pack', 'onboarding_track', 'implementation_playbook', 'knowledge_portal', 'certification', 'resource_pack']; + workspaces: Workspace[] = []; + projects: Project[] = []; + loading = true; + failed = false; + savingProject = false; + savingContent = false; + projectError = ''; + contentError = ''; + statusMessage = ''; + + projectForm = { workspaceId: '', name: '', description: '' }; + contentForm = { workspaceId: '', projectId: '', type: 'course', title: '', description: '', visibility: 'draft' }; + private readonly subscriptions = new Subscription(); + + constructor(private readonly platform: V2PlatformService, private readonly router: Router) {} + + ngOnInit(): void { this.load(); } + ngOnDestroy(): void { this.subscriptions.unsubscribe(); } + + get availableProjects(): Project[] { + return this.contentForm.workspaceId ? this.projects.filter(project => project.workspace_id === this.contentForm.workspaceId) : []; + } + + load(): void { + this.loading = true; + this.failed = false; + this.subscriptions.add(forkJoin({ workspaces: this.platform.getWorkspaces(), projects: this.platform.getProjects() }).subscribe({ + next: result => { + this.workspaces = result.workspaces; + this.projects = result.projects; + const defaultWorkspace = this.workspaces[0]?.id || ''; + this.projectForm.workspaceId ||= defaultWorkspace; + this.contentForm.workspaceId ||= defaultWorkspace; + this.loading = false; + }, + error: () => { this.failed = true; this.loading = false; }, + })); + } + + createProject(): void { + if (!this.projectForm.workspaceId || !this.projectForm.name.trim() || this.savingProject) return; + this.savingProject = true; + this.projectError = ''; + this.statusMessage = ''; + this.subscriptions.add(this.platform.createProject({ + workspace_id: this.projectForm.workspaceId, + name: this.projectForm.name.trim(), + description: this.projectForm.description.trim() || null, + status: 'active', + }).subscribe({ + next: project => { + this.projects = [...this.projects, project]; + this.contentForm.workspaceId = project.workspace_id; + this.contentForm.projectId = project.id; + this.projectForm = { workspaceId: project.workspace_id, name: '', description: '' }; + this.savingProject = false; + this.statusMessage = `Project “${project.name}” created.`; + }, + error: () => { this.savingProject = false; this.projectError = 'The project was not created. Your entries remain available to review.'; }, + })); + } + + createContent(): void { + if (!this.contentForm.workspaceId || !this.contentForm.title.trim() || this.savingContent) return; + this.savingContent = true; + this.contentError = ''; + this.statusMessage = ''; + this.subscriptions.add(this.platform.createProduct({ + workspace_id: this.contentForm.workspaceId, + project_id: this.contentForm.projectId || null, + product_type: this.contentForm.type, + title: this.contentForm.title.trim(), + description: this.contentForm.description.trim() || null, + status: 'draft', + review_state: 'draft', + access_state: 'private', + visibility: this.contentForm.visibility, + pricing_model: 'internal', + }).subscribe({ + next: product => { this.savingContent = false; this.router.navigate(['/studio/content', product.id]); }, + error: () => { this.savingContent = false; this.contentError = 'The learning offering was not created. Your entries remain available to review.'; }, + })); + } + + workspaceChanged(): void { this.contentForm.projectId = ''; } + label(value: string): string { return value.replace(/_/g, ' ').replace(/\b\w/g, character => character.toUpperCase()); } +} diff --git a/frontend/src/app/pages/studio-draft-detail/studio-draft-detail.component.html b/frontend/src/app/pages/studio-draft-detail/studio-draft-detail.component.html new file mode 100644 index 0000000..afafeb9 --- /dev/null +++ b/frontend/src/app/pages/studio-draft-detail/studio-draft-detail.component.html @@ -0,0 +1 @@ +

Reviewable content draft

{{ draft?.title || 'Draft details' }}

Inspect draft content and traceability without creating learner progress or publishing course lessons.

Return to content drafts

Draft state

{{ label(draft.review_state) }}
Content type
{{ label(draft.artifact_type) }}
Source association
{{ draft.knowledge_source_id ? 'Source record linked' : 'No source record linked' }}
Learning offering
{{ draft.product_id ? 'Offering linked' : 'No offering linked' }}
Updated
{{ draft.updated_at | date:'medium' }}

Draft preview

{{ draft.body || draft.uri || 'No preview content has been recorded.' }}

This is a content-record preview.

It does not create a lesson session, learner progress, enrollment, or public availability.

diff --git a/frontend/src/app/pages/studio-draft-detail/studio-draft-detail.component.scss b/frontend/src/app/pages/studio-draft-detail/studio-draft-detail.component.scss new file mode 100644 index 0000000..1c1a22e --- /dev/null +++ b/frontend/src/app/pages/studio-draft-detail/studio-draft-detail.component.scss @@ -0,0 +1,7 @@ +@use '../../../styles/studio-production'; +.studio-detail-grid { display: grid; gap: var(--ee-space-3); grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; } +.studio-detail-grid div { border-bottom: 1px solid var(--ee-color-border-subtle); padding-bottom: var(--ee-space-3); } +.studio-detail-grid dt { color: var(--ee-color-text-muted); font-size: var(--ee-font-size-sm); } +.studio-detail-grid dd { margin: var(--ee-space-1) 0 0; overflow-wrap: anywhere; } +.draft-body { line-height: var(--ee-line-height-body); overflow-wrap: anywhere; white-space: pre-wrap; } +@media (max-width: 47.99rem) { .studio-detail-grid { grid-template-columns: 1fr; } } diff --git a/frontend/src/app/pages/studio-draft-detail/studio-draft-detail.component.ts b/frontend/src/app/pages/studio-draft-detail/studio-draft-detail.component.ts new file mode 100644 index 0000000..80b066f --- /dev/null +++ b/frontend/src/app/pages/studio-draft-detail/studio-draft-detail.component.ts @@ -0,0 +1,18 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { Subscription } from 'rxjs'; +import { EchoLoadingStateComponent } from '../../components/echo-loading-state/echo-loading-state.component'; +import { EchoStatePanelComponent } from '../../components/echo-state-panel/echo-state-panel.component'; +import { Artifact } from '../../models/v2-platform.model'; +import { V2PlatformService } from '../../services/v2-platform.service'; + +@Component({ selector: 'app-studio-draft-detail', standalone: true, imports: [CommonModule, RouterLink, EchoLoadingStateComponent, EchoStatePanelComponent], templateUrl: './studio-draft-detail.component.html', styleUrl: './studio-draft-detail.component.scss' }) +export class StudioDraftDetailComponent implements OnInit, OnDestroy { + draft?: Artifact; loading = true; failed = false; private draftId = ''; private readonly subscriptions = new Subscription(); + constructor(private readonly route: ActivatedRoute, private readonly platform: V2PlatformService) {} + ngOnInit(): void { this.draftId = this.route.snapshot.paramMap.get('artifactId') || ''; this.load(); } + ngOnDestroy(): void { this.subscriptions.unsubscribe(); } + load(): void { if (!this.draftId) { this.failed = true; this.loading = false; return; } this.loading = true; this.failed = false; this.subscriptions.add(this.platform.getArtifact(this.draftId).subscribe({ next: draft => { this.draft = draft; this.loading = false; }, error: () => { this.failed = true; this.loading = false; } })); } + label(value: string): string { return value.replace(/_/g, ' ').replace(/\b\w/g, character => character.toUpperCase()); } +} diff --git a/frontend/src/app/pages/studio-library/studio-library.component.html b/frontend/src/app/pages/studio-library/studio-library.component.html new file mode 100644 index 0000000..791c3bf --- /dev/null +++ b/frontend/src/app/pages/studio-library/studio-library.component.html @@ -0,0 +1,53 @@ +
+
+
+

{{ copy.eyebrow }}

+

{{ copy.title }}

+

{{ copy.intro }}

+
+ Create record +
+ + + + + +
+
+
+ + +
+
+ + +
+

{{ resultText }}

+
+
+ +
+
+
+

{{ item.title }}

+ {{ label(item.status) }} +
+

{{ item.summary }}

+
+
Type
{{ item.type }}
+
Context
{{ item.context }}
+
+ +
+
+ + + + +
+
diff --git a/frontend/src/app/pages/studio-library/studio-library.component.scss b/frontend/src/app/pages/studio-library/studio-library.component.scss new file mode 100644 index 0000000..90f6e0d --- /dev/null +++ b/frontend/src/app/pages/studio-library/studio-library.component.scss @@ -0,0 +1 @@ +@use '../../../styles/studio-production'; diff --git a/frontend/src/app/pages/studio-library/studio-library.component.spec.ts b/frontend/src/app/pages/studio-library/studio-library.component.spec.ts new file mode 100644 index 0000000..f600e6b --- /dev/null +++ b/frontend/src/app/pages/studio-library/studio-library.component.spec.ts @@ -0,0 +1,45 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { of, throwError } from 'rxjs'; +import { StudioLibraryComponent } from './studio-library.component'; +import { V2PlatformService } from '../../services/v2-platform.service'; + +describe('StudioLibraryComponent', () => { + let fixture: ComponentFixture; + const platform = jasmine.createSpyObj('V2PlatformService', ['getProducts', 'getProjects', 'getKnowledgeSources', 'getArtifacts']); + const products = [ + { id: 'course-1', title: 'Africa and the World', product_type: 'course', course_id: 'runtime-1', description: 'Course description', status: 'draft', review_state: 'in_review', visibility: 'private' }, + { id: 'path-1', title: 'History Path', product_type: 'learning_path', program_id: null, description: null, subtitle: null, status: 'published', review_state: 'approved', visibility: 'public' }, + ] as any; + + beforeEach(async () => { + platform.getProducts.calls.reset(); + platform.getProjects.calls.reset(); + platform.getKnowledgeSources.calls.reset(); + platform.getArtifacts.calls.reset(); + platform.getProducts.and.returnValue(of(products)); platform.getProjects.and.returnValue(of([])); platform.getKnowledgeSources.and.returnValue(of([])); platform.getArtifacts.and.returnValue(of([])); + await TestBed.configureTestingModule({ imports: [RouterTestingModule, StudioLibraryComponent], providers: [ + { provide: V2PlatformService, useValue: platform }, { provide: ActivatedRoute, useValue: { data: of({ mode: 'courses' }) } }, + ] }).compileComponents(); + fixture = TestBed.createComponent(StudioLibraryComponent); fixture.detectChanges(); + }); + + it('filters course wrappers without exposing unrelated paths', () => { + expect(fixture.nativeElement.textContent).toContain('Africa and the World'); + expect(fixture.nativeElement.textContent).not.toContain('History Path'); + expect(fixture.nativeElement.textContent).toContain('1 item shown'); + }); + + it('supports accessible search and status filters', () => { + fixture.componentInstance.search = 'missing'; fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain('0 items shown'); + expect(fixture.nativeElement.textContent).toContain('No items match these filters'); + }); + + it('shows a retryable load failure', () => { + platform.getProducts.and.returnValue(throwError(() => new Error('failed'))); + fixture.componentInstance.load(); fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain('Course wrappers could not be loaded'); + }); +}); diff --git a/frontend/src/app/pages/studio-library/studio-library.component.ts b/frontend/src/app/pages/studio-library/studio-library.component.ts new file mode 100644 index 0000000..d75593f --- /dev/null +++ b/frontend/src/app/pages/studio-library/studio-library.component.ts @@ -0,0 +1,118 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { Observable, Subscription } from 'rxjs'; + +import { EchoLoadingStateComponent } from '../../components/echo-loading-state/echo-loading-state.component'; +import { EchoStatePanelComponent } from '../../components/echo-state-panel/echo-state-panel.component'; +import { Artifact, KnowledgeSource, Product, Project } from '../../models/v2-platform.model'; +import { V2PlatformService } from '../../services/v2-platform.service'; + +type StudioLibraryMode = 'projects' | 'content' | 'courses' | 'programs' | 'sources' | 'drafts' | 'publishing'; + +interface StudioLibraryItem { + id: string; + title: string; + summary: string; + status: string; + type: string; + context: string; + detailRoute?: string; +} + +const MODE_COPY: Record = { + projects: { eyebrow: 'Curriculum organization', title: 'Projects', intro: 'Organize related sources, content drafts, and learning offerings within a scoped curriculum project.', empty: 'No curriculum projects are available in the current organization scope.' }, + content: { eyebrow: 'Learning content', title: 'Content library', intro: 'Review the course, path, certification, and resource wrappers currently available to Studio.', empty: 'No learning offerings are available in the current organization scope.' }, + courses: { eyebrow: 'Course stewardship', title: 'Course wrappers', intro: 'Inspect course-backed publishing and review records. Full course graph editing is not supported for content administrators by the current APIs.', empty: 'No course wrappers are available in the current organization scope.' }, + programs: { eyebrow: 'Learning sequences', title: 'Programs and learning paths', intro: 'Inspect program-backed and learning-path wrappers. Course membership and ordering remain read-only until supported authoring APIs exist.', empty: 'No program or learning-path wrappers are available in the current organization scope.' }, + sources: { eyebrow: 'Evidence and attribution', title: 'Source library', intro: 'Review source identity, citation text, and project usage recorded by the current source-wrapper API.', empty: 'No source records are available in the current organization scope.' }, + drafts: { eyebrow: 'Prepared content', title: 'Content drafts', intro: 'Inspect source-linked outlines, guides, lesson drafts, and other reviewable content outputs.', empty: 'No content drafts are available in the current organization scope.' }, + publishing: { eyebrow: 'Availability and release', title: 'Publishing states', intro: 'Review wrapper status and visibility. Learner lesson governance remains separately authoritative.', empty: 'No publishable learning offerings are available in the current organization scope.' }, +}; + +@Component({ + selector: 'app-studio-library', + standalone: true, + imports: [CommonModule, FormsModule, RouterLink, EchoLoadingStateComponent, EchoStatePanelComponent], + templateUrl: './studio-library.component.html', + styleUrl: './studio-library.component.scss', +}) +export class StudioLibraryComponent implements OnInit, OnDestroy { + mode: StudioLibraryMode = 'content'; + copy = MODE_COPY.content; + items: StudioLibraryItem[] = []; + loading = true; + failed = false; + search = ''; + statusFilter = 'all'; + private readonly subscriptions = new Subscription(); + + constructor(private readonly route: ActivatedRoute, private readonly platform: V2PlatformService) {} + + ngOnInit(): void { + this.subscriptions.add(this.route.data.subscribe(data => { + this.mode = (data['mode'] as StudioLibraryMode) || 'content'; + this.copy = MODE_COPY[this.mode]; + this.load(); + })); + } + + ngOnDestroy(): void { this.subscriptions.unsubscribe(); } + + get statusOptions(): string[] { return [...new Set(this.items.map(item => item.status))].sort(); } + get filteredItems(): StudioLibraryItem[] { + const query = this.search.trim().toLowerCase(); + return this.items.filter(item => { + const matchesSearch = !query || [item.title, item.summary, item.type, item.context].some(value => value.toLowerCase().includes(query)); + const matchesStatus = this.statusFilter === 'all' || item.status === this.statusFilter; + return matchesSearch && matchesStatus; + }); + } + get resultText(): string { return `${this.filteredItems.length} ${this.filteredItems.length === 1 ? 'item' : 'items'} shown`; } + + load(): void { + this.loading = true; + this.failed = false; + let request: Observable>; + if (this.mode === 'projects') request = this.platform.getProjects(); + else if (this.mode === 'sources') request = this.platform.getKnowledgeSources(); + else if (this.mode === 'drafts') request = this.platform.getArtifacts(); + else request = this.platform.getProducts(); + + this.subscriptions.add(request.subscribe({ + next: records => { + this.items = this.mapRecords(records); + this.loading = false; + }, + error: () => { this.failed = true; this.loading = false; }, + })); + } + + label(value: string): string { return value.replace(/_/g, ' ').replace(/\b\w/g, character => character.toUpperCase()); } + statusClass(status: string): string { + if (['published', 'approved', 'active', 'available'].includes(status)) return 'studio-status--success'; + if (['rejected', 'archived', 'blocked'].includes(status)) return 'studio-status--danger'; + if (['draft', 'in_review', 'needs_changes', 'not_reviewed'].includes(status)) return 'studio-status--warning'; + return ''; + } + + private mapRecords(records: Array): StudioLibraryItem[] { + if (this.mode === 'projects') return (records as Project[]).map(item => ({ id: item.id, title: item.name, summary: item.description || 'No project description has been provided.', status: item.status, type: 'Curriculum project', context: 'Sources and drafts are managed from the project detail.', detailRoute: `/studio/projects/${item.id}` })); + if (this.mode === 'sources') return (records as KnowledgeSource[]).map(item => ({ id: item.id, title: item.title, summary: item.citation || item.uri || 'No citation or reference has been recorded.', status: item.status, type: this.label(item.source_type), context: item.citation ? 'Citation recorded' : 'Citation needs attention' })); + if (this.mode === 'drafts') return (records as Artifact[]).map(item => ({ id: item.id, title: item.title, summary: item.body || item.uri || 'No preview content has been recorded.', status: item.review_state || item.status, type: this.label(item.artifact_type), context: item.knowledge_source_id ? 'Source record linked' : 'No source record linked', detailRoute: `/studio/drafts/${item.id}` })); + + let products = records as Product[]; + if (this.mode === 'courses') products = products.filter(item => item.product_type === 'course' || !!item.course_id); + if (this.mode === 'programs') products = products.filter(item => item.product_type === 'learning_path' || !!item.program_id); + return products.map(item => ({ + id: item.id, + title: item.title, + summary: item.description || item.subtitle || 'No learner-facing description has been provided.', + status: item.status, + type: this.label(item.product_type), + context: `${this.label(item.review_state)} review; ${this.label(item.visibility)} visibility`, + detailRoute: `/studio/content/${item.id}`, + })); + } +} diff --git a/frontend/src/app/pages/studio-overview/studio-overview.component.html b/frontend/src/app/pages/studio-overview/studio-overview.component.html new file mode 100644 index 0000000..ae9749d --- /dev/null +++ b/frontend/src/app/pages/studio-overview/studio-overview.component.html @@ -0,0 +1,73 @@ +
+
+
+

Content stewardship

+

Studio overview

+

Organize source-backed content, review draft materials, and verify publishing states within your current organization scope.

+
+ Create content record +
+ + + + + +
+

Needs attention

+
+
+

Content drafts

{{ review.pending_artifacts.length }}
+

Draft outputs waiting for a wrapper review decision.

+ Open review +
+
+

Learning offerings

{{ review.draft_products.length }}
+

Course, path, and resource wrappers that are not yet published.

+ Review publishing +
+
+

Lesson governance

{{ review.lesson_governance_items.length }}
+

Existing course content with verified readiness blockers or warnings.

+ Review readiness +
+
+ +

No supported review items currently require attention.

Source records and published wrappers can still be reviewed directly.

+
+
+ +
+

Content summary

+
+

{{ summary.totals.total_projects }}

Curriculum projects

+

{{ summary.totals.total_products }}

Learning offerings

+

{{ summary.totals.total_knowledge_sources }}

Source records

+

{{ summary.totals.total_artifacts }}

Content drafts

+

{{ summary.review_health['published_products'] || 0 }}

Published wrappers

+
+
+ +
+

Studio shortcuts

+ +
+ +
+

Current authoring scope

+

Studio currently manages content-operation records and wrapper governance.

Full course, unit, lesson, assessment, and asset editing is not shown because the current content-administrator APIs do not provide a complete authorized workflow.

+
+
diff --git a/frontend/src/app/pages/studio-overview/studio-overview.component.scss b/frontend/src/app/pages/studio-overview/studio-overview.component.scss new file mode 100644 index 0000000..90f6e0d --- /dev/null +++ b/frontend/src/app/pages/studio-overview/studio-overview.component.scss @@ -0,0 +1 @@ +@use '../../../styles/studio-production'; diff --git a/frontend/src/app/pages/studio-overview/studio-overview.component.spec.ts b/frontend/src/app/pages/studio-overview/studio-overview.component.spec.ts new file mode 100644 index 0000000..c61461c --- /dev/null +++ b/frontend/src/app/pages/studio-overview/studio-overview.component.spec.ts @@ -0,0 +1,42 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { of, throwError } from 'rxjs'; +import { StudioOverviewComponent } from './studio-overview.component'; +import { AnalyticsService } from '../../services/analytics.service'; +import { V2PlatformService } from '../../services/v2-platform.service'; + +describe('StudioOverviewComponent', () => { + let fixture: ComponentFixture; + const analytics = jasmine.createSpyObj('AnalyticsService', ['getV2WorkspaceAnalytics']); + const platform = jasmine.createSpyObj('V2PlatformService', ['getReviewCenter']); + const summary = { totals: { total_projects: 2, total_products: 3, total_knowledge_sources: 4, total_artifacts: 5 }, review_health: { published_products: 1 } } as any; + const review = { pending_artifacts: [{ id: 'draft-1' }], draft_products: [], lesson_governance_items: [], recent_activity: [] } as any; + + beforeEach(async () => { + analytics.getV2WorkspaceAnalytics.calls.reset(); platform.getReviewCenter.calls.reset(); + analytics.getV2WorkspaceAnalytics.and.returnValue(of(summary)); platform.getReviewCenter.and.returnValue(of(review)); + await TestBed.configureTestingModule({ imports: [RouterTestingModule, StudioOverviewComponent], providers: [ + { provide: AnalyticsService, useValue: analytics }, { provide: V2PlatformService, useValue: platform }, + ] }).compileComponents(); + }); + + it('shows supported content counts and review attention', () => { + fixture = TestBed.createComponent(StudioOverviewComponent); fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain('Content drafts'); + expect(fixture.nativeElement.textContent).toContain('Source records'); + expect(fixture.nativeElement.textContent).toContain('Current authoring scope'); + }); + + it('keeps review work visible after a partial summary failure', () => { + analytics.getV2WorkspaceAnalytics.and.returnValue(throwError(() => new Error('failed'))); + fixture = TestBed.createComponent(StudioOverviewComponent); fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain('Some Studio information could not be loaded'); + expect(fixture.nativeElement.textContent).toContain('Content drafts'); + }); + + it('shows an honest empty attention state', () => { + platform.getReviewCenter.and.returnValue(of({ pending_artifacts: [], draft_products: [], lesson_governance_items: [], recent_activity: [] })); + fixture = TestBed.createComponent(StudioOverviewComponent); fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain('No supported review items currently require attention'); + }); +}); diff --git a/frontend/src/app/pages/studio-overview/studio-overview.component.ts b/frontend/src/app/pages/studio-overview/studio-overview.component.ts new file mode 100644 index 0000000..3cffabd --- /dev/null +++ b/frontend/src/app/pages/studio-overview/studio-overview.component.ts @@ -0,0 +1,57 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { Subscription } from 'rxjs'; + +import { EchoLoadingStateComponent } from '../../components/echo-loading-state/echo-loading-state.component'; +import { EchoStatePanelComponent } from '../../components/echo-state-panel/echo-state-panel.component'; +import { ReviewCenter } from '../../models/v2-platform.model'; +import { AnalyticsService, V2WorkspaceAnalytics } from '../../services/analytics.service'; +import { V2PlatformService } from '../../services/v2-platform.service'; + +type StudioSource = 'summary' | 'review'; + +@Component({ + selector: 'app-studio-overview', + standalone: true, + imports: [CommonModule, RouterLink, EchoLoadingStateComponent, EchoStatePanelComponent], + templateUrl: './studio-overview.component.html', + styleUrl: './studio-overview.component.scss', +}) +export class StudioOverviewComponent implements OnInit, OnDestroy { + loading = true; + summary?: V2WorkspaceAnalytics; + review?: ReviewCenter; + failedSources = new Set(); + private readonly subscriptions = new Subscription(); + + constructor( + private readonly analytics: AnalyticsService, + private readonly platform: V2PlatformService, + ) {} + + ngOnInit(): void { this.load(); } + ngOnDestroy(): void { this.subscriptions.unsubscribe(); } + + get attentionCount(): number { + if (!this.review) return 0; + return this.review.pending_artifacts.length + this.review.draft_products.length + this.review.lesson_governance_items.length; + } + + load(): void { + this.loading = true; + this.failedSources.clear(); + let pending = 2; + const done = () => { pending -= 1; this.loading = pending > 0; }; + const fail = (source: StudioSource) => { this.failedSources.add(source); done(); }; + + this.subscriptions.add(this.analytics.getV2WorkspaceAnalytics().subscribe({ + next: value => { this.summary = value; done(); }, + error: () => fail('summary'), + })); + this.subscriptions.add(this.platform.getReviewCenter().subscribe({ + next: value => { this.review = value; done(); }, + error: () => fail('review'), + })); + } +} diff --git a/frontend/src/app/pages/studio-project-detail/studio-project-detail.component.html b/frontend/src/app/pages/studio-project-detail/studio-project-detail.component.html new file mode 100644 index 0000000..f8de191 --- /dev/null +++ b/frontend/src/app/pages/studio-project-detail/studio-project-detail.component.html @@ -0,0 +1,18 @@ +
+

Curriculum project

{{ project?.name || 'Project details' }}

{{ project?.description || 'Organize source records, reviewable drafts, and related learning offerings.' }}

Return to projects
+ + +

{{ statusMessage }}

+ + +

{{ sources.length }}

Source records

{{ drafts.length }}

Content drafts

{{ products.length }}

Learning offerings

+ +
+

Add source record

Record available identity and citation information. No file parsing or credibility score is created.

+ +

Create content draft

Drafts are reviewable content records. They are not learner-visible lessons or published course content.

+
+ +

Project records

Sources

No sources have been recorded.

{{ source.title }}{{ label(source.source_type) }}{{ source.citation || source.uri || 'Citation not recorded' }}
+
+
diff --git a/frontend/src/app/pages/studio-project-detail/studio-project-detail.component.scss b/frontend/src/app/pages/studio-project-detail/studio-project-detail.component.scss new file mode 100644 index 0000000..3fbe7bb --- /dev/null +++ b/frontend/src/app/pages/studio-project-detail/studio-project-detail.component.scss @@ -0,0 +1,11 @@ +@use '../../../styles/studio-production'; +.studio-form-grid { display: grid; gap: var(--ee-space-4); grid-template-columns: repeat(2, minmax(0, 1fr)); } +.studio-form { align-content: start; display: grid; gap: var(--ee-space-4); } +.studio-form p { color: var(--ee-color-text-muted); margin-bottom: 0; } +.studio-control { resize: vertical; } +.studio-error { background: var(--ee-color-status-danger-background); border: 1px solid var(--ee-color-status-danger-border); border-radius: var(--ee-radius-card-default); color: var(--ee-color-status-danger-text) !important; padding: var(--ee-space-3); } +.record-row { border-top: 1px solid var(--ee-color-border-subtle); color: inherit; display: grid; gap: var(--ee-space-1); padding-top: var(--ee-space-3); text-decoration: none; } +.record-row span, .record-row small { color: var(--ee-color-text-muted); overflow-wrap: anywhere; } +.record-row--link { color: var(--ee-color-text-link); } +.record-row--link:focus-visible { box-shadow: var(--ee-focus-ring-shadow); outline: var(--ee-focus-ring-width) solid var(--ee-focus-ring-color); outline-offset: var(--ee-focus-ring-offset); } +@media (max-width: 47.99rem) { .studio-form-grid { grid-template-columns: 1fr; } } diff --git a/frontend/src/app/pages/studio-project-detail/studio-project-detail.component.ts b/frontend/src/app/pages/studio-project-detail/studio-project-detail.component.ts new file mode 100644 index 0000000..1af772e --- /dev/null +++ b/frontend/src/app/pages/studio-project-detail/studio-project-detail.component.ts @@ -0,0 +1,70 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { forkJoin, Subscription } from 'rxjs'; + +import { EchoLoadingStateComponent } from '../../components/echo-loading-state/echo-loading-state.component'; +import { EchoStatePanelComponent } from '../../components/echo-state-panel/echo-state-panel.component'; +import { Artifact, KnowledgeSource, Product, Project } from '../../models/v2-platform.model'; +import { V2PlatformService } from '../../services/v2-platform.service'; + +@Component({ + selector: 'app-studio-project-detail', + standalone: true, + imports: [CommonModule, FormsModule, RouterLink, EchoLoadingStateComponent, EchoStatePanelComponent], + templateUrl: './studio-project-detail.component.html', + styleUrl: './studio-project-detail.component.scss', +}) +export class StudioProjectDetailComponent implements OnInit, OnDestroy { + readonly sourceTypes = ['repository', 'document', 'pdf', 'url', 'transcript', 'upload', 'manual_note', 'lesson_source']; + readonly draftTypes = ['outline', 'documentation', 'guide', 'checklist', 'source_map', 'export_bundle', 'assessment_seed', 'lesson_draft']; + project?: Project; + products: Product[] = []; + sources: KnowledgeSource[] = []; + drafts: Artifact[] = []; + loading = true; + failed = false; + savingSource = false; + savingDraft = false; + sourceError = ''; + draftError = ''; + statusMessage = ''; + sourceForm = { title: '', type: 'document', uri: '', citation: '' }; + draftForm = { title: '', type: 'lesson_draft', productId: '', sourceId: '', body: '' }; + private projectId = ''; + private readonly subscriptions = new Subscription(); + + constructor(private readonly route: ActivatedRoute, private readonly platform: V2PlatformService) {} + ngOnInit(): void { this.projectId = this.route.snapshot.paramMap.get('projectId') || ''; this.load(); } + ngOnDestroy(): void { this.subscriptions.unsubscribe(); } + + load(): void { + if (!this.projectId) { this.failed = true; this.loading = false; return; } + this.loading = true; this.failed = false; + this.subscriptions.add(forkJoin({ project: this.platform.getProject(this.projectId), products: this.platform.getProducts({ projectId: this.projectId }), sources: this.platform.getProjectKnowledgeSources(this.projectId), drafts: this.platform.getProjectArtifacts(this.projectId) }).subscribe({ + next: result => { this.project = result.project; this.products = result.products; this.sources = result.sources; this.drafts = result.drafts; this.loading = false; }, + error: () => { this.failed = true; this.loading = false; }, + })); + } + + createSource(): void { + if (!this.project || !this.sourceForm.title.trim() || this.savingSource) return; + this.savingSource = true; this.sourceError = ''; this.statusMessage = ''; + this.subscriptions.add(this.platform.createKnowledgeSource({ workspace_id: this.project.workspace_id, project_id: this.project.id, title: this.sourceForm.title.trim(), source_type: this.sourceForm.type, uri: this.sourceForm.uri.trim() || null, citation: this.sourceForm.citation.trim() || null, status: 'active' }).subscribe({ + next: source => { this.sources = [...this.sources, source]; this.sourceForm = { title: '', type: 'document', uri: '', citation: '' }; this.savingSource = false; this.statusMessage = `Source “${source.title}” added to this project.`; }, + error: () => { this.savingSource = false; this.sourceError = 'The source was not added. Your entries remain available to review.'; }, + })); + } + + createDraft(): void { + if (!this.project || !this.draftForm.title.trim() || this.savingDraft) return; + this.savingDraft = true; this.draftError = ''; this.statusMessage = ''; + this.subscriptions.add(this.platform.createArtifact({ workspace_id: this.project.workspace_id, project_id: this.project.id, product_id: this.draftForm.productId || null, knowledge_source_id: this.draftForm.sourceId || null, artifact_type: this.draftForm.type, title: this.draftForm.title.trim(), body: this.draftForm.body.trim() || null, status: 'draft', review_state: 'review_required' }).subscribe({ + next: draft => { this.drafts = [...this.drafts, draft]; this.draftForm = { title: '', type: 'lesson_draft', productId: '', sourceId: '', body: '' }; this.savingDraft = false; this.statusMessage = `Draft “${draft.title}” created. It is not learner-visible.`; }, + error: () => { this.savingDraft = false; this.draftError = 'The draft was not created. Your entries remain available to review.'; }, + })); + } + + label(value: string): string { return value.replace(/_/g, ' ').replace(/\b\w/g, character => character.toUpperCase()); } +} diff --git a/frontend/src/app/pages/studio-review/studio-review.component.html b/frontend/src/app/pages/studio-review/studio-review.component.html new file mode 100644 index 0000000..703e3f1 --- /dev/null +++ b/frontend/src/app/pages/studio-review/studio-review.component.html @@ -0,0 +1,18 @@ +
+

Editorial governance

Review content

Make explicit wrapper decisions while preserving the separate lesson-readiness rules that control learner delivery.

Return to Studio overview
+ + +

{{ statusMessage }}

+ + +

Content drafts

+

Learning offerings

+

Lesson governance

+
+ + +

{{ item.title }}

{{ label(item.status) }}

{{ item.source_coverage }}

Readiness
{{ item.readiness }}
Decision needed
{{ item.required_decision }}
Preview
+
+ + +
diff --git a/frontend/src/app/pages/studio-review/studio-review.component.scss b/frontend/src/app/pages/studio-review/studio-review.component.scss new file mode 100644 index 0000000..ce3b85f --- /dev/null +++ b/frontend/src/app/pages/studio-review/studio-review.component.scss @@ -0,0 +1,3 @@ +@use '../../../styles/studio-production'; +.studio-actions .studio-button { flex: 0 1 auto; } +@media (max-width: 47.99rem) { .studio-actions .studio-button { width: 100%; } } diff --git a/frontend/src/app/pages/studio-review/studio-review.component.spec.ts b/frontend/src/app/pages/studio-review/studio-review.component.spec.ts new file mode 100644 index 0000000..b21f990 --- /dev/null +++ b/frontend/src/app/pages/studio-review/studio-review.component.spec.ts @@ -0,0 +1,33 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { of, throwError } from 'rxjs'; +import { StudioReviewComponent } from './studio-review.component'; +import { V2PlatformService } from '../../services/v2-platform.service'; + +describe('StudioReviewComponent', () => { + let fixture: ComponentFixture; + const platform = jasmine.createSpyObj('V2PlatformService', ['getReviewCenter', 'updateArtifactReviewStatus', 'updateProductReviewStatus']); + const item = { id: 'draft-1', item_type: 'artifact', title: 'Lesson outline', status: 'in_review', review_state: 'in_review', source_coverage: 'Source linked', readiness: 'Wrapper review only', required_decision: 'Review', blocked: false } as any; + + beforeEach(async () => { + platform.getReviewCenter.calls.reset(); platform.updateArtifactReviewStatus.calls.reset(); platform.updateProductReviewStatus.calls.reset(); + platform.getReviewCenter.and.returnValue(of({ pending_artifacts: [item], draft_products: [], lesson_governance_items: [], recent_activity: [] })); + platform.updateArtifactReviewStatus.and.returnValue(of({ ...item, status: 'approved' })); + await TestBed.configureTestingModule({ imports: [RouterTestingModule, StudioReviewComponent], providers: [{ provide: V2PlatformService, useValue: platform }] }).compileComponents(); + fixture = TestBed.createComponent(StudioReviewComponent); fixture.detectChanges(); + }); + + it('requires confirmation before recording a review decision', () => { + fixture.componentInstance.requestDecision(item, 'approved'); + expect(platform.updateArtifactReviewStatus).not.toHaveBeenCalled(); + fixture.componentInstance.applyDecision(); + expect(platform.updateArtifactReviewStatus).toHaveBeenCalledWith('draft-1', { status: 'approved' }); + }); + + it('preserves the pending decision after a failed mutation', () => { + platform.updateArtifactReviewStatus.and.returnValue(throwError(() => new Error('failed'))); + fixture.componentInstance.requestDecision(item, 'rejected'); fixture.componentInstance.applyDecision(); + expect(fixture.componentInstance.decision?.status).toBe('rejected'); + expect(fixture.componentInstance.actionError).toContain('not changed'); + }); +}); diff --git a/frontend/src/app/pages/studio-review/studio-review.component.ts b/frontend/src/app/pages/studio-review/studio-review.component.ts new file mode 100644 index 0000000..fb47ee0 --- /dev/null +++ b/frontend/src/app/pages/studio-review/studio-review.component.ts @@ -0,0 +1,70 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { Observable, Subscription } from 'rxjs'; + +import { EchoConfirmationDialogComponent } from '../../components/echo-confirmation-dialog/echo-confirmation-dialog.component'; +import { EchoLoadingStateComponent } from '../../components/echo-loading-state/echo-loading-state.component'; +import { EchoStatePanelComponent } from '../../components/echo-state-panel/echo-state-panel.component'; +import { ReviewCenter, ReviewCenterItem } from '../../models/v2-platform.model'; +import { V2PlatformService } from '../../services/v2-platform.service'; + +interface PendingDecision { item: ReviewCenterItem; status: string; } + +@Component({ + selector: 'app-studio-review', + standalone: true, + imports: [CommonModule, RouterLink, EchoConfirmationDialogComponent, EchoLoadingStateComponent, EchoStatePanelComponent], + templateUrl: './studio-review.component.html', + styleUrl: './studio-review.component.scss', +}) +export class StudioReviewComponent implements OnInit, OnDestroy { + readonly artifactStatuses = ['draft', 'in_review', 'approved', 'needs_changes', 'rejected']; + readonly productStatuses = ['draft', 'in_review', 'approved', 'archived']; + review?: ReviewCenter; + loading = true; + failed = false; + saving = false; + decision?: PendingDecision; + actionError = ''; + statusMessage = ''; + private readonly subscriptions = new Subscription(); + + constructor(private readonly platform: V2PlatformService) {} + ngOnInit(): void { this.load(); } + ngOnDestroy(): void { this.subscriptions.unsubscribe(); } + + load(): void { + this.loading = true; this.failed = false; + this.subscriptions.add(this.platform.getReviewCenter().subscribe({ next: review => { this.review = review; this.loading = false; }, error: () => { this.failed = true; this.loading = false; } })); + } + + requestDecision(item: ReviewCenterItem, status: string): void { if (item.status !== status) { this.actionError = ''; this.decision = { item, status }; } } + cancelDecision(): void { if (!this.saving) this.decision = undefined; } + applyDecision(): void { + if (!this.decision || this.saving) return; + const { item, status } = this.decision; + this.saving = true; this.actionError = ''; + const request: Observable = item.item_type === 'artifact' + ? this.platform.updateArtifactReviewStatus(item.id, { status }) + : this.platform.updateProductReviewStatus(item.id, { status }); + this.subscriptions.add(request.subscribe({ + next: () => { this.saving = false; this.decision = undefined; this.statusMessage = `“${item.title}” moved to ${this.label(status)}.`; this.load(); }, + error: () => { this.saving = false; this.actionError = 'The review state was not changed. Review the item and try again.'; }, + })); + } + + detailRoute(item: ReviewCenterItem): string | null { return item.item_type === 'artifact' ? `/studio/drafts/${item.id}` : item.item_type === 'product' ? `/studio/content/${item.id}` : null; } + statuses(item: ReviewCenterItem): string[] { return item.item_type === 'artifact' ? this.artifactStatuses : item.item_type === 'product' ? this.productStatuses : []; } + label(value: string): string { return value.replace(/_/g, ' ').replace(/\b\w/g, character => character.toUpperCase()); } + statusClass(item: ReviewCenterItem): string { return item.blocked || ['rejected', 'archived'].includes(item.status) ? 'studio-status--danger' : ['approved', 'published'].includes(item.status) ? 'studio-status--success' : 'studio-status--warning'; } + get decisionTitle(): string { return this.decision ? `Move this ${this.decision.item.item_type === 'artifact' ? 'content draft' : 'learning offering'} to ${this.label(this.decision.status)}?` : 'Confirm review decision'; } + get decisionDescription(): string { + if (!this.decision) return ''; + if (this.decision.status === 'approved') return 'This records wrapper approval. It does not publish course lessons or make learner content available.'; + if (this.decision.status === 'archived') return 'This removes the wrapper from active review work. The current API does not provide a restore-specific action or audit history.'; + if (this.decision.status === 'rejected') return 'This records rejection for the content draft. It does not delete the draft or its source records.'; + if (this.decision.status === 'needs_changes') return 'This records that revision is required. Existing draft content and source associations remain in place.'; + return 'This changes the wrapper review state only. Learner-visible lesson governance remains unchanged.'; + } +} diff --git a/frontend/src/app/services/shell-navigation.service.spec.ts b/frontend/src/app/services/shell-navigation.service.spec.ts index 7af4fa5..1db4968 100644 --- a/frontend/src/app/services/shell-navigation.service.spec.ts +++ b/frontend/src/app/services/shell-navigation.service.spec.ts @@ -72,9 +72,12 @@ describe('ShellNavigationService', () => { const labels = sections.flatMap((section) => section.items.map((item) => item.label)); expect(service.getPrimarySpace(new Set(['role:content_admin'])).name).toBe('Studio'); - expect(labels).toContain('Content Studio'); - expect(labels).toContain('Learning Products'); + expect(service.getPrimarySpace(new Set(['role:content_admin'])).canonicalRoute).toBe('/studio'); + expect(labels).toContain('Content'); + expect(labels).toContain('Courses'); + expect(labels).toContain('Programs and Paths'); expect(labels).toContain('Sources'); + expect(labels).not.toContain('Invitations'); expect(labels).not.toContain('Commercial'); }); diff --git a/frontend/src/app/services/shell-navigation.service.ts b/frontend/src/app/services/shell-navigation.service.ts index 623c46f..8e93dd6 100644 --- a/frontend/src/app/services/shell-navigation.service.ts +++ b/frontend/src/app/services/shell-navigation.service.ts @@ -67,13 +67,15 @@ export class ShellNavigationService { title: 'Studio', space: 'studio', items: [ - { label: 'Studio Home', route: '/workspace', icon: 'Home', roles: ['content_admin', 'org_admin'], exact: true }, - { label: 'Projects', route: '/workspace/projects', icon: 'ClipboardList', permission: 'nav:projects', roles: ['content_admin', 'org_admin'] }, - { label: 'Content Studio', route: '/workspace/product-studio', icon: 'BookOpen', permission: 'nav:product-studio', roles: ['content_admin', 'org_admin'] }, - { label: 'Learning Products', route: '/workspace/products', icon: 'Book', permission: 'nav:products', roles: ['content_admin', 'org_admin'] }, - { label: 'Sources', route: '/workspace/knowledge-sources', icon: 'FileText', permission: 'nav:knowledge-sources', roles: ['content_admin', 'org_admin'] }, - { label: 'Review', route: '/workspace/review-center', icon: 'ClipboardList', permission: 'nav:review-center', roles: ['content_admin', 'org_admin'] }, - { label: 'Analytics', route: '/workspace/analytics', icon: 'SlidersHorizontal', permission: 'nav:analytics', roles: ['content_admin', 'org_admin'] }, + { label: 'Studio Overview', route: '/studio', icon: 'Home', roles: ['content_admin'], exact: true }, + { label: 'Projects', route: '/studio/projects', icon: 'ClipboardList', permission: 'nav:projects', roles: ['content_admin'] }, + { label: 'Content', route: '/studio/content', icon: 'BookOpen', permission: 'nav:products', roles: ['content_admin'] }, + { label: 'Courses', route: '/studio/courses', icon: 'Book', permission: 'nav:products', roles: ['content_admin'] }, + { label: 'Programs and Paths', route: '/studio/programs', icon: 'ClipboardList', permission: 'nav:products', roles: ['content_admin'] }, + { label: 'Sources', route: '/studio/sources', icon: 'FileText', permission: 'nav:knowledge-sources', roles: ['content_admin'] }, + { label: 'Content Drafts', route: '/studio/drafts', icon: 'FileText', permission: 'nav:product-studio', roles: ['content_admin'] }, + { label: 'Review', route: '/studio/review', icon: 'ClipboardList', permission: 'nav:review-center', roles: ['content_admin'] }, + { label: 'Publishing', route: '/studio/publishing', icon: 'SlidersHorizontal', permission: 'nav:products', roles: ['content_admin'] }, ], }, { @@ -81,10 +83,10 @@ export class ShellNavigationService { space: 'organization', items: [ { label: 'Organization Home', route: '/workspace/learners', icon: 'Home', roles: ['org_admin'], exact: true }, - { label: 'Invitations', route: '/workspace/settings/invites', icon: 'Users', permission: 'nav:org-invites', roles: ['org_admin', 'content_admin'] }, + { label: 'Invitations', route: '/workspace/settings/invites', icon: 'Users', permission: 'nav:org-invites', roles: ['org_admin'] }, { label: 'Classes', route: '/workspace/learners/cohorts', icon: 'Users', permission: 'nav:cohorts', roles: ['org_admin'] }, - { label: 'Access', route: '/workspace/access', icon: 'Users', permission: 'nav:access', roles: ['org_admin', 'content_admin'] }, - { label: 'Settings', route: '/workspace/settings', icon: 'Settings', permission: 'nav:settings', roles: ['org_admin', 'content_admin'] }, + { label: 'Access', route: '/workspace/access', icon: 'Users', permission: 'nav:access', roles: ['org_admin'] }, + { label: 'Settings', route: '/workspace/settings', icon: 'Settings', permission: 'nav:settings', roles: ['org_admin'] }, ], }, { @@ -136,7 +138,7 @@ export class ShellNavigationService { name: 'Studio', eyebrow: 'EchoEd Studio', description: 'Prepare, review, and steward learning content for your community.', - canonicalRoute: '/workspace', + canonicalRoute: '/studio', }; } diff --git a/frontend/src/styles/_studio-production.scss b/frontend/src/styles/_studio-production.scss new file mode 100644 index 0000000..02f9af7 --- /dev/null +++ b/frontend/src/styles/_studio-production.scss @@ -0,0 +1,104 @@ +.studio-page { + color: var(--ee-color-text-default); + display: grid; + gap: var(--ee-space-6); + margin-inline: auto; + max-width: var(--ee-space-layout-content-max); + padding: var(--ee-space-layout-page); + width: 100%; +} + +.studio-page__header { + align-items: flex-start; + display: flex; + gap: var(--ee-space-4); + justify-content: space-between; +} + +.studio-page__eyebrow { + color: var(--ee-color-action-primary-background); + font-size: var(--ee-font-size-xs); + font-weight: 800; + margin: 0 0 var(--ee-space-1); + text-transform: uppercase; +} + +.studio-page h1, +.studio-page h2, +.studio-page h3, +.studio-page p { margin-top: 0; } +.studio-page h1 { font-size: var(--ee-font-size-3xl); margin-bottom: var(--ee-space-2); } +.studio-page h2 { font-size: var(--ee-font-size-xl); margin-bottom: var(--ee-space-4); } +.studio-page__intro { color: var(--ee-color-text-muted); margin-bottom: 0; max-width: 48rem; } + +.studio-panel { + background: var(--ee-color-surface-raised); + border: 1px solid var(--ee-color-border-subtle); + border-radius: var(--ee-radius-card-default); + padding: var(--ee-space-5); +} + +.studio-summary-grid, +.studio-shortcuts, +.studio-records { + display: grid; + gap: var(--ee-space-4); + grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr)); +} + +.studio-metric { border-left: 4px solid var(--ee-color-river-600); } +.studio-metric__value { font-size: var(--ee-font-size-3xl); font-weight: 800; line-height: 1; margin-bottom: var(--ee-space-2); } +.studio-metric__label { color: var(--ee-color-text-muted); font-size: var(--ee-font-size-sm); margin-bottom: 0; } + +.studio-link, +.studio-button { + align-items: center; + border-radius: var(--ee-radius-control-default); + display: inline-flex; + font-weight: 750; + justify-content: center; + min-height: 2.75rem; + padding: .65rem 1rem; + text-decoration: none; +} +.studio-link { color: var(--ee-color-text-link); justify-content: flex-start; } +.studio-button { background: var(--ee-color-action-primary-background); border: 1px solid var(--ee-color-action-primary-border); color: var(--ee-color-action-primary-text); cursor: pointer; } +.studio-button--secondary { background: var(--ee-color-action-secondary-background); border-color: var(--ee-color-action-secondary-border); color: var(--ee-color-action-secondary-text); } +.studio-button--danger { background: var(--ee-color-action-danger-background); border-color: var(--ee-color-action-danger-border); color: var(--ee-color-action-danger-text); } +.studio-button:disabled { cursor: not-allowed; opacity: .6; } +.studio-link:focus-visible, .studio-button:focus-visible, .studio-control:focus-visible { + box-shadow: var(--ee-focus-ring-shadow); + outline: var(--ee-focus-ring-width) solid var(--ee-focus-ring-color); + outline-offset: var(--ee-focus-ring-offset); +} + +.studio-toolbar { align-items: end; display: flex; flex-wrap: wrap; gap: var(--ee-space-3); } +.studio-field { display: grid; gap: var(--ee-space-1); min-width: min(100%, 14rem); } +.studio-field label { font-size: var(--ee-font-size-sm); font-weight: 750; } +.studio-control { background: var(--ee-color-surface-raised); border: 1px solid var(--ee-color-border-default); border-radius: var(--ee-radius-control-default); color: inherit; min-height: 2.75rem; padding: .6rem .75rem; width: 100%; } +.studio-results { color: var(--ee-color-text-muted); font-size: var(--ee-font-size-sm); margin: 0; } + +.studio-record { align-content: start; display: grid; gap: var(--ee-space-3); min-width: 0; } +.studio-record__heading { align-items: flex-start; display: flex; gap: var(--ee-space-3); justify-content: space-between; } +.studio-record__heading h2 { font-size: var(--ee-font-size-lg); margin-bottom: 0; overflow-wrap: anywhere; } +.studio-record__summary { color: var(--ee-color-text-muted); margin-bottom: 0; overflow-wrap: anywhere; } +.studio-record__meta { display: grid; gap: var(--ee-space-2); margin: 0; } +.studio-record__meta div { border-top: 1px solid var(--ee-color-border-subtle); padding-top: var(--ee-space-2); } +.studio-record__meta dt { color: var(--ee-color-text-muted); font-size: var(--ee-font-size-xs); font-weight: 750; text-transform: uppercase; } +.studio-record__meta dd { margin: var(--ee-space-1) 0 0; overflow-wrap: anywhere; } +.studio-actions { display: flex; flex-wrap: wrap; gap: var(--ee-space-3); } + +.studio-status { background: var(--ee-color-status-info-background); border: 1px solid var(--ee-color-status-info-border); border-radius: var(--ee-radius-control-pill); color: var(--ee-color-status-info-text); display: inline-block; flex: none; font-size: var(--ee-font-size-xs); font-weight: 800; padding: .2rem .55rem; text-transform: capitalize; } +.studio-status--warning { background: var(--ee-color-status-warning-background); border-color: var(--ee-color-status-warning-border); color: var(--ee-color-status-warning-text); } +.studio-status--success { background: var(--ee-color-status-success-background); border-color: var(--ee-color-status-success-border); color: var(--ee-color-status-success-text); } +.studio-status--danger { background: var(--ee-color-status-danger-background); border-color: var(--ee-color-status-danger-border); color: var(--ee-color-status-danger-text); } + +.studio-callout { background: var(--ee-color-status-info-background); border: 1px solid var(--ee-color-status-info-border); border-radius: var(--ee-radius-card-default); color: var(--ee-color-status-info-text); padding: var(--ee-space-4); } +.studio-callout p:last-child { margin-bottom: 0; } + +@media (max-width: 47.99rem) { + .studio-page__header { display: grid; } + .studio-toolbar, .studio-field { width: 100%; } + .studio-button { width: 100%; } + .studio-record__heading { display: grid; } +} diff --git a/frontend/tests/demo/studio-content-smoke.spec.ts b/frontend/tests/demo/studio-content-smoke.spec.ts new file mode 100644 index 0000000..493e4a7 --- /dev/null +++ b/frontend/tests/demo/studio-content-smoke.spec.ts @@ -0,0 +1,69 @@ +import { expect, test } from '@playwright/test'; + +const CONTENT_ADMIN_USERNAME = process.env['DEMO_CONTENT_ADMIN_USERNAME'] || 'contentadmin'; +const CONTENT_ADMIN_PASSWORD = process.env['DEMO_CONTENT_ADMIN_PASSWORD'] || 'password'; +const DRAFT_OFFERING_TITLE = process.env['DEMO_STUDIO_DRAFT_TITLE'] || 'EchoEd Governance Review Pack'; + +async function signIn(page: import('@playwright/test').Page, username: string, password: string) { + await page.goto('/login'); + await page.getByLabel('Email or Username').fill(username); + await page.getByLabel('Password').fill(password); + await page.getByRole('button', { name: 'Sign in' }).click(); +} + +test.describe('seeded Content Studio smoke', () => { + test('content administrator can inspect supported Studio work without publishing', async ({ page }) => { + await signIn(page, CONTENT_ADMIN_USERNAME, CONTENT_ADMIN_PASSWORD); + await expect(page).toHaveURL(/\/studio$/); + await expect(page.getByRole('heading', { name: 'Studio overview' })).toBeVisible(); + await expect(page.getByText('Current authoring scope')).toBeVisible(); + + await page.getByRole('link', { name: 'Projects', exact: true }).first().click(); + await expect(page.getByRole('heading', { name: 'Projects', exact: true })).toBeVisible(); + await page.getByRole('searchbox', { name: 'Search', exact: true }).fill('EchoEd'); + await expect(page.getByText(/items? shown/)).toBeVisible(); + + await page.getByRole('link', { name: 'Sources', exact: true }).first().click(); + await expect(page.getByRole('heading', { name: 'Source library' })).toBeVisible(); + + let progressRequests = 0; + page.on('request', request => { if (request.url().includes('/api/progress') || request.url().includes('/api/start-course')) progressRequests += 1; }); + await page.getByRole('link', { name: 'Content Drafts', exact: true }).first().click(); + await expect(page.getByRole('heading', { name: 'Content drafts' })).toBeVisible(); + const firstDraft = page.getByRole('link', { name: /^Review / }).first(); + if (await firstDraft.count()) { + await firstDraft.click(); + await expect(page.getByText('This is a content-record preview.')).toBeVisible(); + expect(progressRequests).toBe(0); + } + + await page.getByRole('link', { name: 'Content', exact: true }).first().click(); + await expect(page.getByRole('heading', { name: 'Content library' })).toBeVisible(); + await page.getByRole('searchbox', { name: 'Search', exact: true }).fill(DRAFT_OFFERING_TITLE); + await page.getByRole('link', { name: `Review ${DRAFT_OFFERING_TITLE}` }).click(); + const publishButton = page.getByRole('button', { name: 'Publish publicly' }); + await expect(publishButton).toBeVisible(); + await publishButton.click(); + await expect(page.getByRole('dialog', { name: 'Publish this learning offering publicly?' })).toBeVisible(); + await expect(page.getByText(/does not publish course lessons/i)).toBeVisible(); + await page.getByRole('button', { name: 'Cancel' }).click(); + + await page.getByRole('link', { name: 'Review', exact: true }).first().click(); + await expect(page.getByRole('heading', { name: 'Review content' })).toBeVisible(); + + await page.setViewportSize({ width: 390, height: 844 }); + await expect(page.getByRole('heading', { name: 'Review content' })).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true); + + await page.keyboard.press('Tab'); + await expect(page.locator(':focus')).toBeVisible(); + }); + + test('student deep link to Studio is rejected by the shared permission flow', async ({ page }) => { + await signIn(page, process.env['DEMO_STUDENT_USERNAME'] || 'normalstudent', process.env['DEMO_STUDENT_PASSWORD'] || 'password'); + await expect(page).toHaveURL(/\/learn$/); + await page.goto('/studio/review'); + await expect(page).toHaveURL(/\/access-denied$/); + await expect(page.getByText(/permission|access/i).first()).toBeVisible(); + }); +}); diff --git a/openspec/changes/overhaul-role-based-ui-ux-experience/tasks.md b/openspec/changes/overhaul-role-based-ui-ux-experience/tasks.md index bfae52c..b8f15ae 100644 --- a/openspec/changes/overhaul-role-based-ui-ux-experience/tasks.md +++ b/openspec/changes/overhaul-role-based-ui-ux-experience/tasks.md @@ -52,7 +52,7 @@ ## 8. Studio, Organization, and Community Surfaces -- [ ] 8.1 Redesign Studio navigation and product/source/artifact/review screens for content admins; affected screens: workspace projects/product-studio/products/sources/artifacts/review; APIs: V2 platform APIs; tests: V2 service/page specs; a11y: review status text; responsive: queue cards; frontend-only: yes; rollback: old workspace templates. +- [x] 8.1 Redesign Studio navigation and product/source/artifact/review screens for content admins; affected screens: workspace projects/product-studio/products/sources/artifacts/review; APIs: V2 platform APIs; tests: V2 service/page specs; a11y: review status text; responsive: queue cards; frontend-only: yes; rollback: old workspace templates. - [ ] 8.2 Redesign organization people, invites, access, and settings over existing org/invite/access APIs; affected screens: org invites, access grants, settings; APIs: orgs, invites, access grants; tests: org/access specs; a11y: role descriptions and confirmation; responsive: mobile lists; frontend-only: yes; rollback: old screens. - [x] 8.3 Rename visible community-facing labels while preserving `/workspace/commercial` route until alias/deprecation is planned; affected screens: community route/nav; APIs: none; tests: route/nav specs and copy search; a11y: clear labels; responsive: no layout regression; frontend-only: yes; rollback: label revert.