release: develop → main (v0.8.0 — admin broadcasts + launch-celebration popup + drawer polish)#557
Merged
Conversation
chore: sync main → develop after v0.7.2
* feat(web): broadcastsApi client + useBroadcasts hooks (#500) Add the client + TanStack Query hooks that the upcoming admin broadcast surface will sit on top of. Broadcasts are admin-authored bilingual messages that fan out to every user's notification inbox; this commit lays the typed read/write path against /api/v1/admin/broadcasts and the cache-invalidation glue that keeps the user-facing notification bell in sync when a broadcast is created, edited, or deleted. Mirrors the announcements client/hook shape (apiGet/Post/Patch/Delete wrappers, useMutation -> invalidate query keys on success) so the admin page in the next commit can reuse the same mental model. Because broadcasts are merged into the existing /notifications feed server-side, mutations here invalidate the notifications + unread count keys too, so a fresh broadcast appears in every bell on the next refetch cycle without a manual reload. Both EN and ZH titles + bodies are required at create time (no auto-fallback) — modeled as a `BilingualText` pair so the drawer component can validate against the same shape. * feat(api): broadcasts domain — types + Zod schemas (#500) New `domains/broadcasts/` module. Defines the persistence shapes (`BroadcastDocument`, `BroadcastReadReceiptDocument`) and the wire shapes the admin surface returns (`AdminBroadcastResponse`, `UserBroadcastFeedItem`). Bilingual fields use **nested objects** (`titleI18n: { en, zh }`, `bodyMarkdownI18n: { en, zh }`) instead of the flattened columns announcements (#493) use. Two reasons: 1. Both locales are required at create time — no fallback semantics to encode in flat field names like "EN canonical, ZH optional". 2. Each broadcast carries exactly one title pair + one body pair, so the nested shape keeps the field count low and lets a PATCH unambiguously target a single locale. Zod schemas in a separate `schemas.ts` file (kept off `types.ts` so the OpenAPI builder can import schemas without dragging the whole domain). Create requires both locales non-empty. Patch allows each locale to be omitted, but a provided value must be non-empty — admins cannot blank a locale via the wire, only by deleting the broadcast. An empty i18n object (`titleI18n: {}`) is rejected so the route never persists a no-op locale patch. No bootstrap wiring yet — commits 2-5 build out the repo, service, routes, and notifications merge on top. * feat(web): BroadcastEditDrawer with bilingual markdown editor (#500) Right-edge slide-in drawer that creates or edits a single broadcast, mirroring AnnouncementEditDrawer's pattern (portal, Framer Motion spring entry, ESC + backdrop dismiss, card-impression surface). The shape is intentionally simpler than announcements: broadcasts have no schedule window, no CTA, no enabled toggle — just a bilingual title + bilingual markdown body. Both EN and ZH are REQUIRED on every save. Broadcasts fan out to every authenticated user's inbox, and unlike announcements they don't auto-fall back across locales — the admin must explicitly write what each audience reads. The validator enforces non-empty trimmed strings on all four fields before the mutation fires; field errors render under each input. Reuses the shared MarkdownEditor (toolbar + sanitized live preview) one instance per locale rather than building bespoke widgets, so the authoring experience matches the existing announcement and skill README editors. Includes a Vitest happy-path test: fills all four fields, submits, asserts the mutation was called with the trimmed bilingual payload and the success toast fired. A second case proves the validator blocks submission when any of the four required fields is empty. * feat(api): broadcasts repository — CRUD + read receipts (#500) Persistence layer for the broadcasts domain. Two Mongo collections: - `broadcasts` — the broadcast docs themselves. - `broadcast_read_receipts` — `(userId, broadcastId)` rows tracking which user has read which broadcast. Receipts live in a separate collection rather than embedded on the broadcast doc so write rate scales with (users × broadcasts) instead of mutating a single hot doc on every read. A `(userId, broadcastId)` unique index makes `markRead` idempotent — repeat writes use upsert with `$setOnInsert` so the original `readAt` wins (first-read time is the meaningful value, not last-read). Repo surface: - listAll / getById / create / update / delete - markRead (idempotent) - markManyRead (batched, for `notifications.markAllRead`) - deleteAllForBroadcast (cascade hook owned by the service layer) - readCountForBroadcast / readCountsForBroadcasts (admin list) - unreadBroadcastIdsForUser (unread-count roll-up) - hasUserReadBroadcastsMap (per-id readAt lookup for the feed merge) Cascade-delete is deliberately NOT folded into `delete()` — the service layer owns the contract so the call site is explicit and testable. Patches on the bilingual `titleI18n` / `bodyMarkdownI18n` fields are applied via read+merge rather than dot-path `$set`. The volume is tiny (one extra read per admin edit) and it keeps the merge logic readable without juggling 4 different `titleI18n.en` / `titleI18n.zh` paths. Colocated bun test covers the load-bearing behaviours: unique-index enforcement at the DB level, markRead idempotence, cascade delete isolation between broadcasts, and the bulk helpers used by the notifications feed merge. * feat(web): BroadcastsPage with list / edit / delete (#500) Admin surface at /admin/broadcasts (mounted by the next commit). Lists every broadcast newest-first with the columns the operator needs day-to-day: locale-resolved title, an 80-char body teaser (markdown stripped for readability), created / updated timestamps, the per-broadcast read count rolled up from broadcast_read_receipts, and edit / delete actions. The page deliberately mirrors AnnouncementsPage's structure (header, Card-wrapped table, drawer hand-off, ConfirmDialog for destructive actions) so the admin's mental model carries across. Differences are all things broadcasts simply don't have: no enabled toggle, no schedule window column, no status badge — every row is live until deleted. The clarity is the point. Includes a tiny markdown-strip helper for the preview cell. It is not a full parser — just enough to flatten fenced code, inline code, links, images, headings, emphasis, blockquotes, and list markers into a single line. The teaser is read-only and only ever shows ~80 characters, so a perfect parse is not load-bearing. Re-exported through `pages/admin/index.ts` so App.tsx's lazy import barrel keeps working without bespoke route plumbing. * feat(api): broadcasts service — admin business logic (#500) Thin service layer on top of the broadcasts repository. Three jobs: 1. **Admin list with `readCount`.** Every row carries the number of distinct users that have read it — the admin page doubles as broadcast history. Counts come from a single grouped aggregate on `broadcast_read_receipts`, not N count calls. 2. **Lifecycle logging.** Pino `info` on create / update / delete with broadcast id + actor id. `error` if the cascade delete pass after a successful broadcast removal fails — orphan receipts are harmless (no broadcast → never surface) but they pile up, so an operator should know. 3. **Cascade on hard delete.** Order is broadcast-first, receipts-second: a user racing a `markRead` after the cascade pass can't insert a fresh orphan because the broadcast doc is already gone. The receipt cleanup error is swallowed (logged loudly) — the user-visible delete already succeeded, so failing the request would be worse than the orphan. The freshly created broadcast trivially has `readCount = 0`, so the `create()` path skips the count query — one less round-trip on the hot admin write path. Service-level test covers list enrichment, the merge in `update`, 404 surfaces for missing ids, and that `delete` actually calls `deleteAllForBroadcast` on the underlying repo (not just nukes the broadcast doc and leaves receipts dangling). * feat(web): AdminLayout sidebar entry for broadcasts (#500) Adds a /admin/broadcasts entry between Announcements and Settings in the admin sidebar, plus the route registration in App.tsx using the same lazy-import pattern as the other admin pages (single chunk via the pages/admin barrel). The new entry's label resolves through i18next (key `nav.admin.broadcasts`, added in the i18n commit later in this stack); the existing nav items keep their hardcoded labels to keep this commit narrowly scoped to introducing the new surface rather than refactoring the whole sidebar. The NavItem shape now supports an `i18nLabel: true` flag so the next entry that wants i18n can opt in the same way without breaking the existing rows. Icon is the lucide-react `Bell` glyph inlined as a bare <svg> to match the rest of the sidebar's icon style and avoid pulling in a new dependency for a single row. * feat(api): admin /broadcasts routes + bootstrap mount (#500) Wire the broadcasts domain into the HTTP layer. GET /api/v1/admin/broadcasts POST /api/v1/admin/broadcasts PATCH /api/v1/admin/broadcasts/:id DELETE /api/v1/admin/broadcasts/:id Every route is admin-only — `nyxidAuthMiddleware()` followed by `requirePermission("ornn:admin:skill")`, identical to the announcements admin surface (#493). The public user-facing read path is `/api/v1/notifications` after the notifications-feed merge lands in the next commit. Validation is Zod-driven via the schemas in commit 1. Parse failures translate into the project-standard `INVALID_BROADCAST_INPUT` AppError so the global error middleware formats them consistently. 404 surfaces are produced by the service (`BROADCAST_NOT_FOUND`), not the route — keeps the contract uniform between PATCH/DELETE. Bootstrap wiring sits next to announcements: build the repo, fire `ensureIndexes()` fire-and-forget, build the service, mount the routes under `/api/v1`. Same shape, no DI surprises. Repository test fix: cast string `_id`s to `never` when bypassing the repo and writing directly through the raw Mongo `Collection` contract — matches the pattern used in `quota/repository.test.ts`. The repo itself already does this internally on every read/write. * feat(web): NotificationBell renders broadcast items (#500) Backend now merges broadcasts into the existing /notifications feed with a `source: "broadcast"` discriminator plus bilingual `titleI18n` and `bodyMarkdownI18n` fields. This commit teaches the shared `Notification` type and both renderers (NotificationBell + NotificationsPage) about that new row shape so a freshly authored broadcast lights up the bell and the badge for every user. Notification type changes (extend, don't break): - `source: "user" | "broadcast"` discriminator, optional with a "user" default so legacy rows from before the merge still type- check. - `titleI18n` + `bodyMarkdownI18n` carry the bilingual content for broadcast items. - `title`, `userId`, `data` relaxed to optional because broadcasts have no per-user `userId` and resolve their title at render time. Bell renders broadcast rows with: - A small "BROADCAST" mono tag in front of the title so the user can tell at a glance that this is an admin announcement rather than a per-user audit / quota notification. - The locale-resolved body rendered as sanitized markdown (react-markdown + remark-gfm + rehype-sanitize) — same safe pattern AnnouncementPopup uses for landing-page popups. - No navigation on click (broadcasts have no deep-link target); clicking just marks read and collapses the popover. Unread badge already reflects the merged count because it sources from `useUnreadNotificationCount` which calls the backend's `/unread-count` endpoint — server-side does the union. NotificationsPage gets the same locale-resolve + markdown-render treatment so the full list view stays consistent with the bell. * feat(web): i18n strings for admin broadcasts + bell tag (#500) Adds the en + zh translations for everything the broadcast feature touches: nav.admin.broadcasts — sidebar label for /admin/broadcasts. notifications.broadcast.tag — small "Broadcast" chip in the bell. adminPages.broadcasts.* — full admin surface: page eyebrow / title / description, table headers, empty state, delete confirm, toasts (created / updated / deleted + their failure counterparts), drawer chrome (section headings, field labels, placeholders, save button), and the four required-field validation messages. Drawer placeholders + section headings for the Chinese form fields stay in Chinese on both locale files — they're guidance text the operator sees while filling the ZH side of a bilingual record, not content the end-user reads. The error messages are localized per file so an EN-locale admin sees English errors and a ZH-locale admin sees Chinese errors. Description copy is intentional content, not boilerplate — it clarifies what a broadcast is and how it differs from an announcement (no schedule, hard delete, both languages required) so an operator reading the page header alone understands the model. * feat(api): merge broadcasts into /notifications feed + unread + mark-read (#500) Extend the notifications service so admin-authored broadcasts (#500) surface through the same `/api/v1/notifications` feed the bell already renders. No new user-facing endpoints — broadcasts and per-user notifications share the existing routes: GET /api/v1/notifications → merged feed GET /api/v1/notifications/unread-count → per-user + unread broadcasts POST /api/v1/notifications/:id/read → routes by id type POST /api/v1/notifications/mark-all-read → both sources **Feed shape change (additive).** The wire format is now a discriminated union — each row carries `source: "user"` (existing title/body/category/data) or `source: "broadcast"` (bilingual `titleI18n` / `bodyMarkdownI18n` markdown). The frontend resolves the locale at render time, same pattern as announcements (#493). Sort is `createdAt` desc across the union so a fresh broadcast interleaves correctly between recent per-user notifications. The per-page cap is applied AFTER the merge so a chatty admin can't starve per-user notifications out of the feed. **markRead routes by id type.** `POST /notifications/:id/read` was single-id before this commit; it still is. The service looks up the id in `notifications` first (the common case — audit / quota events fire orders of magnitude more often than broadcasts), and only checks `broadcasts` on miss. Unknown id still throws NOTIFICATION_NOT_FOUND so existing 404 semantics are preserved. A new `markManyRead(userId, ids[])` service method handles mixed batches and silently skips unknown ids — "ensure these are marked read" is the caller's intent, so a vanished row shouldn't fail the batch. Not yet wired to a route, but available for the frontend if it wants to coalesce multiple bell clicks. **markAllRead covers broadcasts.** Per-user notifications get the existing `readAt = now` sweep; broadcasts get a `markManyRead` over every currently-unread broadcast id for that user (idempotent via the `(userId, broadcastId)` unique index). Total transitions returned to the client. **Bootstrap wiring.** The `BroadcastRepository` is constructed once and shared between `NotificationService` (for the feed merge) and `BroadcastService` (for admin CRUD). The notifications block had to move slightly so the repo exists at NotificationService-build time. Service-test covers the new merge logic via in-memory fakes for both repositories: interleave order, unread filter, broadcast id routing through markRead, mixed-id markManyRead, mark-all-read across both sources, idempotence on second call, and the broadcasts-repo-absent fallback path for legacy callers that construct NotificationService without it. * chore(api): broadcastItems is const in feed merge (#500) Drop a stray `let` in `listFeedForUser` — the array is pushed into but never reassigned, so `const` is correct and eslint's `prefer-const` catches it. No behaviour change. * fix(web): align broadcast client types with confirmed API shapes (#500) Backend signed off with two divergences from what was assumed in this stack's earlier commits: 1. AdminBroadcastResponse uses `id` (the public DTO field), NOT `_id`. The notification-feed item still uses `_id` because that DTO predates broadcasts. They're deliberately different shapes even though they project off the same Mongo `_id` server-side. 2. Broadcast items in the user-facing /notifications feed do NOT carry a `category` field — only `_id, source, titleI18n, bodyMarkdownI18n, readAt, createdAt`. `category` is now optional on the shared `Notification` type so user items keep their typed enum while broadcast items legitimately omit it. Effects: - broadcastsApi.AdminBroadcast.id replaces `_id`, with a comment explaining why the two DTOs differ. - BroadcastEditDrawer + BroadcastsPage updated to read `.id`. - The synthetic "broadcast" category I had speculatively added is dropped. The bell already keys off `source` for its tag chip; NotificationsPage now picks the chip label via `source` first, falling back to the CATEGORY_LABEL lookup for user items. No API contract changes here — purely client adjustments to match what backend actually ships. Lint / typecheck / build / tests all green. * fix(api): align AdminBroadcastResponse to _id convention (#500) Admin CRUD endpoint was returning `id` while the user-facing feed (`/notifications`) returns `_id` for the same broadcast — and the web client typed both shapes as `_id`. Result: every BroadcastsPage row had `b._id === undefined`, breaking the React key, the edit drawer's id plumbing, and the delete confirm dialog. Rename `AdminBroadcastResponse.id` → `_id`, update `toAdminResponse`, and adjust the service tests that asserted against the old name. Also drop the unused `UserBroadcastFeedItem` type — the merged feed item is already defined inside `notifications/types.ts`'s `FeedItem` discriminated union, so this duplicate was dead. * revert(api): keep AdminBroadcastResponse.id — matches announcements (#500) Earlier fix f253548 renamed `AdminBroadcastResponse.id` → `_id` to match the merged-feed item's `_id` shape. That was the wrong direction: admin/public response DTOs in this codebase use `id` (announcements `PublicAnnouncement.id`, line 51 of announcements/types.ts) — the convention is "REST DTO exposes `id`, Mongo doc keeps `_id` internal". The merged-feed item uses `_id` because it directly extends `NotificationDocument` (the Mongo shape), which is a different surface. Frontend already aligned to `id` in ba62cfe before my fix landed, so this restores the original backend contract and ends the back-and-forth. Keeps the unrelated dead-code removal from f253548 (`UserBroadcastFeedItem` type that was never imported anywhere — feed broadcast items live in notifications/types.ts FeedItem union). * chore(api): drop unused admin broadcast response Zod schemas (#500) `adminBroadcastResponseSchema` + `adminBroadcastListResponseSchema` were defined in `domains/broadcasts/schemas.ts` but no module ever imported them — the route handlers return `c.json(...)` directly without running the response through a runtime check, and the OpenAPI builder doesn't pick them up either. Dead Zod schemas are a footgun: any future drift between `AdminBroadcastResponse` (the TS type the service returns) and the Zod shape goes unnoticed. Delete rather than wire into OpenAPI — broadcasts aren't on the public OpenAPI surface today and we can add the schemas back when they actually have a consumer. * docs: changeset for admin broadcast notifications (#500) Both packages bump minor (new admin surface + new user-facing feed source). Fixed-mode config means they version together.
* feat(web): broadcastsApi types — recipientUserIds on responses + create only (#502) Wire the targeted-broadcast contract into the admin client: - AdminBroadcast: add `recipientUserIds: string[] | null` (always present on the wire — null = broadcast to all users, non-empty array = targeted). - CreateBroadcastInput: add optional `recipientUserIds?: string[]`. Omit / undefined → broadcast. Non-empty array → targeted. Empty array would be rejected with a 400 by the API. - UpdateBroadcastInput: deliberately narrowed to title + body only. Recipients are immutable after create — the API rejects PATCH bodies that include `recipientUserIds`, so we keep the field off the type to make that impossible at compile time. The `useBroadcasts` hooks stay shape-identical; the new field passes through the existing wire layer untouched. * feat(web): UserEmailPicker — debounced email search + chip multi-select (#502) Reusable recipient-picker for targeted broadcasts and any future admin surface that needs to pin an action to a hand-picked set of users: - Search field with 300 ms debounce — feeds `GET /api/v1/admin/users?role=normal&q=…&pageSize=10`. The endpoint already supports `q`, so we reuse it directly instead of fetching the full list and filtering client-side. - Chip strip above the input shows currently-selected users. The label resolves to the user's email by piggy-backing on a single cached page of normal users (admin user counts are small) — this avoids threading a dedicated id→email lookup endpoint through the API for chip render. Falls back to the raw user_id when a chip isn't in cache. - Click a row to add, "×" on a chip to remove. Dropdown stays open after add so the admin can pile-add a handful of recipients in one pass. - Outside-click + disabled state handled. Returns `string[]` of user_ids via `onChange` so the parent stays in control of selection state. * feat(api): broadcasts.recipientUserIds schema + migration (#502) Add `recipientUserIds: string[] | null` to the broadcast doc + admin response. `null` (the canonical default) means broadcast to every user — matching #500 behaviour. A non-empty array means the message is only visible to those NyxID user_ids; commits 2 and 3 enforce that on the create + feed-filter paths respectively. The field is **immutable after create**. The repository's `UpdateBroadcastDocInput` and the service's `UpdateBroadcastParams` deliberately omit `recipientUserIds`, so adding it on a PATCH call is a compile error — the cheapest guard against a future caller forgetting the invariant. PATCH-time enforcement at the route layer follows in commit 2. Migration `backfillBroadcastRecipientUserIds` mirrors the announcements bilingual backfill: a one-shot boot pass that writes `recipientUserIds: null` on every pre-#502 doc where the field is absent. Idempotent (matches \`\$exists: false\` only), non-fatal on failure (the repo mapper's \`Array.isArray\` guard normalises absent fields to \`null\` on the read path so the feed keeps working). Wired into \`bootstrap.ts\` right after the broadcast repo indexes are ensured. Repository \`create\` accepts \`recipientUserIds\` in the input, persists an explicit \`null\` on omitted / empty, and \`[...arr]\` on a populated list. Service \`create\` propagates the field through and logs the recipient count only — never the user_id list (PII-adjacent and pointless for ops debugging). Tests: - New migration test covers the three scenarios: absent → null, already-set (null or array) → untouched, idempotent re-run. - Existing service fakes updated to seed \`recipientUserIds: null\` so the new shape compiles. * feat(api): admin /broadcasts CRUD accepts recipientUserIds on create only (#502) Zod-level enforcement for the immutable-after-create rule on `recipientUserIds`: - `createBroadcastSchema` adds an optional `recipientUserIds: z.array(z.string().min(1)).min(1).optional()`. Omitting the key means broadcast-to-all (the #500 default). A non-empty array means targeted. \`null\`, \`[]\`, and arrays containing empty strings are rejected upstream of the service so bad shapes never reach the repo. - \`patchBroadcastSchema\` is already \`.strict()\`. Any PATCH body carrying \`recipientUserIds\` now returns a 400 \`INVALID_BROADCAST_INPUT\` via the route's existing safe-parse path with an \`unrecognized_keys\` issue — recipients are frozen at create time and cannot be edited. Route handler threads \`parsed.data.recipientUserIds\` through to \`broadcastService.create\` (the service + repo already accept it as of commit 1). PATCH handler is unchanged — the schema does the rejection. Logging: \`broadcast created\` now carries \`recipientCount\` (= \`"all"\` when null, the array length otherwise) — surfaced from commit 1's service.create log line. We never log the user_id list itself. Tests (schemas.test.ts + extensions to service.test.ts): - POST: omitted, present-non-empty, empty array, null, array with empty string, array with non-string entry — six paths covered. - PATCH: rejected when \`recipientUserIds\` is present (asserts the Zod issue code is \`unrecognized_keys\` so a future schema change that silently accepts the field trips this test); normal patches still parse. - Service round-trip: create with recipients shows up in \`listAdmin\`; \`update\` (no recipients in input by design — the type doesn't expose the field) leaves the persisted array unchanged. * feat(web): BroadcastEditDrawer adds Recipients section (create-only) (#502) Recipients are immutable after create — the API rejects PATCH bodies that try to change them. The drawer reflects that contract directly: - Create mode: a Recipients section appears above the bilingual title / body fields with an "All users" / "Specific users" radio pair. Selecting "Specific users" reveals the UserEmailPicker. Submit is blocked with an inline error if "Specific users" is chosen but no recipients have been picked. POST body only includes `recipientUserIds` when the targeted path is selected — for the default broadcast-to-all path the key is omitted entirely so the API treats it as a broadcast. - Edit mode: Recipients renders as a read-only summary card — "All users" or "{n} users" with the resolved email chips. A locked- hint line ("Recipients are locked after creation.") makes the contract visible. Emails resolve via a cached page of normal users (same trick UserEmailPicker uses) so a freshly-opened drawer doesn't need a separate id→email lookup endpoint. - The PATCH submission path defensively strips `recipientUserIds` before sending so an internal bug can't accidentally smuggle a field through that the API would already reject. Test coverage extended: blocks submit when "Specific users" + empty selection, plus existing happy-path + validation tests pass through the new QueryClientProvider wrapper. * feat(web): BroadcastsPage Recipients column (#502) Add a Recipients column between Preview and Created so an admin can tell broadcast-to-all from targeted at a glance: - "All users" badge for the broadcast case (recipientUserIds == null). - "{n} users" badge for the targeted case, with a hover-tooltip that resolves user_ids back to emails (one newline-separated line per recipient). Emails come from the same cached page of normal users the drawer uses — only fetched when at least one row in the table is actually targeted, so the broadcast-only case doesn't pay for the lookup. Tooltip is the native `title` attribute rather than a custom popover to keep the column lightweight; the chip + small recipient lists (targeted broadcasts are inherently scoped) make that the right trade. * feat(api): merged feed filters broadcasts by recipientUserIds (#502) Targeted broadcasts (#502) are invisible to non-recipients on every read + write surface the notifications service exposes — not just the merged feed. Treat them like rows that don't exist for outsiders: - \`listFeedForUser\` filters the broadcast roster through \`isBroadcastVisibleTo(broadcast, userId)\` before joining read receipts. Everyone-broadcasts (\`recipientUserIds === null\`) stay visible to all; targeted broadcasts only render for ids in the recipient list. - \`countUnread\` builds its unread count from the same visible roster, not \`unreadBroadcastIdsForUser\` (which is recipient-blind). A non-recipient sees no badge bump from a targeted broadcast. - \`markAllRead\` only writes receipts for visible broadcasts — a non-recipient pressing "mark all" never inadvertently writes a receipt for a targeted broadcast they can't see. - \`markRead\` (single id) now 404s for non-recipients on a targeted broadcast, matching the rest of the surface. This also closes a side channel: without the check, a non-recipient could probe whether a targeted broadcast id exists by comparing the error code on markRead vs markRead-of-typo. - \`markManyRead\` filters broadcast ids through the same visibility predicate before batching — typos and unauthorised ids collapse into the same silent-skip path. The visibility predicate \`isBroadcastVisibleTo\` is a single private function at the top of the file so all five call sites share one implementation. Adding a new visibility rule later (per-org broadcasts, role-gated broadcasts) is one edit. No new repo methods — the existing \`listAll\` + \`hasUserReadBroadcastsMap\` + \`markManyRead\` are enough. Cost: each surface that previously called \`unreadBroadcastIdsForUser\` now does \`listAll\` instead, which is the same two-query plan (broadcasts + receipts) but returns the recipient field we need to filter. Broadcast count is bounded (<< 1k) so the array filter cost is negligible. Tests (notifications/service.test.ts): - Targeted broadcast visible to listed recipients; invisible to others. - Everyone-broadcast still visible to all. - Targeted + everyone interleave correctly per-recipient. - countUnread + markAllRead respect the filter. - markRead 404s for non-recipients on a targeted broadcast. - markManyRead silently skips non-visible broadcast ids. - unreadOnly + recipient filter compose. * feat(web): NotificationDetailModal + bell/page wiring (#502) A broadcast can carry markdown bodies that don't fit in the bell-row preview or a one-line list cell, so a click should open the full content in place rather than trying to render every body inline. - New `NotificationDetailModal` renders the bilingual broadcast in full: locale-resolved title as the heading, locale-resolved markdown body through `react-markdown` + `remark-gfm` + `rehype-sanitize` — the same safe stack `AnnouncementPopup` uses. Motion vocabulary matches Modal / ConfirmDialog (backdrop fade + dialog spring). - Close vectors: ESC, backdrop click, explicit ×. - On open: if `readAt == null` the modal fires the mark-read mutation passed in by the parent. Guarded on `id + readAt` so a parent rerender doesn't double-fire. The mutation lives in the parent so the modal stays stateless wrt React Query. - NotificationBell + NotificationsPage now route broadcast-source clicks into the modal instead of "mark read + collapse" / "mark read + stay on page". User-source clicks keep the existing navigate behavior (link → that link, no link → /notifications). * feat(web): i18n strings for recipients + modal (#502) Bilingual strings to support the targeted-broadcast surface and the broadcast detail modal: - `adminPages.broadcasts.recipients.*` — section heading, audience radios ("All users" / "Specific users"), summary labels, locked hint for edit mode, picker placeholder + aria + states, chip remove aria. - `adminPages.broadcasts.table.recipients` — new column header. - `adminPages.broadcasts.errors.recipientsRequired` — submit validation for "Specific users" + empty selection. - `notifications.broadcast.modal.aria` — accessible label for the detail dialog. EN + ZH key trees match. * chore(web): memoize BroadcastsPage items list to satisfy react-hooks lint (#502) The `useMemo` for `hasTargetedRows` depends on `items`, but `items` was recomputed from `broadcastsQuery.data ?? []` on every render — a new array reference each time, which made react-hooks/exhaustive-deps flag the dependency chain as unstable. Wrap the fallback in its own `useMemo` so downstream memoization is actually stable. No behavior change; lint-only. * docs: changeset for targeted broadcasts + modal viewer (#502) Both packages bump minor — backend gets a new schema field (`recipientUserIds`) and feed filter; web gets the targeted-broadcasts UI (UserEmailPicker, drawer Recipients section, page column) plus the new NotificationDetailModal for the bell + `/notifications` page. #500's changeset stays untouched — it describes a different release-notes bullet ("admin can author bilingual broadcasts"), and both changesets will be consumed together at the next release-bump PR. Keeping them separate so the eventual release notes have one bullet per behaviour change.
…del picker (#503) (#508) * fix(web): translate Playground hero, starters, drawer chrome (#503) The Playground page (`/playground?skill=…`) calls `t()` with `playground.*` keys for its empty-state hero, the three prompt-starter cards, drawer tab labels, kbHint, env-var lock hint, About / Tags / Skill page rows, and the pin / unpin / pinned drawer header buttons — none of which existed in either locale file. `react-i18next` was therefore returning the inline English default at every render, so the entire surface stayed in English under `lng = zh`. Add every referenced key to `en.json` (verbatim copy of the existing inline default) and `zh.json` (Chinese translation). No component change — the components already point at these keys; the resolver just had nothing to look up. Refs #503 * fix(web): translate Generative skill builder hero, starters, drawer (#503) CreateSkillGenerativePage calls `t()` with `generative.*` keys for its eyebrow, hero title + subtitle, drawer hint, the three prompt-starter cards (Slack notifier / Fetch GitHub PRs / CSV → JSON), the composer askPlaceholder (distinct from the in-flight `placeholder` "Generating…"), the Package drawer tab, pin/unpin/pinned, validation panel title, and the empty-preview hero + hint. None existed in the locale files. Add every referenced key to `en.json` (verbatim copy of the inline default) and `zh.json` (Chinese translation). Bring `startOver` / `saveSkill` casing into line with what the component renders (the page uses sentence case; the locale had title case from an earlier draft). Refs #503 * fix(web): route QuotaInline + QuotaChip through useTranslation (#503) Both components used hardcoded English copy for everything visible — the admin-bypass "Admin · Unlimited playground/skill-gen" stamp on the playground / skill-gen surfaces (`QuotaInline`) and the paired "Playground · ∞" / "Skill-gen · 190/200" pills in the breadcrumb-row right rail on every authenticated page (`QuotaChip`). Switching to 中文 left them untouched. Wire both through `useTranslation` under a new `quota.*` namespace: surface labels, the admin/remaining chip variants (with their aria-label + title attributes), the warning banner heading + body, the admin-grant suffix, and the compact "calls left" / "+N grant" microcopy. Keep the existing `aria.adminQuotaUnlimited` / `aria.adminUnlimitedUsageTitle` keys on the QuotaInline admin stamp since they're the established a11y keys. The interpolation tokens use `{{value}}` (not `{{count}}`) — i18next reserves `count` for plural-form numbers and TypeScript rejects strings there. Refs #503 * fix(web): route ModelPicker through useTranslation (#503) The picker rendered the "Model" trigger label, the " — default" suffix on the selected option, the "default" badge inside the menu, the "Loading models…" placeholder, and the "<surface> — temporarily unavailable. Contact admin." empty state as hardcoded English strings, plus its trigger / listbox aria-labels were also hardcoded. All visible under Chinese locale because none of these passed through `t()`. Drop the inline `SURFACE_LABEL` map, plumb everything under a new `modelPicker.*` namespace, and let callers still override the trigger label via the `label` prop (the prop now defaults to `undefined` and falls back to `t("modelPicker.label")`). Refs #503 * docs: changeset for #503 * ci: re-trigger after transient npm registry failure
* fix(web): break NotificationDetailModal mark-read render loop (#509) Clicking a broadcast item in NotificationBell / NotificationsPage crashed the React tree with error #185 (Maximum update depth exceeded). The mark-read useEffect inside the modal included `onMarkRead` in its dependency array, but both call sites pass an inline arrow `(id) => markRead.mutate(id)` — a new reference every render. The mutation's `isPending` state shift rerenders the parent → fresh arrow → effect deps shift → fires the mutation again → loop. The existing `if (readAt) return` guard couldn't catch this: `readAt` reads from the `notification` prop, which is a snapshot taken when the user clicked the item, so it stays `null` while the live query cache is updating in the background. Two layered defenses, both modal-side so the fix doesn't require callers to remember to `useCallback`: 1. Stash `onMarkRead` in a "latest ref" updated on every render. The mark-read effect invokes the ref instead of the prop, so the prop's identity is no longer part of the effect's deps. 2. Track the last id we've already marked in a separate ref. Even if the effect somehow reruns (e.g. a future regression to the deps array), the same id won't refire. Resets to null when the modal closes so reopening a different unread item still works. Regression test rerenders the modal five times with a fresh `onMarkRead` arrow each pass and asserts mark-read fired exactly once. This is the exact production call shape that triggered #509 — the existing tests mounted with stable props and didn't catch the loop. Hotfix on top of #502. ornn-web only — no api changes; only the web image needs a rebuild on deploy. * docs: changeset for notification modal render-loop fix (#509) Patch bump for both packages (fixed-mode config). User-visible impact: clicking a broadcast no longer crashes the page.
* feat(web): hardcoded launch-celebration popup on landing page (#511) Adds LaunchCelebrationPopup — a session-only modal that fires on every mount of LandingPage for both anonymous and signed-in visitors, announcing the public-launch free-credit promo (200 Playground + 200 Skill Generation credits for the first 500 GitHub stargazers, plus the NyxID invite code for new users). Deliberately independent of the dynamic announcements collection so the launch notice cannot expire from the admin panel or depend on the /announcements/active endpoint during launch traffic. Dismissal sets local state only — no localStorage write. Navigating away and back to '/' reopens the popup, by design for the launch window. Remove the import + JSX line from LandingPage.tsx when the offer ends; the component + tests can stay parked. Visual language reuses the AnnouncementPopup Industrial Forge plate (ember surface, Space Grotesk title, JetBrains Mono micro-label, welded-seam divider, press-down CTA) sized one step larger (max-w-2xl) to fit the launch body content. Bilingual via i18n landing.launchPopup.* keys in en.json + zh.json. Tests lock in the load-bearing contract: opens on mount, closes on dismiss, reopens on remount (proves no persistence between visits). * docs: changeset for #511
…ast (#513) (#514) The launch popup shipped with an ember-orange surface (matching AnnouncementPopup), but the inline GitHub repo link uses ember-deep — a dark shade in the same orange family — so the most actionable string in the popup was effectively invisible. Body paragraphs on the orange surface also read as muddy because most strings sat at text-obsidian /85–/90 opacity rather than full ink. Flips the popup surface to obsidian (bg-panel) with a 2px ember border. Title and strong body move to parchment; secondary body to bone; meta to ash; the eyebrow micro-label and the GitHub link both move to ember so they pop against the dark panel without colliding with each other. Invite code shifts to molten gold for a single high-value chip in the callout. CTA inverts to ember-on-obsidian. Letterpress shadow, welded-seam rivet divider, press-down CTA behavior, hard-offset shadow on hover — all unchanged. AnnouncementPopup also keeps its ember plate; only this one-off launch-day modal flips.
…#515) (#516) Full design pass. Replaces the previous obsidian-on-rust patch (which relied on raw-var arbitrary classes that didn't theme cleanly + an inline box-shadow that violates DESIGN.md) with a proper Forge Workshop surface built from semantic theme-aware tokens. New visual structure: - Bracketed mono eyebrow + ISO date for publication-cite signal - Space Grotesk display title anchored to text-strong - Welded-seam divider — hairline in strong-edge with ember rivets - Two-up offer tiles — 200 numerals in text-accent, mono brand labels, meta model captions, framed in elevated + subtle border - Numbered redemption steps (01 / 02) with tabular-num ember numerals; step 1 contains the GitHub repo link in ember with an accent-muted underline - Click-to-copy invite-code chip in molten-gold mono framed by accent-support/55; shows Copied ✓ for 1.8s. Silent no-op on older Safari / non-secure contexts - Limited-slots warning row with ember triangle prefix - Right-aligned CTAs: ghost Dismiss + primary STAR ON GITHUB, both wearing .cta-letterpress for press-down + reduced-motion All surfaces use semantic tokens (bg-card, text-strong, border-accent, border-subtle, border-strong-edge, bg-elevated, text-meta, text-accent, text-accent-support, bg-accent, border-accent-muted, text-page) so dark + light both resolve through the same code path. The card sits on a hard-offset letterpress plate in bg-ember-deep — no inline shadow strings anywhere. i18n keys restructured for the new layout: per-tile {number, title, caption}, split-step body strings (`step1Before`, `step1After`, `step2`), invite-code copy/copied labels, limited-slots warning, inviteAria. Old keys removed. Existing tests still pass (opens on mount, closes on dismiss, reopens on remount).
…ntext bug (#517) (#518) Visual verification of #515 caught a load-bearing bug: the letterpress shadow plate was painting OVER the card surface, not behind it. Root cause was a stacking-context trap. The card div had `position: relative + z-index: 10` — that combination establishes a stacking context. Inside that context, a child with `z-index: -10` paints between the parent's bg and its in-flow content, NOT behind the parent. So the `bg-ember-deep` plate was sitting directly on top of the card's `bg-card` bg, hiding it almost entirely (only a 6×6 px L-corner of the actual card bg was exposed where the plate's 6px translate doesn't reach). Dark mode masked the bug because both `bg-card` (#1A1610) and `bg-ember-deep` (#7A2308) are dark warm tones — visually similar. Light mode unmasks it: `bg-card` is `#FFFFFF` and `bg-ember-deep` is `#6E2207`, a saturated rust red. The popup interior read as a rust panel with low-contrast ink — exactly the regression #513 and #515 were supposed to prevent. Fix moves the plate from card-child to card-sibling inside a positioning wrapper. No z-index hacks. Paint order = document order — plate paints first (filling the wrapper bounds with the 6px translate offset), then the card paints over it with its opaque `bg-card`. The plate is now visible only at the 6px offset edges, which is the correct letterpress impression. Animation moves up one level — the `motion.div` is now the wrapper, so plate + card animate together. ARIA semantics move down one level — `role="dialog"`, `aria-modal`, `aria-labelledby` live on the inner card. The 3 existing tests still pass (they query by role, which finds the inner card). AnnouncementPopup has the same structural bug but doesn't read visually because its card and plate are both ember-toned. Filed as a separate follow-up rather than scope-creep this fix.
…vite code (#519) (#520) The popup showed the invite code (`NYX-2XXJI08A`) under a small mono label "NyxID invite · first-time users" with no further context. Users couldn't tell where they'd be asked for the code, that Ornn's Sign In redirects to NyxID (a separate product — our identity provider), or that they still have to choose GitHub on the NyxID page after pasting the code. Added a short body paragraph in the popup directly below the code chip (and above the "Limited slots" warning) walking through the actual flow: click Sign In (top-right of Ornn) → redirected to NyxID → paste this code into the invite field → choose GitHub to finish sign-up New i18n key landing.launchPopup.inviteHelp in both en.json and zh.json. No tests changed — existing popup contract (mount, dismiss, remount) still holds; the helper is pure presentational copy.
…t-redemption flow (#524) (#525) Follow-up to #519. #519 explained where users would be asked for the invite code (NyxID SSO login). This commit explains what happens *after* they finish login + star the repo: delivery timing, where the code arrives, how to apply it, and where to ask for help. New body paragraph below the existing NyxID-flow paragraph and above the "Limited slots" warning, walking through: - 24-hour delivery window (sets expectations, prevents anxious "did I do something wrong?" follow-ups) - Bell icon at top-right (names the actual UI element so users don't look in their email) - Profile dropdown → "Redeem code" (quotes the menu label) - Discussions thread link (https://github.com/.../discussions/521) for technical issues during redemption Three new i18n keys (fulfillmentBefore / fulfillmentLinkLabel / fulfillmentAfter) following the same split pattern as step1Before / step1After so the discussions URL renders inline. Link is styled identically to the GitHub link in step 01 (same accent / decoration / hover tokens) for cross-section visual consistency. No tests changed — popup mount/dismiss/remount contract is untouched; the helper is pure presentational copy.
…els (#523) * feat(web): add SkillIcon, EnvIcon, PackageIcon to shared icons module Three new 1.5 px stroke icons in the existing `components/icons/` voice (`fill="none"`, currentColor, IconProps): SkillIcon (document + content lines, SKILL.md metaphor), EnvIcon (key — env vars as secrets), PackageIcon (cube — the canonical "shipping box" glyph). Used by the rail tab handles on PlaygroundPage and CreateSkillGenerativePage (next two commits). * fix(web): playground rail tabs — icon + tooltip handles, fix CJK upside-down labels (#522) The Skill / Env / Package rail tabs on the right edge of the Playground rendered their labels with `writing-mode: vertical-rl` plus `transform: rotate(180deg)`. That combo is a vertical-English convention — `vertical-rl` lays Latin letters sideways, then the 180° rotation flips them so the word reads bottom-to-top. For CJK characters, `vertical-rl` is already the *natural* upright vertical writing mode; adding `rotate(180deg)` flips every character upside down. End result: Chinese users saw a column of inverted glyphs and reasonably concluded the page was broken. Replaces the rotated text with icon-only tab handles plus a horizontal mono-uppercase tooltip on `group-hover`. Wording matches the drawer header voice: `[§ SKILL]` / `[§ ENV]` / `[§ PACKAGE]` — the DESIGN.md bracketed mono operational labels. Tooltip is hidden when the drawer for that tab is already open (the drawer header provides the same context, so the tooltip would just be noise). Tab handle fixes to `h-11 w-9` with a 16 px icon centred; all three tabs are now equal height. Previously PACKAGE was ~1.7× the height of ENV because of letter count, which broke the rail's vertical rhythm. `aria-label` continues to use the i18n string so screen readers stay locale-correct. Preserves the warn-dot and pin-indicator affordances (warn dot `top-1.5` adjusted for the slightly shorter handle). Closes #522. * fix(web): skill-gen rail tab — icon + tooltip handle, fix CJK upside-down label (#522) Same fix as the Playground rail (previous commit), applied to the single Package tab on CreateSkillGenerativePage. The page used the same `writing-mode: vertical-rl` + `rotate(180deg)` pattern on the "Package" label, which renders fine for English but upside-down for Chinese. Replaces the rotated label with a `PackageIcon` handle plus a horizontal `[§ PACKAGE]` mono-uppercase tooltip that fades in on `group-hover` when the drawer is not pinned open. Tab handle is fixed to `h-11 w-9` to match the Playground rail's new geometry. `aria-label` keeps using the i18n string. Closes #522. * docs: changeset for #522 * chore(web): localize playground rail tab aria-labels via i18n keys (#522) After rebasing onto develop, the skill-gen page already used the post-#508 i18n cleanup pattern (`t("aria.skillPackageDrawer")`) for its rail tab aria-label. The playground's three rail tabs were still using the older fallback-string concatenation (`${tab.label} drawer`), which renders as English regardless of locale. Adds two new keys (`aria.playgroundSkillDrawer`, `aria.playgroundEnvDrawer`) to both `en.json` and `zh.json`; the playground's package tab reuses the existing `aria.skillPackageDrawer` key since the meaning is identical. Renames the railTabs object's `label` field to `ariaLabel` to reflect its single remaining purpose — the visible tooltip continues to use the hard-coded mono-uppercase `tip` field (`SKILL` / `ENV` / `PACKAGE`) to match the drawer header voice. Closes #522.
…tency (#527) * feat(web): LaunchCelebrationPopup — molten edge flow on offer tiles (#526) The two "200 credits" tiles are the central benefit of the launch popup but sat as flat matte rectangles, so the eye drifted past them. Added a single bright ember-to-gold "comet" that travels around each tile's perimeter over 5s, with a soft outer ember halo so each tile radiates heat even at rest. The two tiles stagger by 50% of the cycle (-2.5s on tile 2) so they read as independent objects, not CSS clones. Pure CSS: - @Property --offer-angle (registered as <angle> so it interpolates smoothly during animation) - conic-gradient that uses --offer-angle as its `from` angle, rotated to 360deg via @Keyframes offer-tile-rotate - mask-composite: exclude (with -webkit-mask-composite: xor for Safari) to carve out the interior, leaving only the 1.5px ring - layered box-shadow halo: short bright molten-gold ring + longer diffuse ember glow No JS, no Framer Motion, no per-frame React work. The conic-gradient arc is asymmetric (~180deg lit, ~180deg transparent) so only one half of the perimeter glows at any moment — keeps the effect premium- feeling rather than gaudy. Reduced-motion fallback: rotation suppressed, ring becomes a static diagonal molten gradient; halo box-shadow stays in both modes (it's not motion). OfferTile gets an optional animationOffsetSeconds prop wired through to the CSS via the --offer-tile-anim-delay custom property, so the stagger doesn't require a second class or duplicated keyframes. * fix(web): LaunchCelebrationPopup — align credit2Caption with credit1Caption (#526) Both offer tiles spend the same conversation-credit pool, but the captions diverged: credit1Caption (Playground) Conversations · GPT-5.5 credit2Caption (Skill Generation) Drafts · GPT-5.5 ← stale In ZH the same divergence: "对话额度" vs "创建额度". Two captions for one credit type reads as "two different credit types", which is misleading — users would assume Playground credits and Skill Generation credits are non-fungible. Aligned credit2Caption to credit1Caption in both locales.
… link (#532) (#533) * feat(web): NotificationDetailModal renders user-source body (#532) The modal previously assumed `source === "broadcast"` and rendered locale-resolved bilingual markdown. Generalize it so user-source rows (audit / quota) can use the same chrome: render `title` + plain-text `body` wrapped in `whitespace-pre-wrap` so admin-supplied notes keep their newlines and long redemption-code strings without HTML risk. Tag chip resolves to a category label (Audit / Quota) instead of the hardcoded broadcast tag. New `notifications.detail.modal.aria` i18n key (en + zh) covers the screen-reader label for the non-broadcast shape. Test added for the user-source path; existing broadcast tests unchanged. * feat(web): open detail modal for non-broadcast rows missing a link (#532) `handleOpen` / `handleItemClick` previously only opened the modal for broadcasts. Non-broadcast rows fell through to `if (n.link) navigate`, which meant quota credit notifications — emitted without a link by design — were a dead click that only silently flipped `readAt`. New routing for user-source rows: - has `link` → mark-read + navigate (unchanged; audit deep-links still go to the audit page) - no `link` + has `body` → open detail modal in place - neither → bell falls back to /notifications, page silently marks read (the existing terminal behavior) Both surfaces (full /notifications page + navbar bell) get the same treatment so the row's clickability matches user expectation everywhere. * docs: changeset for #532 (notification modal opens for non-broadcast rows)
…restrain palette (#548) * refactor(web): unify Skill / File pane headers — equal h-9, drop redundant FILE prefix (#547) Both `FileTree` and `SkillFileViewer` headers used the same paint (`bg-elevated/40 border-b border-subtle px-4 py-2`) but their content sized differently — the tree's `text-[10px]` label vs the viewer's `text-sm` filename produced unequal line heights, so the two pane headers misaligned across the seam in the unified package panel. Locks both to `flex h-9 items-center px-4` so the seam is straight regardless of content. Also drops the `FILE` literal prefix from the viewer header — the filename right next to it always implied this; the prefix added no information and ate horizontal space on narrow drawers. The filename is now the header. * refactor(web): SkillPackagePreview — flat identity strip, drop multi-colour badge palette (#547) The preview component had two structural problems compounding: 1. Multi-colour `Badge` palette — category got a cyan/magenta/yellow/ green tint by `metadata.category`, and every tag got a hash-bucketed colour from the same four-colour pool. DESIGN.md is explicit that ember is the *only* action voice; spraying four accents across an identity card is exactly the "no decorative rainbow" anti-pattern. 2. Nested cards — an identity `card-impression` on top, then a panes `card-impression` below. Inside a drawer that *itself* is a `card-impression`, the user saw three rounded letterpressed surfaces stacked, which DESIGN.md flags as visual noise. This commit: - Drops the `Badge` import, `CATEGORY_BADGE_COLORS`, `TAG_COLORS`, and `getTagColor` together — none of them needed any more. - Replaces the identity card with a single flat section, hairline- separated from the panes below. Category renders as mono `[§ CATEGORY]` in `text-accent` (same bracketed voice as drawer headers — `[§ PACKAGE]`, `[§ SKILL]`). Tags render as `tag · tag · tag` plain mono in `text-meta`. Author on the right of the title row in mono `text-meta` (`by NAME`). - One outer `card-impression` wraps the whole component, header + panes, so the drawer sees a single rounded surface. - `ResizablePanes` default split 30 % → 26 % gives the viewer ~4 % more horizontal room — meaningful on narrow drawers, the file tree rarely needs more than a quarter of the width. - Drops the inline `style={{ minHeight: 320px }}` from the panes container — it was fighting `flex-1` and forced an overflow when the parent gave less than 320 px. The component now sizes purely off whatever flex height its parent allocates. * fix(web): CreateSkillGenerativePage drawer — widen, pin actions, push past navbar (#547) Three usability issues on the AI Skill Generation drawer: 1. **Drawer top clipped by navbar.** `top-4 bottom-4` slid the drawer under the 60 px navbar — the drawer header (`[§ PACKAGE]` + Pin + ×) was hidden behind the nav bar. Bumped to `top-[68px]` (navbar height + 8 px buffer); bottom unchanged. 2. **Whole drawer scrolled, hiding action buttons.** The body used `overflow-y-auto` over a `flex flex-col gap-4 p-4` wrapper, so on any non-trivial skill the user had to scroll past the file viewer to reach `Save skill`. Replaces with a proper flex column: validation errors at the top (`shrink-0`), `SkillPackagePreview` in the middle (`flex-1 min-h-0`, scrolls internally inside its panes), action buttons at the bottom (`shrink-0`, always visible). Drawer body no longer scrolls; the panes do. 3. **Drawer too narrow.** `w-[520px]` made frontmatter YAML wrap mid-key on Chinese descriptions and truncated the file tree's folder names to `hello-res…`. The preview *is* the artifact being built on this page (per the file's docstring), not an auxiliary peek — so it deserves real space. Bumped to `w-[min(960px,65vw)]` with the existing `max-w-[calc(100vw-3rem)]` safety cap. Passes `className="min-h-0 flex-1"` to `SkillPackagePreview` so it takes the body's middle row cleanly. `WeldedSeam` gets `shrink-0` so the seam stays at its natural height when the preview fills. * refactor(web): PlaygroundPage drawers — flat identity strip, mono status, match generative voice (#547) Brings the Playground's three drawers (Skill / Env / Package) into the same visual language as the rebuilt `SkillPackagePreview` and the gen-page drawer, so the two surfaces feel like one product. **Drawer chrome (all three drawers)** - `top-4` → `top-[68px]` — same navbar clearance as the gen page. - `w-[420px]` → `w-[min(560px,40vw)]`. Chat is still the page hero (`max-w-2xl`), so this is a moderate expansion — enough that the Skill description and Env labels stop wrapping awkwardly, without pushing the drawer over the chat composer. **Skill drawer** - Drops the `</>` icon-square left of the title, the outlined `v1.1` border pill, and the per-section `WeldedSeam` rivet dividers between About / Tags / footer. - New flat identity strip: `name` in `font-display text-xl`, `v1.1` in mono `text-meta` on the right of the title row. `[§ category]` ember-bracketed mono + `tag · tag · tag` dot-separated mono on the next row. Description flows directly below, no preceded "About" label — the description is its own label. - "Skill page →" registry link moves from a mid-content section to a hairline-bordered footer pinned at the bottom of the drawer. Always visible regardless of how long the description is. **Env drawer** - Drops `<Badge color="green">` / `<Badge color="cyan">` for the Set / Required indicator — same multi-colour palette critique as the gen-page redesign. Replaces with plain mono uppercase text: `text-success` when filled, `text-meta` when empty. One accent, used intentionally. - Header becomes the same flat strip pattern as the skill drawer — `[§ env vars]` ember bracketed + description below — and the form itself scrolls in its own region under the strip. **Package drawer** — picks up the `SkillPackagePreview` redesign automatically (no direct edit). The `name@v1.1` + "Open in registry →" header bar stays since `metadata={null}` is passed through, meaning the preview itself doesn't render an identity strip and the bar is the only identity hint. **Cleanup** — drops the now-unused `Badge` import and the local `WeldedSeam` helper (no remaining call sites in the file). * docs: changeset for #547
…#550) * chore(api): mask redemption code with **** instead of ellipsis (#549) The grant audit + downstream quota notification carry a privacy mask so the full redemption code never leaks. Mask glyph was U+2026 (`…`), which works fine in the audit log but is ambiguous in the new /notifications detail modal (#532): the ellipsis is what every UI on the web uses to mean "text truncated, click to see more", so users kept asking why the modal was hiding the rest of their code. Swap the mask to `****`. First four chars of the code still leak (so the redemption is still identifiable on a follow-up), nothing else changes. Historical rows keep `…` — no migration needed; the row context (QUOTA tag, "Redeemed code" prefix) already makes the provenance unambiguous. Lock the new mask in with a regression test that asserts the literal note format on each grant audit row and explicitly rejects `…`. * docs: changeset for #549 (redemption note mask glyph)
…en-page pulses on new iterations (#552) * refactor(web): SkillPackagePreview — bump file-tree default split 26 % → 32 % (#551) Typical skill folder names — `ornn-agent-manual-cli`, `cdn-image- processor` etc. — are 18–24 chars. At the old 26 % default split, the left pane is ~250 px on a 960 px drawer, which puts the name right at the truncation boundary and ellipsises real skills to `ornn-agent-man…`. 32 % gives ~300 px, enough horizontal room for those names without truncating; the user can still drag the divider in either direction to bias toward the viewer. * refactor(web): playground drawer — absorb Skill tab into Package, unify width, fix overflow (#551) After #548 landed, the Playground drawer rail had four small issues that compounded into a "two products / two drawers" feel relative to the Skill Generation page. This pass fixes them together because they touch the same component and would each be a noop without the others. **1. Package drawer now surfaces full skill metadata.** Previously `SkillPackagePreview` was called with `metadata={null}`, hiding its identity strip; a bespoke `name@version | Open in registry` sub- header bar covered for it. Now we synthesise a `SkillMetadata`- shaped object from the registry skill record (via `createDefaultSkillMetadata`) and pass it through, so the Playground sees the exact same flat identity strip — name + bracketed category + dot-separated mono tags + description — that the gen page sees. The bespoke sub-header bar is gone. **2. Skill rail tab dropped — Package drawer subsumes it.** With the identity strip now rendered inside the Package drawer, the Skill drawer was showing the *same* information one tab over. Two tabs, one piece of info. `DrawerKey` narrows from `"skill" | "package" | "env"` to `"package" | "env"`; the Skill drawer body branch goes, the Skill rail tab definition goes, and the now-unused `SkillIcon` import goes with it. The Env tab is unchanged (still conditional on `needsEnvVars`). **3. Drawer width unified.** `w-[min(560px,40vw)]` → `w-[min(960px,65vw)]`, matching the gen page exactly. The earlier narrower width was rationalised as "chat is the hero on the Playground, drawer is auxiliary"; with the Package drawer now hosting the same artifact view as the gen page, that rationale no longer survives — the user's expectation is that the same content fits the same way. **4. Footer + flex-col wrapper fix.** The Package drawer body had a padded `<div>` (non-flex) wrapping `<SkillPackagePreview className="min-h-0 flex-1" />`. `flex-1` inside a non-flex parent is a no-op, so the preview took its natural height and the file viewer overflowed past the drawer footer. Wrapping in `flex flex-col` makes `min-h-0 flex-1` claim the remaining height, so the file viewer scrolls internally and the footer stays pinned. The footer itself moves from inside-the-padding to an edge-to- edge hairline-bordered row at the bottom (same pattern as the Skill drawer's old footer), holding `skill.name@v{version}` on the left and the registry link on the right. The drawer chrome (`motion.aside` + header bar with `[§ NAME]` + Pin / ×) is still duplicated between gen and playground by hand; extracting it into a shared `<RightSlideDrawer>` component is the next obvious refactor and intentionally out of scope here. Closes #551. * feat(web): skill-gen rail tab — pulse on new iteration while drawer is closed (#551) The generative chat is a multi-turn refinement loop — the user keeps typing "now make it bilingual", "add Chinese", "also tone it formal" and each turn produces a fresh skill package. If the user un-pins the drawer, the only signal that the artifact has changed is the chat line `Generated: hello-in-chinese`, which scrolls away as the user types the next prompt. Detect the `generation.phase` transition `"generating"` → `"preview"` while `drawerOpen === false`, set `hasUnseenIteration`. Clear it whenever the drawer opens (hover or pin). While set, the Package rail tab gets: - Ember accent border + icon colour (instead of grey idle), so the tab itself reads as active-ish even when the drawer is closed. - Two stacked rings around the tab: a steady `ring-accent/70` outline + an `animate-ping` ring at `ring-accent/40` that scales out to draw the eye. - A small ember dot at the top-right corner with `animate-pulse`, for the case where the rings blend into the surrounding card. All three layers are `pointer-events: none` so they don't intercept the existing hover / click on the tab handle. The CSS animations are Tailwind primitives (`animate-ping`, `animate-pulse`); no new keyframes, no `shadow-[…]` arbitrary tokens (DESIGN.md restricts those). * docs: changeset for #551 * fix(web): move previewMetadata useMemo above early returns to satisfy rules-of-hooks (#551) The previous commit placed `previewMetadata = useMemo(...)` after the three early-return guards (`if (!skillName) ...`, `if (skillLoading) ...`, `if (isOverLimit && ...) ...`). That violates React's Rules of Hooks — hook call count must be the same across every render of a given component, and an early `return` before a hook can produce a render path with fewer hooks than the normal path. CI's eslint job caught it on `react-hooks/rules-of-hooks` (1 error, PR #552 ci/lint check). The fix is purely structural — move the `skillCategory` derivation and the `previewMetadata` `useMemo` up to just after `activeDrawer`, before any guards. The hook now runs unconditionally on every render. Behaviour is identical: when the component would short-circuit (no skill name, loading, over-quota), the memoised value is computed but the component returns before it's ever read.
* feat(web): pin launch-celebration content to top of /news (#553) Adds LaunchCelebrationNewsEntry — a hardcoded, non-modal sibling of LaunchCelebrationPopup that stamps the public-launch free-credit promo at the top of the News archive (/news), ahead of the dynamic announcements feed. A user who lands on /news from the navbar (i.e. anywhere other than /) otherwise has no surface for the launch notice — the landing-page modal only fires on '/' and the launch content is deliberately bundle-local (cannot expire from admin, cannot depend on /announcements/active uptime), so it never appears in the dynamic feed. Reuses the popup's landing.launchPopup.* i18n keys verbatim — EN + ZH inherited, no duplicated strings. Visual language matches the rest of the News archive (card-impression article, bg-card, accent border) rather than the popup's full-ember plate; click-to-copy NyxID invite chip and "Star on GitHub" CTA are retained. Cleanup contract: remove <LaunchCelebrationNewsEntry /> + its import from NewsPage.tsx when the offer ends. Component file + i18n keys can stay parked, same as the popup. * docs: changeset for #553 (launch promo pinned to /news)
* docs: prep release v0.8.0 Adds the dated release-notes file (`release-notes-20260516.md`) so the upcoming `develop → main` cut produces a tagged **v0.8.0** Release with curated user-facing notes. Two minor changesets pending on develop bump both packages 0.7.2 → 0.8.0 in fixed mode: admin broadcast notifications (#501) and targeted broadcasts + click-to-popup viewer (#504). The remaining 15 patch changesets cover the launch-celebration popup polish cycle, drawer consolidation, i18n coverage on Playground / Skill builder / Quota chip / Model picker, the redemption-mask glyph swap, and the notification-modal mark-read / non-broadcast-open fixes. Closes #555. * chore: empty changeset for release-notes prep Satisfies `check-changeset.yml` for this docs-only PR. No semver bump — the version impact is fully covered by the existing pending changesets on develop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Minor release. After merge, the changeset-release workflow will:
release/v0.8.0PR consuming the 17 pending changesets (2 minor + 15 patch) and bumping both packages0.7.2 → 0.8.0in fixed mode.v0.8.0, GitHub Release sourced from.github/release-notes-20260516.md, sync PR to develop (merge with Create a merge commit, never squash — per CLAUDE.md).What ships
New Feature
/notificationsfeed.Changed
****instead of ellipsis.Fixed
Issues closed
Test plan
release/v0.8.0PR — squash-merge it..github/release-notes-20260516.mdcontent (not the short fallback).