* fix(dashboard): show course cards in landing page settings preview
Fetch public courses for the org and pass them to the theme preview
on the landing page settings screen, matching the editor preview.
Co-authored-by: Rotimi Best <[email protected]>
* refactor(dashboard): skip redundant public course fetches
Add loadPublicCoursesIfNeeded so settings, editor, and live landing
page reuse cached courses for the same org site instead of clearing
and refetching on every mount.
Co-authored-by: Rotimi Best <[email protected]>
* fix(dashboard): always refetch courses on live org landing page
Keep loadPublicCoursesIfNeeded for settings preview and editor only.
The public-facing landing page should fetch fresh on each visit.
Co-authored-by: Rotimi Best <[email protected]>
* feat(ui): add shared empty state for org landing page catalogs
Introduce OrgLandingPageCoursesEmpty using the dashboard Empty pattern
with landing-theme colors. Show it across all ten org landing templates
when no courses are published, and wire translated labels through
buildOrgLandingPageProps.
Co-authored-by: Rotimi Best <[email protected]>
* fix(dashboard): address Greptile review on public courses cache
- Add coursesLoaded prop so empty catalog state waits for fetch
- Deduplicate concurrent public course requests via in-flight promise
- Refresh courses on settings mount; reuse cache in editor only
- Expose invalidatePublicCourses for explicit cache clearing
Co-authored-by: Rotimi Best <[email protected]>
* fix(dashboard): load landing preview courses when org store hydrates
Replace onMount course fetch with a reactive effect keyed on the route
slug and currentOrg.siteName so public courses load after app init.
Mark the fetch as complete in finally so failed requests show the empty
state instead of a permanently blank catalog section.
Co-authored-by: Rotimi Best <[email protected]>
---------
* fix(website): restore radial hero backgrounds on page headers
PageHeader lost its decorative background when hero-bg was replaced by
DotField. Add a bottom-centered radial glow via a CSS class so it renders
reliably without Tailwind arbitrary-value parsing issues.
Also move the signup CTA gradient from an inline arbitrary Tailwind class
to the same CSS-class pattern for consistency.
Co-authored-by: Rotimi Best <[email protected]>
* fix(website): restore dotted DotPattern hero background on PageHeader
Revert the radial glow added to page headers and bring back the static
DotPattern grid that was replaced by DotField in c31ad6395.
Co-authored-by: Rotimi Best <[email protected]>
---------
Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
* fix(api): return Zod validation errors from public API routes
Public API handlers now surface ZodError details as 400 VALIDATION_ERROR
responses instead of generic 500 INTERNAL_ERROR payloads. Course import
services also rethrow ZodError so validation failures are not wrapped as
server errors.
Co-authored-by: Rotimi Best <[email protected]>
* refactor(utils): centralize course type values without db dependency
Define COURSE_TYPE_VALUES once in @cio/utils/constants and use it for
ZCourseType validation and the Drizzle COURSE_TYPE pgEnum. Removes the
circular @cio/utils -> @cio/db import while keeping course-import schemas
aligned with all four course types including PUBLIC.
Co-authored-by: Rotimi Best <[email protected]>
* fix(validation): default missing course metadata allowNewStudent to true
Courses stored without allowNewStudent in metadata failed structure export
validation. Align Zod preprocessing with runtime behavior where absent
values mean enrollments are open.
Co-authored-by: Rotimi Best <[email protected]>
* chore(db): drop unused COURSE_TYPE_VALUES re-export from schema
Co-authored-by: Rotimi Best <[email protected]>
---------
Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
Embed iframes were placed inside flex column layouts without shrink
protection, so authored width/height attributes could be ignored when the
preview panel was narrower than the iframe. Parse dimensions from pasted
embed code and size a dedicated host wrapper so the iframe renders at the
intended dimensions while still scaling down with max-width: 100%.
Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
* fix(dashboard): API page snippet highlighting and breadcrumb i18n
Stop double-translating nav titles in breadcrumbs (t was called on
already-localized strings like Audience and API).
Use Code.Root default typescript highlighting for all API quick-start
snippets instead of unsupported bash/python/go langs.
Co-authored-by: Rotimi Best <[email protected]>
* revert(dashboard): drop breadcrumb i18n change from API page PR
Translation warnings were unrelated; keep only the Shiki lang fix.
Co-authored-by: Rotimi Best <[email protected]>
---------
Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
* feat(api): purge Cloudflare CDN before and after OpenAPI spec upload
Purge api.cdn.clsrio.com edge cache for openapi-latest and the dated
spec URL so deploys are not blocked by the 1h Cache-Control TTL on R2
objects. Uses the same CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID
pattern as embed uploads; skips when credentials are unset.
Co-authored-by: Rotimi Best <[email protected]>
* fix(api): purge OpenAPI CDN cache only after upload
Match the embeds upload pattern: update R2 first, then invalidate
edge cache so clients refetch the new spec.
Co-authored-by: Rotimi Best <[email protected]>
* fix(api): treat Cloudflare purge 2xx as success when body is unparseable
Avoid false upload failures when the purge API returns 200 with a
non-JSON body. Addresses Greptile review on #704.
Co-authored-by: Rotimi Best <[email protected]>
---------
Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
* feat(cohorts): rename Programs to Cohorts with data migration
- Add cohort database tables and Drizzle migration alongside legacy program tables
- Introduce migrate-programs-to-cohorts script to copy existing program data safely
- Rename API routes (/cohort), services, queries, validation, and error codes
- Rename dashboard feature module, routes (/cohorts), navigation, and translations
- Update docs, emails, and internal compliance cron endpoints for cohort terminology
- Keep legacy program tables in place for rollback until explicitly removed
Co-authored-by: Rotimi Best <[email protected]>
* fix(db): restore migrate:answer-data script alongside cohort migration
Co-authored-by: Rotimi Best <[email protected]>
* fix(db): cast program enums when migrating data to cohort tables
Co-authored-by: Rotimi Best <[email protected]>
* fix(db): truncate cohort tables before program-to-cohort copy
Co-authored-by: Rotimi Best <[email protected]>
* fix(db): report accurate row counts in programs-to-cohorts migration
Co-authored-by: Rotimi Best <[email protected]>
---------
Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
* fix(automation): align public API plan gating across cloud and self-hosted
Add shared canUsePublicApi helper so cloud allows Early Adopter and
Enterprise while self-hosted requires Enterprise. Update the API key
dashboard page and backend key creation checks to use the same rules.
Co-authored-by: Rotimi Best <[email protected]>
* docs(api): document bearer auth on public API reference
Add shared OpenAPI enrichment with bearerAuth security scheme and
authentication instructions. The interactive reference now loads an
enriched spec with Authorize support, and the API guide explains how
to create and use org API keys.
Co-authored-by: Rotimi Best <[email protected]>
* feat(dashboard): add API quick-start code snippets on automation page
Mirror the MCP client setup section with cURL, JavaScript, and Python
examples that show how to authenticate public API requests with org keys.
Co-authored-by: Rotimi Best <[email protected]>
* ci(openapi): republish spec when public API OpenAPI helpers change
Co-authored-by: Rotimi Best <[email protected]>
* feat(dashboard): add Go snippet to API quick-start examples
Co-authored-by: Rotimi Best <[email protected]>
* docs(api): use /org/*/api and clarify dashboard key generation path
Co-authored-by: Rotimi Best <[email protected]>
* fix(api): escape backticks in OpenAPI description template literal
Greptile flagged that unescaped backticks around * in
PUBLIC_API_OPENAPI_DESCRIPTION evaluated to NaN and dropped auth docs
from the Scalar spec. Also use plan-specific snackbar message on upgrade.
Co-authored-by: Rotimi Best <[email protected]>
---------
Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
- Add Types section to the Filter sidebar with all 4 course types
- Add full-text search input centered in the hero (title + description)
- Add Pricing section (Free / Paid) based on course cost
- Compact hero prop across all 10 themes to halve vertical padding
- Children snippet support in all hero themes for the search input
- All filters are URL-driven (?types=, ?search=, ?tags=, ?pricing=)
- Full-stack: validation schema, DB queries, service, API route, server load, UI
The embed section passed code through SafeHtmlContent which ran DOMPurify
with FORBID_TAGS including 'iframe', silently removing the element.
Replaced SafeHtmlContent with a new sanitizeEmbedHtml() that uses a
whitelist of ALLOWED_TAGS: ['iframe'] and restricts src to https: only,
so Google Forms, YouTube, and other https embeds render while javascript:
sources and non-iframe tags are still blocked.
Items with isUnlocked = null were evaluated as teacher-locked due to
`?? false` defaulting to false before negation. This caused content to
appear locked immediately after a student marked a lesson complete, even
in free-mode courses.
Changed to `?? true` in both client-side (progression-utils.ts) and
server-side (core/progression.ts) so null/undefined isUnlocked defaults
to unlocked — consistent with the sidebar's own `?? true` guard.
* feat: make upload file size limits configurable via .env
Add UPLOAD_MAX_*_MB environment variables for documents, images, videos,
exercise assignment files, AI assistant documents, landing page images,
and media thumbnails. API presign routes validate file size server-side;
dashboard UI reads the same limits from layout data.
* chore: wire upload limit env vars through Docker compose and docs
Pass UPLOAD_MAX_*_MB from the root .env into api and dashboard containers
in both compose files, document them in .env.example and self-hosting guides.
Co-authored-by: Rotimi Best <[email protected]>
* docs: cross-link upload limits in README, Railway, Coolify, and Cloudflare guides
Co-authored-by: Rotimi Best <[email protected]>
* fix: address PR review feedback on upload limits
- Restore 5 MB default for thumbnail uploads (edit-asset-dialog regression)
- Add getResolvedUploadLimits() fallback for Storybook/tests
- Cache core getUploadLimits() like the dashboard helper
- Document presign fileSize as advisory; allow zero-byte fileSize in schema
- Only send fileSize to presign when greater than zero
* docs: remove upload limits from Cloudflare setup guide
---------
- Add .odt (application/vnd.oasis.opendocument.text) as a supported upload
format; extract text via JSZip + content.xml parsing
- Add attach_document_to_lesson agent tool so the AI can attach an uploaded
document to a lesson as a downloadable material
- Instruct the agent to create a "Course Materials" lesson (first lesson of
the first section) and attach the uploaded document to it on implementation
- Suppress hallucinated references: uploaded documents do not count as fetched
URLs — omit the References section when no fetch_documentation_url calls
were made
- Surface specific upload error snackbars (file_too_large, unsupported_file_type)
instead of silently logging; fix generic fallback message
- Allow typing in the chat textarea while a document is uploading; only block
Enter/send, not the whole input
- Fix retry endpoint to return the current run instead of erroring when the run
is already active (queued/running/waiting_for_input/paused)
- Connect @cio/core Redis singleton in the jobs bootstrap so course-generation
workers no longer fail with "The client is closed"
- Expose documentAssets (assetId + fileName) alongside documentText in
AgentContext and pass it through both the chat route and course runner
* feat(lms): add student certificates page and sidebar nav
Add a Certificates item to the student LMS sidebar that lists courses
where the student has earned a certificate. Each course card includes a
View Certificate action linking to the existing per-course certificate
page.
* refactor: reuse isOrgOnFreePlan helper from org store
* chore(i18n): add certificates translations for all locales
---------
exercise.svelte was auto-adding a QUESTION_TEMPLATE to the store whenever
questions were empty, which raced with hydrateExercisePageData.
applyMySubmission fired with the blank template in place, set
isFinished=true, and the guard prevented re-application once real
questions loaded.
Two fixes: guard the auto-add effect with isOrgStudent (students never
need to add questions), and gate applyMySubmission on isExerciseLoaded
so it always waits for hydrateExercisePageData to populate real questions
first.
Also fix off-by-1 in applyMySubmission (questions.length → questions.length + 1)
so currentQuestion is undefined at end-of-exercise, satisfying the
isFinished derived. Clear questionnaire.questions in onDestroy to prevent
stale questions flashing on the next exercise.
Fix lesson delete not updating sidebar/content list by filtering the
removed lesson from sections on success.
When a lesson has two or more uploaded videos each calls mintHlsCookie,
but all wrote to the same `cio_hls` cookie name. The last write won,
leaving every other video with a mismatched token and a 403 from the
CF Worker.
Cookie name is now `cio_hls_${assetId}` so each video owns its own
cookie. The tenant-router Worker, the Hono HLS fallback route, and the
transcripts route all read the per-asset name.
Replace the per-handler boolean guard with a shared 300ms cooldown
timestamp so all three Plyr seek-enforcement paths (progress-bar
listener, seeking, seeked) produce at most one snackbar per skip
attempt.
The registry hardcoded numeric ids that drifted from the database
`question_type` table: MATCHING and HOTSPOT were inserted mid-list
(ids 9 and 11) but never seeded, shifting ORDERING/LINK/WORD_BANK/
STAR/VIDEO_RECORDING one or two ids past their real rows. Every id
emitter inherited the wrong values — the exercise editor, the AI
question generator, and the grading/scoring gate — so e.g. an ORDERING
question was stored as id 10 (LINK in the DB), came back as a manual
type, and never auto-graded.
- Move MATCHING/HOTSPOT to the end (ids 14/15) and flag them
`disabled`, restoring the append-only id convention and realigning
ORDERING=9, LINK=10, WORD_BANK=11, STAR=12, VIDEO_RECORDING=13 with
the DB.
- Add a registry test asserting every enabled type's id matches the
canonical `question_type` rows and disabled types stay past the live
range, so ids can't silently drift again.
- Constrain the agent tool schema to enabled, DB-backed ids so the
model can't emit a disabled id with no row.
- Drop disabled types from the teacher prompt's type list and guidance.
- Centralize the dashboard question-type picker on the `disabled` flag.
This also fixes video-recording detection, which compared against the
stale id 15 instead of the real 13.
Generated migration for the narrowed
asset_usages_asset_target_slot_unique constraint (asset_id, target_type,
target_id, slot_type, slot_key). Run the duplicate cleanup before
applying — existing rows that differ only by position will otherwise
violate the new constraint.
Drop `position` from the `asset_usages` unique constraint so the same
asset can't be attached to one target/slot more than once, and update
the insert's onConflict target + lookup to match the narrower key.
Guard it at the service layer too: attachAssetService now rejects an
attach when the asset already has a usage for that target with a new
ASSET_ALREADY_ATTACHED (409) error, backed by an
assetUsageExistsForTarget query.
Add an admin flow to permanently delete a media asset and clean up
everything it leaves behind.
- Delete dialog with a destructive, irreversible confirmation; deletion
is blocked while the video is still attached and shows the in-use
lessons so the admin can detach first.
- Usage list resolves lesson/course context for each attachment, links
to the lesson editor via an anchor (right-click "open in new tab"),
and exposes a refresh button to re-check usage after detaching
elsewhere.
- Storage cleanup runs as a background maintenance job: the producer
captures the asset's object keys/prefixes up front and an idempotent
worker sweeps HLS segments, thumbnails, audio, transcript, and the
raw upload from object storage.
Also harden usage accounting so stale attachments cannot trap an asset:
- asset_usages has no FK to its polymorphic target, so prune a lesson's
usage rows whenever the lesson is hard-deleted (single + bulk paths).
- Usage resolution drops orphaned lesson usages (lesson gone or course
soft-deleted) so they neither appear in the list nor block deletion.
Document the icon-only button convention (variant="secondary") in
AGENTS.md.
The env module was moved to @cio/core/config/env but the upload-openapi-spec
script still imported from the removed apps/api/src/config/env path, causing
CI upload:openapi to fail with ERR_MODULE_NOT_FOUND.
Co-authored-by: Rotimi Best <[email protected]>
* feat(course): add reset student course progress action
Give course admins and tutors a destructive reset-progress flow on the
student people detail page. The backend clears learner progress for one
(courseId, memberId) pair in a single transaction while keeping the
groupmember enrolled, and logs an audit summary. The dashboard shows a
confirmation dialog with impact counts from analytics before deleting.
Co-authored-by: Rotimi Best <[email protected]>
* fix(i18n): add localized reset progress strings for all locales
Co-authored-by: Rotimi Best <[email protected]>
* fix(course): scope reset progress to course data only
Stop deleting org-level community questions and answers during course
progress reset. Fix course newsfeed deletion order so comments on a
student's posts are removed before the posts themselves. Restrict reset
to student members, move analytics loading to the person detail layout,
and rename impact copy to course newsfeed only.
Co-authored-by: Rotimi Best <[email protected]>
* fix(course): address Greptile review on reset progress
Make progressImpact opt-in via includeProgressImpact query param so
analytics requests avoid extra DB work unless the reset dialog needs it.
Count only student-authored course newsfeed posts and comments in the
impact summary to avoid double-counting replies on the student's posts.
Co-authored-by: Rotimi Best <[email protected]>
---------
Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
Disabled items were overriding props.class with a custom class string,
stripping the Sidebar.MenuButton's height, padding, and color styles.
Drop the inline class and rely on the existing aria-disabled CSS rules.
* fix(dashboard): course menu icons, delete scroll, and settings tags auth
Remove inconsistent dropdown icons, scroll to settings sections for delete
and related actions when already on the settings page, and skip org tag
group fetches for non-admin users to avoid ORG_TEAM_NOT_AUTHORIZED errors.
* fix(api): allow org members to fetch organization tags
Use orgMemberMiddleware for GET /organization/tags instead of restricting
reads to admins. Revert the dashboard settings workaround that skipped tag
group fetches for non-admin users.
---------
Disabled items were overriding props.class with a custom class string,
stripping the Sidebar.MenuButton's height, padding, and color styles.
Drop the inline class and rely on the existing aria-disabled CSS rules.
Add View course site and Publish course options to org course context menus.
Replace the course header View as student button with View Course Site and
a companion overflow menu that includes View as student plus list actions.
Show a publish prompt modal for unpublished courses and support #publish
deep links in course settings.
Clamping currentTime in the seeking/seeked handlers is ignored on progressive
MP4 (the forward seek takes effect immediately), so the must-watch lock leaked
even though the snackbar showed.
Block at the source: a Plyr `seek` listener returns false for targets past the
watched point, which skips Plyr's default seek so the media never moves.
Rewinding and re-seeking up to the watched point stay allowed; the snackbar
fires when a forward skip is blocked. furthestSeconds is now shared between the
listener and the playback tracker. The seeking/seeked clamp is kept as a
backstop for keyboard/programmatic seeks that bypass the progress bar.
* fix(analytics): show per-student last seen from login and session activity
Return ISO last-seen timestamps from course analytics instead of
pre-formatted strings so the dashboard can format them per student.
Batch-fetch last seen for all students and include session activity
alongside login events for more accurate, distinct values.
Co-authored-by: Rotimi Best <[email protected]>
* fix(analytics): destructure first row from getLastLogin query results
Promise.all returns an array per Drizzle select; nested destructuring
is required so loggedInAt and updatedAt are read from row objects.
Co-authored-by: Rotimi Best <[email protected]>
---------
Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
The lock clamped currentTime only during the seeking event. HLS defers the
forward seek until the fragment loads, so that clamp caught it; but for
progressive MP4 the forward seek takes effect immediately and the mid-seek
write is ignored, so playback leaked past the watched point (snackbar showed,
no snap-back).
Keep the immediate seeking-time clamp (HLS relies on it to avoid buffering
ahead) and add an authoritative re-clamp in seeked: if the playhead still
landed past the limit, snap back via a backward seek into buffered content,
which holds. Snackbar now fires once per blocked gesture and only when a
clamp actually happens.
Resetting currentTime inside the seeking handler doesn't reliably stick
across sources (HLS re-buffering, emulated YouTube events, coalesced native
seeks), so the snackbar showed but playback continued ahead. The timeupdate
handler then ignored the large forward jump via its delta < 2 guard, making
the skip permanent.
Re-clamp in seeked once the position settles, and add a timeupdate backstop
that snaps back forward jumps (delta >= 2) landing past furthestSeconds.
The query selected groupmember.certificate_earned_at as a non-aggregated
column but only grouped by course.id and course_completion_record.id,
causing Postgres error 42803. Add groupmember.id (its PK) to the GROUP BY
so the certificate column is functionally determined.
Adds live-class session scheduling on lessons (join link, date/time,
timezone) with automatic 24h and 1h reminder emails plus a calendar
invite (.ics), a time-change notification with an updated invite, a
teacher-authored welcome email and session invite on enrollment, and a
"Notify students" quiz nudge.
LMS gets an upcoming live-sessions card and Join buttons, plus a
reusable live-session card component (3 states) in @cio/ui with a
Storybook story. The sidebar content tree now reflects sequential
progression locking like the content list, and the course-completion
confetti regression is fixed.
Org-based emails no longer reference ClassroomIO by name. Docs add
live-class, welcome-email, and course-progression guides, expand the
certificates guide (design, signatures, corrected labels), move
plan-required callouts to the top, flatten how-to-guides to the docs
root with redirects, and add screenshots. Includes an add-docs-image
skill.
* fix(dashboard): redirect students on app domain to org LMS
When a student logs in on app.classroomio.com, send them to their org
tenant domain with a full page reload instead of staying on /lms at the
app host. Org sites and self-hosted installs keep the existing behavior.
Students who explicitly open /onboarding while already belonging to an
org are no longer redirected away, so they can still create their own
academy with the same email.
Co-authored-by: Rotimi Best <[email protected]>
* refactor(dashboard): use getOrgPublicUrl instead of duplicate helper
Main added getOrgPublicOrigin for tenant URLs; extend it with
getOrgPublicUrl for path-based redirects and drop the parallel
getOrgSiteUrl helper from org utils.
Co-authored-by: Rotimi Best <[email protected]>
---------
* fix(dashboard): use verified custom domain for view-as-student handoff
Prefer the org's verified custom domain over window.location.origin when
resolving the public tenant host. This prevents admin URLs like
app.example.com from being used for the student login-link handoff when
the org's public site is example.com.
Co-authored-by: Rotimi Best <[email protected]>
* fix(dashboard): keep self-hosted org origin on window.location
Self-hosted deployments serve admin and LMS from the same URL, so
getOrgPublicOrigin should always return window.location.origin there
rather than preferring a configured custom domain.
Co-authored-by: Rotimi Best <[email protected]>
---------
Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
* docs(prd): add Zapier integration PRD
Draft PRD for Zapier integration with locked decisions on private-first distribution, Early Adopter+ plan gating, zapier:read/zapier:write scopes, polling-only triggers for v1, and apps/zapier app location. Covers trigger/action/search catalog, public API surface gaps, dashboard tab, and rollout plan.
Co-authored-by: Rotimi Best <[email protected]>
* docs(prd): revise Zapier integration PRD with OAuth and binary plan gating
Update PRD per planning decisions:
- Auth: switch from API key paste to OAuth 2.0 (authorization_code + refresh_token) shipping in v1; drop type: 'zapier' from organization_api_key.type enum.
- Plan gating: binary feature (Early Adopter+); no monthly Zapier credits, only per-minute rate limits. Plan downgrade revokes all grants immediately.
- Invite link: single shared private invite URL (we want adoption).
- Revoke UX: per-grant revoke in dashboard for safety net only; no bulk Disconnect button.
- Triggers: drop program_completed from v1 (now 5 triggers); deferred to v2.
- Add OAuth schema (oauth_authorization_code, oauth_access_token, oauth_refresh_token), routes, consent screen, plan-guard hook into license.ts.
Co-authored-by: Rotimi Best <[email protected]>
---------
The course cover Upload tab stored the raw file and relied on CSS
object-cover to fake the 280x200 frame at display time. Route uploads
through the existing ImageCropper at a 280/200 aspect ratio so the saved
asset matches the frame. Also teach the cropper UploadTrigger to accept
drag-and-drop so the prior drop-to-upload UX is preserved.
The footer grid (brand, socials, columns, CTAs) was only rendered for
the 'classic' variant. For 7 other variants (minimal, bold, saas, tech,
studio, corporate, terminal) the grid content was defined as a snippet
but never invoked, silently dropping social links and other footer
content.
The active tab's data-[state=active]:text-foreground rule outranked the
plain text-primary hover class, so hovering the active tab left white
text on the white hover pill (invisible in dark mode). Mark the hover
text-primary as important so the hovered active tab matches the hover
color of inactive tabs.
The org landing templates used 'Program(s)' in catalog headings, the
'view all' link, and the studio/terminal hero counters. That collided
with the app's actual Programs feature and confused users into thinking
a landing-page Program meant an app Program. Default copy now says
'Courses'; renamed the programsCount var to coursesCount to match.
A subdomain like xxazzz.myclassroomio.com set isOrgSite=true with a null
org, and the course page fell back to resolving the org from the course
itself — so courses rendered under subdomains that don't exist. Guard
the (org-site) layout so an unresolved org site 404s instead.
The preview forced images into a 280x200 box with default object-fit,
squeezing non-matching aspect ratios. Use object-cover so the image
fills the frame at its true aspect ratio and crops overflow instead.
Public course slugs carry a -<timestamp> uniqueness suffix that pushes
them past the 80-char ZSlug cap, so the org-site lookup returned a 400
and the page fell through to the landing page instead of redirecting to
the first lesson. The course.slug column is unbounded, so validate the
public-course route param with a no-max ZCourseSlug.
Use sanitizeOptionalHtml for reward.description now that the DB type
allows it to be omitted, fixing @cio/core build failures.
Co-authored-by: Cursor <[email protected]>
Make reward.description and instructor.coursesNo optional in the course
metadata JSON type so API builds match Zod validation.
Co-authored-by: Cursor <[email protected]>
- Allow coursesNo to be omitted in instructor metadata (not required)
- Allow reward description to be omitted when reward object is present
- Replace raw {@html} with SafeHtmlContent in course-pricing,
course-info-blocks, and embed landing-page components
Mirror the course list-row fix in the demo story: make the group a
container query that stacks below 48rem, cap tags at 3 with a +N pill,
and render the Default story full-width (padded) so both the stacked and
column layouts are visible. Uses unprefixed utilities since Storybook
generates those from its own Tailwind scan (prefixed classes only ship
in the prebuilt @cio/ui output.css).
The rigid 7-track grid always rendered in one row, so when the list was
squeezed the tag track shrank below the badge widths and tags overflowed
into the lessons/exercises column. Make the ResourceListRow.Group a
container query and stack each row's grid into a single column below
48rem, passing the column template via a CSS variable so the responsive
override applies. Also cap the row at 3 tags with a +N pill.
The moving-icons family sizes its SVG via a numeric `size` prop and only
forwards `class` to the wrapper div, so the cap's `size-3.5` class never
reached the SVG and icons rendered at the default 24px, overflowing the
cap. Pass size={14} to the cap icon.
A student opening /org/[slug] on self-hosted stayed on the admin
dashboard. The existing routeUserToNextPage redirect is gated by
shouldRedirectOnAuth (login/signup/onboarding only) and runs once at app
init, so it never caught this route. Add a reactive guard in the admin
org layout that sends students to /lms, gated on
appInitApi.isInitializedAndReady so the role is actually known before
deciding (the student flag defaults to false while loading).
The auth button derived from stores that hydrate in stages, so it
flashed through Login -> Dashboard -> Go to LMS as isLoggedIn and the
role/basePath settled at different times. Gate the label on
appInitApi.isInitializedAndReady so it only renders once the account
(and thus role) has loaded, and show a spinner in the button while a
logged-in user's experience resolves. Add separate loading and disabled
flags to authAction and wire them through all theme navs.
The hovered tab used text-primary-foreground for inactive tabs, which is
near-white — the same token as the hover pill background — so the label
disappeared. Drop the override and keep the hover label on text-primary.
Loosen the custom-domain regex to accept apex domains (1+ labels) in
addition to subdomains; the TLD already allowed any letters. Suppress
the CNAME record for apex hostnames since a CNAME is invalid at the zone
apex, and surface only the A record there. Drop the frontend apex
rejection and the misleading ".com" input suffix, and render the
domain-input prefix/suffix addons only when set.
Wrangler resolves @cio/utils/constants/* from dist/. Run utils build in
predeploy, predev, and pretypecheck so Cloudflare deploy does not fail with
a missing embeds module.
Serve widgets from short public URLs via tenant-router with R2-backed
assets and runtime API_URL injection, so copy-paste snippets hide infra
paths. Update widget payloads, CSP, upload purge URLs, and dev-only API
inject; document format/stage workflow in AGENTS.md and fix ui-prefix-check
YAML syntax.
The /org/[slug] routes loaded 250+ JS chunks and, after a deploy, every
chunk missed Cloudflare's edge cache and stampeded the single origin (up
to 48s to serve one static file) — the "keeps loading" lockout.
- tenant-router: opt /_app/immutable/* into the edge cache via
cf.cacheEverything; setting Cache-Control alone never populated it
- course/api: mark the 14 API singletons /* @__PURE__ */ so the barrel
tree-shakes instead of dragging every API into each importer
- courses page: import directly instead of via the feature barrels
Cuts the courses route from 257 to 225 static chunks and keeps static
assets off the origin after the first edge fill.
Sync modal editor state from the updated feed on open, clear editFeed
on reset, and remount TextEditor so a second edit shows the latest HTML
instead of stale content.
Gate student content by completion order, track per-video watch progress
with seek enforcement, and auto-complete lessons at the configured
threshold. Show ring progress in the lesson header, keep watch progress
on rewatch, and sync progression locks client-side so the next item
unlocks after completion.
- Add paginated recommended courses to LMS dashboard (top 3, list
view)
and explore page (12 per page); exclude courses blocking new
students
- Polish course preview modal: remove padding, render requirements as
HTML, round banner image, secondary close button
- Redirect to course start page after successful enrollment instead of
/lms;
keep spinner active until navigation completes
- Auto-enable "allow new students" when a course is published
- Move Explore after My Learning in LMS sidebar nav
- Move Certificates after Content in course sidebar
- Add hiddenColumns prop to CourseListRow; show "Learn more" button
instead of actions menu in LMS explore context