* responsiveness styling
* Changed plain text to use the translation
* AI comment fixes
* Made changes to the Theme
* debug
---------
Co-authored-by: Rotimi Best <[email protected]>
* fix(course): use shield-check icon for compliance nav
Certificates and Compliance both used CertificateIcon in the course
sidebar; point Compliance at Lucide's shield-check instead.
Co-authored-by: Rotimi Best <[email protected]>
* fix(ui): add ShieldCheckIcon moving icon for compliance nav
Replace the Lucide shield-check import with a moving-icons component
matching sibling hover-animation patterns, and use it for Compliance.
Co-authored-by: Rotimi Best <[email protected]>
* fix(course): use moving BotIcon for AI tutor nav
Switch the course sidebar AI Assistant item from Lucide bot to the
shared moving-icons BotIcon so hover animation matches other nav items.
Co-authored-by: Rotimi Best <[email protected]>
---------
Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
* feat(ui): restyle unsaved-changes bar as a compact inverted card
Show Page.SettingsActions only when the form is dirty, as a centered
dark card with a secondary Discard button and a primary Save button.
Co-authored-by: best <[email protected]>
* fix(ui): announce settings actions from a persistent live region
Keep role=status mounted outside the dirty-state card so screen readers
still hear the status when the bar appears, without live-region wrapping
the Discard/Save buttons.
Co-authored-by: best <[email protected]>
---------
Co-authored-by: Cursor Agent <[email protected]>
* fix: update z-index utility class for Select component across tag category and tutor selection views
* refactor: standardize z-index management using ui:z-menu-elevated, ui:z-app-bar, ui:z-app-bar-elevated and ui:z-modal classes across UI components
---------
Co-authored-by: richardkenneth <[email protected]>
* preserve section content when toggled off
* Update types.ts
* Remove comment on LandingPageLinks enabled property
Removed comment about the default visibility of the section.
---------
Co-authored-by: Rotimi Best <[email protected]>
* fix(ui): restore theme pill radius on course landing CTAs
LandingButton hardcoded rounded-md, so hero Enroll and pricing CTAs
lost the per-theme pill radius after CoursePrimaryAction switched to
LandingButton. Use --landing-radius-pill and cn() so theme overrides
still win.
Co-authored-by: Rotimi Best <[email protected]>
* fix(ui): preserve landing theme button radii
Co-authored-by: Rotimi Best <[email protected]>
* fix(ui): restore hero CTA Button sizing with disabled parity
Revert CoursePrimaryAction to the shared Button so hero enroll matches
View curriculum sizing (h-10 text-sm). When enrollment is closed, apply
landing-button CSS vars with forced opacity so theme color overrides do
not keep the hero CTA looking active while pricing stays muted.
Co-authored-by: Rotimi Best <[email protected]>
* fix(ui): align LandingButton sizing and use for hero CTA
Match LandingButton sm/md/lg heights to the shared Button scale (h-8/h-9/h-10,
text-sm) so hero enroll can use landing-theme tokens without oversized CTAs.
CoursePrimaryAction returns to LandingButton for enabled color parity with
pricing; disabled overrides are preserved. Terminal hero drops hardcoded hex
colors in favor of --landing-button-* tokens.
Co-authored-by: Rotimi Best <[email protected]>
---------
The bar was nested in an extra shrink-0 div together with the dock
sentinel. A sticky element is constrained to its containing block, and
that wrapper is only as tall as the bar plus a 1px sentinel, so the bar
had roughly 1px of travel. It could never float over the page content and
only ever appeared where it naturally sits, at the end of the page.
Render the bar and the sentinel as siblings so the containing block is
Page.Root and the bar has the full page to travel. Measured in headless
Chrome at scrollY=600 in a 713px viewport: wrapped bar bottom was 2484
(off screen), sibling bar bottom is 713, pinned to the viewport.
Also raise the bar from z-10 to z-50. The editor root is relative z-50,
so it painted over the bar on any settings page with an editor, such as
the course welcome email. Matching 50 rather than exceeding it wins the
tie on DOM order while keeping body-portaled dialogs and sheets above the
bar.
ref is now bound to the sticky element rather than the removed wrapper.
No dashboard caller binds ref today.
* fix(newsfeed): tighten card spacing and reposition pinned icon
- Move pinned icon to top-left corner with a tilted rotation
- Remove the extra pin row that added excess top padding
- Reduce spacing between post content, reactions, and comments
- Remove card box shadow
Co-authored-by: Rotimi Best <[email protected]>
* fix(newsfeed): place pinned icon on card corner instead of avatar
Co-authored-by: Rotimi Best <[email protected]>
* fix(newsfeed): stop dropdown menus locking body scroll
bits-ui defaults DropdownMenu.Content's preventScroll to true, so every
open ran a body scroll lock: a forced full-document style recalc and
reflow, a horizontal shift by the scrollbar width, and pointer-events:
none on the body. On the newsfeed page — a long list of cards each with
a scrollable comment thread — that made the comment ellipsis menu feel
sluggish to open. Menus don't need scroll locking; only modal layers do.
Also tighten card spacing, use the primary color for the pinned icon,
give the card menu trigger a pointer cursor, and switch the comment
save and submit buttons to the loading and secondary variants.
* refactor(newsfeed): store reply targets as data, not markup
Replies used to prepend a presentational <blockquote> — Tailwind classes,
hardcoded English and a copied snippet of the parent — onto the comment
body, and that whole string was persisted to course_newsfeed_comment.content.
Editing a reply therefore showed raw markup in the textarea, the quoted
snippet froze at write time, the label could never be localized, and two of
its ui: utilities never compiled because they appeared only in dashboard code.
Add a replyToCommentId column, distinct from parentId (the thread root), so
a reply can name the comment it answers. The service verifies the target
belongs to the same feed and the same thread; the paginated query joins it
to return replyToAuthorFullname, so the label is rendered from live data by
the new CommentTree.ReplyingTo component using the existing replying_to key.
Rows written the old way still display their stored quote, and
stripLegacyReplyQuote keeps that markup out of the edit textarea.
* refactor(newsfeed): drop redundant comments on reply target plumbing
* fix(newsfeed): address review feedback on pinned icon and quote stripping
- The pin icon carried an unprefixed `text-muted-foreground` plus
`ui:stroke-primary`, which is emitted nowhere because it appears only in
dashboard code, so the outline fell back to the muted colour. Use
`ui:text-primary` and let the stroke inherit currentColor.
- Give the pinned state an accessible name: the icon is `aria-hidden`, and
header.svelte no longer carries an indicator, so screen readers had nothing.
- Filter feeds on `isPinned` alone. `is_pinned` is only the database column
name; drizzle maps it to `isPinned` and the API never returns the snake_case
form, so the `as any` fallback was dead and let the page and card disagree.
- `ui:transition-color` is not a Tailwind utility and emitted nothing; the
plural `ui:transition-colors` is what applies the transition.
- Strip a legacy reply quote only when the blockquote carries the `reply-quote`
class the old code always wrote, so a blockquote the author typed is kept.
* docs(agents): forbid presentational markup in persisted content columns
---------
* feat(ui): add AttachmentList component for lesson file attachments
Introduce a reusable AttachmentList molecule with view and edit modes.
View mode shows a header with file count and view/download actions; edit
mode supports drag-to-reorder, view, and delete. Includes Storybook
stories and package exports.
Co-authored-by: Rotimi Best <[email protected]>
* fix(ui): address AttachmentList PR review feedback
- Sync edit-mode file state using full metadata signature, not IDs only
- Clamp formatFileSize unit index and guard non-finite inputs
- Recognize common MIME types for file-type icon styling
- Scope role=list to row container; use dragHandleZone for reordering
- Add reorder label for drag handle accessibility
Co-authored-by: Rotimi Best <[email protected]>
* fix(ui): guard AttachmentList against undefined files prop
Co-authored-by: Rotimi Best <[email protected]>
* fix(ui): render AttachmentList stories and guard missing props
Use Storybook template snippets and default args so stories mount
correctly. Default mode and labels in the component for autodocs, and
align dragHandleZone items with rendered rows.
Co-authored-by: Rotimi Best <[email protected]>
* feat(dashboard): use AttachmentList on lesson documents tab
Replace the horizontal DocumentCard grid with AttachmentList in view and
edit modes, including drag-to-reorder in edit. Tighten the list header to
p-2 with xs text, add document mapping helpers, and translate new labels.
Co-authored-by: Rotimi Best <[email protected]>
* fix(dashboard): reset document upload modal state on close
Clear selected file, success message, and upload progress when the add
document modal closes so reopening starts fresh.
Co-authored-by: Rotimi Best <[email protected]>
* fix(dashboard): raise PDF viewer above sidebar and AI chat
Use z-modal (200) instead of z-100 so the full-screen PDF overlay stacks
above z-app-bar-elevated (150) course sidebar and z-app-bar (100) AI panel.
Add the missing z-modal utility to dashboard app.css to mirror the UI scale.
Co-authored-by: Rotimi Best <[email protected]>
* chore: minor changes
* feat(dashboard): show spinner and snackbar on document download
AttachmentList download button now shows the Button loading spinner
immediately while the async download runs. On success, a snackbar confirms
the download; on failure, an error snackbar is shown instead of opening
a new tab.
Co-authored-by: Rotimi Best <[email protected]>
* fix(ui): address PR review feedback on AttachmentList
- Reject negative byte counts in formatFileSize instead of using Math.abs
- Make AttachmentList mode/files/labels props optional; disable cross-list drops
- Include file name in drag-handle aria-label for screen readers
- Prevent upload modal from closing while an upload is in progress
- Use ICU plural rules for attachment file_count across all locales
Co-authored-by: Rotimi Best <[email protected]>
---------
Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
* feat(ui): add Page.SettingsActions for settings save bar
Introduce a sticky bottom save/discard bar on Page and migrate settings
pages to use it instead of header or inline save buttons. Includes
profile, org, customize-lms, notifications, auth, AI tutor, course
settings, certificates, and cohort settings.
Co-authored-by: Rotimi Best <[email protected]>
* fix(ui): always show Page.SettingsActions, disable when clean
Keep the settings save bar visible at all times and disable Save and
Discard when hasChanges is false instead of hiding the bar entirely.
Co-authored-by: Rotimi Best <[email protected]>
* fix(ui): add bottom spacing and shadow to Page.SettingsActions
Wrap the actions card in a sticky container with bottom padding so it
does not sit flush against the viewport edge, and use shadow-md on the
card for clearer elevation.
Co-authored-by: Rotimi Best <[email protected]>
* fix(ui): stop viewport-sticking Page.SettingsActions on scroll
Remove sticky bottom positioning so the save bar stays at the end of the
page content and scrolls away when viewing upper sections.
Co-authored-by: Rotimi Best <[email protected]>
* fix(ui): use sticky bottom for Page.SettingsActions dock behavior
Replace the IntersectionObserver fixed/dock toggle that caused layout
thrashing and flipping with native position: sticky; bottom: 0. Move
overflow-x-hidden from Page.Root to Page.Body so sticky is not broken.
Co-authored-by: Rotimi Best <[email protected]>
* fix(ui): keep Page.SettingsActions sticky and above editor content
Page.Root carried md:overflow-x-auto, which makes overflow-y compute to
auto, turning the root into the sticky bar's nearest scrolling ancestor.
Since the root sizes to its content it never scrolls internally, so the
bar rendered at its natural flow position instead of pinning to the
viewport. Drop the overflow so sticky resolves against the viewport;
horizontal containment stays on Page.Body.
Raise the bar to z-50 so it is not painted over by the editor root,
which is relative z-50. Matching rather than exceeding 50 keeps
body-portaled dialogs and sheets above the bar.
Show the shadow only while the bar is pinned away from its flow
position, tracked with an IntersectionObserver on a 1px sentinel
sibling. Unlike the previous observer this toggles a box-shadow only, so
it cannot feed back into layout.
* fix(ui): tighten settings actions spacing and tab visibility
- Remove mt-6 and lower z-index so popovers sit above the bar
- Wrap sentinel in a single root to avoid extra Page.Root flex gap
- Remove Page.Body mb-4 redundant with settings actions spacing
- Show certificate save bar only on the settings tab
- Raise theme color picker z-index via --picker-z-index
Co-authored-by: Rotimi Best <[email protected]>
* fix(dashboard): address PR review feedback for settings actions
- Capture profile/org saved baselines only on initial load, not on edits
- Reset course settings errors with explicit field shape in handleDiscard
- Restore VisitOrgSiteButton on org profile settings header
- Derive AI tutor dirty state from stores directly instead of mirrored state
- Use discard-specific French label and document SettingsActions props
Co-authored-by: Rotimi Best <[email protected]>
* docs(ui): clarify Page.Root ownership for Page.SettingsActions
"Last child of Page.Root" is a runtime relationship. Several settings
pages get their Page.Root from a +layout.svelte, so the page file holds
only Page.Header, Page.Body, and Page.SettingsActions. Document that this
is correct and list the pages, since adding a nested Page.Root stacks two
min-height containers and shortens the sticky bar's containing block.
Also document that Page.SettingsActions must never be rendered from
inside Page.Body: overflow-x-hidden makes Page.Body a scroll container
that never scrolls, so a sticky bar in it has no travel.
---------
CoursePrimaryAction used the base Button with per-theme color classes
that overrode disabled styling. When enrollment was closed, the pricing
CTA appeared correctly muted while the hero Buy Now button still looked
active.
Switch the hero CTA to LandingButton (same primitive as pricing) and
reset theme color overrides when disabled so both CTAs look and behave
consistently.
* feat(ui): add THUMBS question type renderer
- Add seven new renderer files under renderers/thumbs/:
thumbs-correct.ts, take.svelte, edit.svelte, preview.svelte,
thumbs-review-toggle.svelte, review.svelte, submission.svelte
- Register THUMBS in renderer-contract.ts with edit/take/preview/submission/review
- Export ThumbsEdit, ThumbsTake, ThumbsPreview, ThumbsSubmission,
ThumbsReview from exercise-question/index.ts
- Add THUMBS_FIXTURE, four thumbs label keys, and Thumbs story
in storybook exercise-question files
- Add THUMBS case to question-type-modes.svelte submission builder
Co-authored-by: Rotimi Best <[email protected]>
* feat(question-types): add THUMBS contracts, scoring, and seed
Introduce the THUMBS question type (id 14) with boolean answer data,
codec, auto-grading, Zod validation, DB seed/schema docs, and binary
normalize support shared with TRUE_FALSE. Dashboard wiring is deferred.
Co-authored-by: Rotimi Best <[email protected]>
* feat(dashboard): wire THUMBS question type into exercise UI
Connect THUMBS option sync in edit/view modes, labels, picker icon,
and locale strings so the existing contracts surface in the dashboard.
Co-authored-by: Rotimi Best <[email protected]>
* fix(ui): polish thumbs question UI and move premium upgrade icon
Widen thumbs Yes/No toggles, inherit selected icon color via custom class,
replace badge-based review correctness with solid fill states and bare
Check/X icons, add a success pill in preview, and place the premium
upgrade icon at the end of the question type select row.
Co-authored-by: Rotimi Best <[email protected]>
* fix(ui): align thumbs review colors with prototypes
Tune correct/incorrect selected states so review buttons use dark
emerald/red content colors on solid fills, matching the design comps.
Co-authored-by: Rotimi Best <[email protected]>
* fix(ui): address remaining PR review comments
Use a shared timestamp local in thumbs edit fallback options.
Move border-beam animation-delay after the animation shorthand.
Alias --spacing to --ui-spacing for tw-animate compatibility.
Co-authored-by: Rotimi Best <[email protected]>
* fix(db): sync question_type sequence before seeding new types
Avoid duplicate key errors when question_type_id_seq lags behind rows
inserted via restore or explicit ids (e.g. THUMBS on restored databases).
Co-authored-by: Rotimi Best <[email protected]>
* fix(exercise): resolve thumbs review label ICU crash
Remove {value} ICU placeholder from thumbs.review.correct_answer_label
since labels are resolved at map-build time without context. Append the
dynamic yes/no label in thumbs-review-toggle instead.
Also bind question points through the questionnaire store index to avoid
the Svelte non-reactive property binding warning in edit-mode.
Co-authored-by: Rotimi Best <[email protected]>
* docs(agent): add THUMBS guidance and sync question type docs
Add shared agent question-type guide helpers in @cio/question-types and
use them across the teacher prompt, add/update_questions tool schemas,
legacy ai-assistant schemas, and MCP exercise tools.
- Document when to use THUMBS (Yes/No sentiment) vs TRUE_FALSE (factual)
- Fix stale MCP question type ids and add THUMBS=14
- Exclude disabled MATCHING/HOTSPOT from enabled type lists
Co-authored-by: Rotimi Best <[email protected]>
* fix(thumbs): clarify auto-grading and add editable option labels
THUMBS is auto-graded like TRUE_FALSE; the difference is thumbs UI with
customizable Yes/No-style labels and the teacher selecting the correct
option. Render take/preview/review from question.options labels and add
label inputs in the thumbs edit renderer. Update agent and MCP docs to
stop implying THUMBS is manual or non-gradable.
Co-authored-by: Rotimi Best <[email protected]>
* chore(mcp): bump version to 0.0.10
Co-authored-by: Rotimi Best <[email protected]>
* fix(question-types): make THUMBS open-ended with no correct answer
THUMBS is a sentiment/poll question type, not an auto-graded binary like
TRUE_FALSE. Remove correct-answer UI, scoring, and validation; update agent
and MCP docs accordingly.
Co-authored-by: Rotimi Best <[email protected]>
* fix(dashboard): stop treating THUMBS as auto-graded in editor validation
Normalize thumbs questions on load/save, show an Open-ended badge instead
of Auto, and only enforce points/correct-answer rules on auto-gradable types.
Co-authored-by: Rotimi Best <[email protected]>
* revert(dashboard): remove unsolicited open-ended grading badge
THUMBS uses the existing Manual badge like other non-auto-gradable types.
Co-authored-by: Rotimi Best <[email protected]>
* fix(exercise): show question title validation errors in editor
Display mapped title errors beneath the question title input instead of
only highlighting the question card border. Pass the exercise entity to
mapZodErrorsToTranslations so messages resolve to validations.exercise.*
keys, and use a translated snackbar key for save validation failures.
Co-authored-by: Rotimi Best <[email protected]>
* fix(mcp): build workspace deps before MCP publish bundle
The MCP esbuild step imports @cio/question-types and @cio/utils dist
outputs. CI failed on a clean checkout because mcp:build only built the
MCP package itself. Build those dependencies first so 0.0.10 can publish.
Co-authored-by: Rotimi Best <[email protected]>
---------
Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
The hero Enroll button was disabled unless metadata.allowNewStudent was
explicitly true, while the pricing CTA treated unset values as open
(matching the enroll page and API). This left the hero button inert on
courses where enrollment was allowed but the flag was not set.
Also sync pricing CTA disabled state, use activeOrg for paid enroll on
public org-site pages, and keep hero/pricing disabled flags aligned via
alignHeroCtaWithPricing.
Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
* feat(newsfeed): improved the newsfeed ui
* feat(newsfeed): updated the ui
* feat(newsfeed): some improvements
* feat(newsfeed): cleanup
* chore(newsfeed): added translations
* fix(newsfeed): fixing comments and reviews from greptile and coderabbit
* fix(newsfeed): fixing comments and reviews from greptile and coderabbit, improved replies to a reply
* fix(newsfeed): fixing reviews
* fix(ui): expose cancelLabel prop in comment-tree-input and pass localized text
---------
Co-authored-by: Rotimi Best <[email protected]>
* fix(dashboard): course landing page editor section and currency bugs
- Raise currency select z-index so the dropdown is clickable in the
full-screen editor shell
- Show dashed top/bottom borders and section title on hover and when
active in EditableLandingSection
- Map hero/header section keys in the course editor preview context
Co-authored-by: Rotimi Best <[email protected]>
* fix(ui): format course landing page pricing with Intl currency
Use getCourseCurrencyFormatter (matching dashboard getCurrencyFormatter)
so NGN and USD prices render as proper locale currency strings instead of
raw numbers with a currency code suffix.
Co-authored-by: Rotimi Best <[email protected]>
* fix(ui): prefix group class for landing section hover badge
Co-authored-by: Rotimi Best <[email protected]>
* fix(ui): remove unused formatCourseCurrency helper
Co-authored-by: Rotimi Best <[email protected]>
* refactor: move currency formatter to @cio/utils
Shared course price formatting belongs in @cio/utils, not the UI
package. Dashboard re-exports the helper to keep existing imports.
Co-authored-by: Rotimi Best <[email protected]>
---------
* feat(dashboard): restyle lesson transcript and summarize actions
Add separator lines around lesson material actions below the video,
switch transcript and summarize controls to plain ghost buttons with
hover-animated icons, and separate the two actions with a pipe when both
are visible. Display rules are unchanged.
Co-authored-by: Rotimi Best <[email protected]>
* refactor(dashboard): inline summarize action into material actions
Fold lesson-summarize-button into lesson-material-actions and remove the
standalone component. Lessons without videos use the same actions bar with
separators disabled.
Co-authored-by: Rotimi Best <[email protected]>
* fix(dashboard): align lesson actions with note width on mobile
Constrain material actions to max-w-2xl when there is no video so they
match the note column. Improve small-screen layout with tighter padding,
flex-wrap, and hide the pipe separator below the sm breakpoint.
Co-authored-by: Rotimi Best <[email protected]>
* add script update
* fix(dashboard): tighten spacing between lesson video and note
Remove gap-2 from the lesson content stack and drop bottom margin on the
last video wrapper so material actions separators control the gap before
the note.
Co-authored-by: Rotimi Best <[email protected]>
* fix(dashboard): show bottom separator on note-only lesson actions
Lessons without video were passing showSeparators=false, hiding both
lines. Split into top/bottom controls so summarize still gets a line before
the note without one above the action row.
Co-authored-by: Rotimi Best <[email protected]>
* fix(dashboard): always show top separator on lesson material actions
Co-authored-by: Rotimi Best <[email protected]>
* fix(dashboard): reduce lesson material action separator margin to my-2
Co-authored-by: Rotimi Best <[email protected]>
* feat(dashboard): consolidate multi-video transcript actions and navigation
Register transcript sources per upload video, show a single material
actions row after all videos, and add prev/next video navigation in the
transcript side panel when multiple transcripts are available.
Co-authored-by: Rotimi Best <[email protected]>
* fix(dashboard): address greptile review on lesson material actions
Require lessonId on material actions and stop overwriting the transcript
seek wrapper with a stale localSeekFn snapshot in the sync effect.
Co-authored-by: Rotimi Best <[email protected]>
* fix(dashboard): stop transcript registry effect infinite loop
Replace the reactive sync effect with explicit registry updates from
loadTranscript and skip no-op transcript source writes to avoid reading
and writing transcriptSources in the same effect pass.
Co-authored-by: Rotimi Best <[email protected]>
* fix(ui): raise tooltip z-index above app bar side panel
Use z-menu-elevated for tooltip content so header icon tooltips are not
hidden behind the fixed transcript side panel (z-app-bar).
Co-authored-by: Rotimi Best <[email protected]>
---------
Co-authored-by: Rotimi Best <[email protected]>
* fix(exercise): persist completion policy and pass threshold on save
buildExerciseUpdateFields accepted completionPolicy and passThreshold in
the validation schema but never mapped them to the database update, so
exercise settings appeared to save while reverting on refresh.
Co-authored-by: Rotimi Best <[email protected]>
* fix(db): build workspace deps before compiling
@cio/db imports @cio/utils/notifications, which only exists after
@cio/utils is built. Building db in isolation failed when utils dist
was missing or stale.
- Add a db prebuild step for question-types, utils, and email
- Clean dist and tsbuildinfo before utils and email builds so
incremental tsc cannot skip emission after dist was removed
Co-authored-by: Rotimi Best <[email protected]>
* chore(dev): build api workspace deps before api:dev
Run turbo build for @cio/api and @cio/jobs-worker dependencies before
starting the dev servers, so missing dist/ folders do not break startup.
Co-authored-by: Rotimi Best <[email protected]>
* feat(course): improve exercise progression grading UX
---------
Co-authored-by: Rotimi Best <[email protected]>
* fix(dashboard): make upgrade plan modal mobile responsive
- Constrain modal height and enable scrolling on small screens
- Anchor modal near top on mobile instead of vertical centering
- Stack success-state buttons and use full-width cards in the grid
- Reduce pricing card padding and adjust toggle badge on narrow viewports
Co-authored-by: Rotimi Best <[email protected]>
* chore(ui): regenerate output.css for pricing responsive classes
Co-authored-by: Rotimi Best <[email protected]>
---------
Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
* fix(dashboard): restore paid course payment modal on landing page
Wire the existing PaymentModal back into the theme-based course landing
page. Paid course CTAs now open the name/email modal and redirect to the
configured payment link instead of navigating to the blocked enroll route.
Co-authored-by: Rotimi Best <[email protected]>
* fix(dashboard): reset payment modal on close without effect loop
Co-authored-by: Rotimi Best <[email protected]>
* docs: add Svelte 5 rerender and effect loop guidance to AGENTS.md
Co-authored-by: Rotimi Best <[email protected]>
* fix(dashboard): avoid double-applying discount on course landing pricing
Co-authored-by: Rotimi Best <[email protected]>
---------
Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
* feat(billing): enforce free-plan student cap across the app
Free (BASIC) plan orgs were able to exceed the advertised 20-student
limit because the cap existed only as dashboard cosmetic copy with no
server-side enforcement.
Add a single source of truth (getStudentLimit / STUDENT_LIMITS) and a
central guard (assertStudentCapacityOrThrow) wired into every path that
creates a new student-role org member: self-enrolment, course-invite
acceptance, bulk audience import, and org-invite acceptance. The guard
throws UPGRADE_REQUIRED (403) and notifies org admins by email (throttled
to once per org per day) when the limit is hit.
Surface the limit in the UI: disable the audience import button and the
course new-student invite section with upgrade banners for admins, and
show a "contact your administrator" message on the public enrol page for
students (who cannot upgrade). Expose a studentLimitReached flag from the
public course endpoint so the enrol button can be disabled proactively.
Add a one-time (manual, non-cron) db script,
db:notify-students-over-limit, to email admins of orgs already over the
limit at rollout.
* feat(billing): guard remaining student-creating paths against the cap
The initial cap enforcement missed several service entry points that also
create a new student-role org member. Add the capacity guard to:
- autoEnrollStudent (public org self-join on a tenant site)
- cohort enrollment (bulk group enrollment and single member accept)
- ensureProgramCourseAccess (program/cohort-granted course access)
For the cohort and program paths, only genuinely new members are counted
(existing org members are skipped) so re-enrolling current students is
never falsely blocked. The core path enforces via the shared limit check;
it throws UPGRADE_REQUIRED without the admin email since the queue lives
in the api layer.
* fix(billing): don't show cap block to existing org members on enroll page
The public course-by-slug endpoint is unauthenticated, so the enroll
page's studentLimitReached flag is org-wide and can't tell whether the
current viewer is already an org member. An existing student of a full
BASIC org would see Join disabled even though the backend correctly lets
existing members enroll without consuming a new seat.
Only pre-block anonymous visitors (would-be new joiners). Logged-in users
are governed by the membership-aware backend, which allows existing
members and returns UPGRADE_REQUIRED (surfaced via the enroll onError
mapping) for genuinely new students.
* feat(billing): deliver org plan-limits via /account with an extensible registry
Replace the ad-hoc, per-surface audience fetches (each calling getOrgAudience
just to read a count) with a single, future-proof delivery of org usage +
limits on the /account payload, gated to admin/tutor members (students never
receive limit data; a plain COUNT, no student rows).
Model it generically so future org limits (courses, etc.) drop in without
reshaping the payload or re-plumbing the frontend:
- New packages/utils plan-limits registry: PLAN_LIMIT_RESOURCES, PLAN_LIMITS
(reuses STUDENT_LIMITS), getPlanLimit, and a JSON-safe ResourceUsage /
OrgUsageLimits shape with isResourceLimitReached / resourceLimitRemaining.
- getAccountData attaches a per-org `limits` map for admin/tutor orgs.
- Store selectors currentOrgLimits / studentUsage / isStudentLimitReached;
sidebars, upgrade card, People pages, and invite modals read these instead
of fetching audience rows.
Bring cohort surfaces to parity with course: cohort People page banner and
cohort sidebar People upgrade icon, matching the course equivalents.
Also: close the upgrade modal that opened over the invite modal (drop the
`add` param via openUpgradeModalOver), redesign the sidebar upgrade card
(progress toward the student limit), add a Progress `variant`, and document
the no-complex-expressions-in-object-literals rule in AGENTS.md.
* refactor(billing): address PR review on the limits work
- Dedupe new student profile IDs before the cohort seat check so duplicate
batch entries can't overcount against the cap (Greptile P1).
- Drive the org-sidebar upgrade indicator from a dynamic nav-item `upgrade`
flag (audience config declares `upgradeResource: 'students'`; groups set it
from a `limitsReached` map) instead of a hardcoded path check.
- Collapse the audience Import button into a single button with ternary
`disabled`/`href`.
- Simplify the notify-students-over-limit upgrade link to app.classroomio.com.
- Drop the comment on the org `limits` field.
- Add .greptile/rules.md documenting the manual one-time db/src/scripts
convention (operator-controlled reruns) so idempotency isn't reflagged.
* fix(billing): name the org in the enroll capacity message
Change the enroll-page student-limit message from "This course has reached
its student capacity" to "{orgName} has reached its student capacity", since
the cap is org-wide, not course-specific. Pass orgName from the loaded org on
the page and from the currentOrg store in the enroll onError snackbar.
* style(billing): use PremiumIcon for the Authentication upgrade indicator
Replace the RocketIcon on paid settings sub-items (e.g. Authentication) with
the PremiumIcon used on the Audience item, right-aligned (ml-auto) to match.
* refactor(z-index): introduce a shared named z-index scale
Replace scattered magic z-index numbers (100, 150, 200, 201, 250) with
named layers (z-app-bar, z-app-bar-elevated, z-modal, z-menu-elevated)
so intent is documented at each call site and future additions insert
predictably instead of colliding.
Defined via Tailwind's @utility directive in two places that mirror
each other: packages/ui/src/index.css (ui:-prefixed, for components
that live in the @cio/ui package) and apps/dashboard/src/app.css
(unprefixed, for dashboard-only files) — a ui:-prefixed custom utility
only compiles if it is also used somewhere under packages/ui/src, so
dashboard-only usages need the unprefixed twin.
Also fixes two pre-existing dropdown/select menus (tags-admin.svelte,
mark-exercise-modal.svelte) that used ui:z-[250], which never actually
compiled to any CSS rule since nothing under packages/ui/src used that
exact class.
Co-authored-by: Rotimi Best <[email protected]>
* fix(ui): safelist the packaged z-index scale so it always compiles
@utility only emits a class when Tailwind's scanner sees that exact
candidate under packages/ui/src, so ui:z-app-bar-elevated and
ui:z-menu-elevated were documented but silently missing from the
compiled @cio/ui CSS since nothing inside the package itself used
them. Safelist all four layers via @source inline() so the shipped
scale always matches what's documented, regardless of which layers
this package happens to reference internally.
Co-authored-by: Rotimi Best <[email protected]>
---------
Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
Remove the global z-150 mobile sheet stacking from Sidebar.Root so LMS
and org sidebars keep the default sheet layer. Course sidebar opts in via
mobileOverlayClass and its existing z-150 class.
Co-authored-by: Rotimi Best <[email protected]>
Restore lesson/exercise page header z-100 stacking and fix the overlap
by lifting the mobile sidebar sheet and overlay to z-150, matching the
desktop course sidebar.
Co-authored-by: Rotimi Best <[email protected]>
* 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]>
---------
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]>
- 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.
* 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
---------
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.
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.
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.
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.
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.
- 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