Skip to content

Feat/global widget enhancements 1#118

Merged
MaryWylde merged 38 commits into
devfrom
feat/global-widget-enhancements-1
May 19, 2026
Merged

Feat/global widget enhancements 1#118
MaryWylde merged 38 commits into
devfrom
feat/global-widget-enhancements-1

Conversation

@MaryWylde
Copy link
Copy Markdown
Contributor

No description provided.

manager and others added 30 commits May 15, 2026 21:50
Three hand-crafted first-touch Q&As on the homepage empty-state:
- What does keepsimple actually make?
- How is this project completely free?
- Where do I start if I'm new here?

Each renders a finished Turn locally with pre-canned answer + 3-4
nominated cards — no LLM call, no retrieval. Carve-out documented
in docs/widget-architecture.md; the normal concierge pipeline keeps
running everywhere else (free-form questions, follow-ups, non-home
pages).

Widget's in-panel UXCAT "Begin Test" CTA now mirrors the in-page
CTA: when anonymous, dispatches ks-aux-request-login event instead
of bouncing through /uxcat/start-test → /uxcat. UXCatLayout listens
and opens its LogInModal directly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Picked-card visual emphasis (orange border, tinted background, "Your
pick" badge) duplicated the host-page glow that already fires on the
same click. Removed the rendering + orphan CSS rules. The c.picked
data stays in flight — the server still gets last-pick context.

Curated UX Core blurb in pageIdentity.ts claimed "200+ entries"; the
real number is 100+. Updated en + ru so concierge landing turns stop
overstating it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Starter chips were dumping the full pre-canned answer in one frame —
read as obviously fake. Now: 750ms think-beat with the caret, then
the answer types in chunks (~3 chars / 14ms), cards land at the end.
Matches the feel of the real concierge stream so the carve-out reads
like the agent thinking, not a static FAQ.

Bold text inside the answer (e.g. "UX Core" in the new starters) was
inheriting #1f1d1a from .ks-aux-a strong, invisible on the dark
panel background. Added a dark-mode override using the same cream
emphasis tone used elsewhere.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
750ms read short compared to the real concierge round-trip; 1.5s
lines the carve-out up with live LLM tempo so the visitor can't
tell which path served them.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previously only the homepage starters faked a typing stream; the real
concierge answer and the landing turns either burst (server chunks
arrive in chunks, not chars) or popped in fully formed. Visitor could
tell which path served them — starters felt alive, everything else
felt static.

Extracted createTypewriter() inside the component — accepts a target
via push() (grow as chunks arrive, or set once for pre-canned text)
and finalises via finish() once displayed catches up. Routed through
it:

- runQuery: server chunks land on push(), finish() attaches cards.
- runStarter: refactored to use the same helper (one tempo source).
- fireOrganicLanding + pendingLanding effect: text reveals at the
  same typewriter tempo instead of slamming in.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Q1 starter ended with a sentence enumerating every sub-project — too
much text on first contact. Trimmed to the flagship UX Core line in
both en and ru.

Starter think-beat 1.5s → 2.2s. Matches the real concierge round-trip
better when the visitor has just clicked something for the first time.

Card hover (light + dark): dropped the orange border + box-shadow +
translateX. The host-page glow on the matching tile already says
"this is the destination" without the widget echoing the same accent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
findHostMatches did a document-wide querySelectorAll for matching
hrefs, which swept in the widget's own card <a> elements and got
them painted with the host-page glow class (orange border + breathing
shadow). The visitor saw all of their own cards lit up as if they
were "destinations on this page" — which they aren't; they're the
nav surface that points off-widget.

Added isInsideWidget(el) — walks parents until any class starting
with `ks-aux-` is hit — and filters both anchor passes through it.
Card anchors stay calm; real host-page links keep getting the glow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Tore these out chasing a phantom — thought the orange highlight on
all cards was hover/picked bleed when it was actually the host-page
highlighter (findHostMatches) sweeping the widget's own anchors. Real
root cause already fixed in c256772.

Restoring:
- .ks-aux-card:hover (light + dark) — clickable cue.
- .ks-aux-card-picked + badge (light + dark) — shows the last card
  the visitor actually clicked.
- yourPick TEXT key (en) + JSX rendering of the badge.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds PAGE_LANDINGS table — hand-crafted message + cards for /uxcore,
/tools/longevity-protocol, /tools, /ai-atlas, /articles. When the
visitor lands on one of these pages (organic nav OR card-click) and
hasn't seen its curated landing this session, the widget renders the
local turn instead of hitting /api/concierge-landing. Server landing
keeps running on every other page.

Once-per-session via sessionStorage keyed off canonical pathname —
revisits in the same tab get nothing (not curated, not server) so the
visitor isn't nagged. Card click into an already-fired curated page
silently drops the placeholder.

Same typewriter throttle + 2.2s thinking pause as the homepage
starters, so the carve-out reads at the same tempo as a live LLM
round-trip. Cards retain host-page highlight (findHostMatches still
finds matching tiles, including hash anchors like /ai-atlas#terminal).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
On UXCG (and any UX Core family page), a "what do I do here" SPATIAL
turn was getting cards to AI Atlas / Longevity / Articles in the
recommendation slot — the LLM picked them because the candidate pool
shipped every surface card regardless of intent, and UXCG-internal
library hits were thin so the off-family surfaces won by default.

Added a family map (UX Core family = uxcore, uxcg, uxcp, uxcat,
uxcore-api; standalones each their own) and a SPATIAL-only filter on
buildCandidates that drops any surface OR library candidate whose top
namespace isn't in the visitor's family. Empty pool → LLM returns 0
cards, which is the right behaviour on a "go deeper here" turn.

GLOBAL turns ("show me articles", "Longevity") are untouched — visitor
intent still wins and cross-project pivots happen.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…is")

detectIntent only returned 'spatial' when the visitor named the
current section explicitly ("more biases" on /uxcore). The most common
spatial signals — "what should I do here", "what's this", "show me
around", "это" / "что тут" / "глубже про это" — fell through to
'neutral', which meant the SPATIAL filter on the candidate pool
never fired and AI Atlas / Longevity surface cards kept showing up
on /uxcg "what do I do" turns (Wolf flagged earlier today).

Added GENERIC_SPATIAL regex bank — en + ru, covering this/here/where
am I/what should I do/show me/explain this/walk me through/глубже про
это family. Only returns 'spatial' when the page has a known section
(here !== null), so unknown-page neutrals stay neutral.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
On a UXCG question page LightRAG retrieval is often sparse — the page
itself is the answer, neighbouring questions live as separate Strapi
entries that may or may not be indexed. The candidate pool would end
up empty after the family filter, and the visitor got no "go deeper
inside UXCG" path.

New module src/lib/widget/uxcgBridge.ts fetches the full 63-question
catalog from Strapi once per process (en + ru), inverts the
relatedQuestions JSON field into a slug → siblings map, and caches
in-process. Concierge awaits getUxcgBridgeEntry when the visitor is
on /uxcg/<slug> and injects up to 2 siblings as high-score library
candidates so the LLM has real picks even on a cold LightRAG.

Phase 2 will add the question → underlying bias mapping (currently
ambiguous in answer text {{N}} refs and skipped for now).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Panel header now shows "Copilot" wordmark next to the pulsing dot.
- Returning-visitor pill copy: "Your copilot is here" / "Ваш copilot
  тут" — replaces the older "I'm always here" / "Я всегда тут".
- Host-page tile highlight: outline trimmed from 6px → 2px; breathe +
  flash + glow shadow ranges roughly halved. Was overpowering the
  underlying tile; now reads as a hint, not a megaphone.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ngual

Brand-critical "about us" questions ("what is keepsimple", "is it
free", "where do I start", "who's Wolf", "why open-source", "300k
users credibility", "how can I contribute", "how do you make money")
now render a hand-crafted answer locally — no LLM round-trip, no
drift. Same think-pause (2.2s) + typewriter as homepage starters so
the canned path reads like a live response.

Each cluster has a bilingual regex bank + en + ru canned answer + 3
nominated cards. Fires on any page; misses fall through to the real
concierge as before.

Funding cluster reflects Wolf's exact framing: keepsimple is funded
solely from his own pocket. No ads, no paid tier, no investor
pressure.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previous tempo (~215 chars/sec from 3 chars / 14ms) read as firehose,
not as someone composing. Dropped to 1 char per 22ms ≈ 45 chars/sec
— smooth char-by-char reveal across starters, landing turns, and the
real concierge stream. Settle hold bumped to 200ms so cards land
slightly later than the last typed character.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
When the visitor's query is a how-to-use-the-chat meta question, a
one-word ack ("ok", "thanks"), or pure conversational filler, ship
the prose with no cards. Cards on these turns push the visitor
sideways out of what they're already engaging with.

Two layers:
- LLM-side: explicit ZERO-CARDS rule in EN + RU system prompts.
- Server-side: bilingual keyword detector hard-gates the card-
  resolution block (and the bias-mention safety net) when the
  query matches a meta pattern, regardless of what the LLM
  nominated.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The CTA was gated on `isCurrentSpatial`, so any follow-up nav turn
(spurious title swap, in-page hash change, modal route) bumped the
"most recent spatial idx" off the curated UXCAT landing and the
Begin-Test button disappeared, even though the visitor was still
sitting on /uxcat.

Stamp `landingKey` on curated landing turns and gate the CTA on
`turn.landingKey === '/uxcat' && onUxcatRoot`. The button now stays
with the turn that earned it, for as long as the visitor is on the
Awareness Test page.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mirror every Copilot session into our existing Strapi:

- Two new collections, `copilot-sessions` (one row per visitor) and
  `copilot-turns` (one row per event). Spec lives at
  `docs/copilot-analytics-strapi-spec.md` — Strapi admin paste-and-
  create. Both prefixed `copilot-*` and written via a write-only
  token (`STRAPI_COPILOT_TOKEN`) so existing content is untouched.
- Q&A turns logged server-side from inside /api/concierge after the
  response is built. Fire-and-forget; visitor never waits.
- CLEARs and card-clicks posted by the widget to /api/copilot/event
  via sendBeacon (survives unload), forwarded to Strapi from there.
- Auth-link detection on every turn: server reads NextAuth JWT via
  getToken; on first sighting it stamps linkedUser/linkedAt on the
  session row AND emits a kind=auth turn at that exact moment, so we
  can see when in the conversation the visitor signed up.
- Thread id: client-side localStorage, rotated on CLEAR, so transcripts
  naturally split into per-conversation blocks under the same sid.
- Inert when STRAPI_COPILOT_TOKEN is unset (local dev). Never queried
  at build time, so a Strapi outage never blocks deploy.

Wolf reads it in Strapi admin, filtered by env=prod for live
calibration. No custom admin UI in v1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds src/lib/copilotSafety.ts and wires it into /api/concierge plus
the widget-event endpoint. All gates run BEFORE retrieval/LLM so
blocked or at-capacity turns cost us nothing.

1. Daily cost ceiling — COPILOT_DAILY_BUDGET_USD (default $5) gates
   every Q&A turn. Over the cap → polite "at capacity" reply, no
   LLM spend. UTC midnight reset. In-memory counter (single-replica
   Contabo container today).
2. Abuse moderation — one free OpenAI omni-moderation call per
   question. Flagged → polite refusal, no LLM spend, blocked turn
   logged with meta.blocked=true. Fails open when the key is missing
   or the API is down.
3. Prompt-injection hardening — visitor / page / history blocks
   fenced as <question>/<page>/<pageContent>/<history>; new
   INSTRUCTION SAFETY rule in EN + RU system prompts treats anything
   inside those fences as DATA, never instructions.
4. PII scrub on Strapi log — emails, phone-shaped runs, long digit
   sequences masked before they reach Strapi from /api/concierge or
   /api/copilot/event.

All four are env-tunable and degrade gracefully when dependencies
(OpenAI key, Strapi token, budget vars) are missing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Long-form draft on building the Copilot widget — four criteria
(high fidelity, friendly, dirt-cheap, unique) and how LightRAG +
Sonnet 4.6 landed there.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…gres

Strapi was wrong for this — at our user base copilot-turn would balloon
past 100k rows per week and the admin panel would become unreadable.
Move the analytics sink to the copilot-events sibling service (Postgres
16, HTTP ingest at /track), and expand the event taxonomy to cover
visitor MOVEMENTS, not only Q&A.

- src/lib/copilotAnalytics.ts: thin HTTP client to copilot-events
  POST /track; uses COPILOT_EVENTS_URL + COPILOT_EVENTS_WRITE_TOKEN;
  same public exports (ensureSession, logTurn, markAuthLink, bumpThread)
  so call sites in /api/concierge + /api/copilot/event are untouched
  beyond comment refresh. Inert when env unset.
- src/pages/api/copilot/event.ts: accept new kinds (page_view, dwell,
  outbound_click) alongside existing clear / card_click / nav.
- widget: every page entry fires page_view; every exit fires dwell with
  ms-on-page (sealed=true on unload, uses sendBeacon); every outbound
  anchor click fires outbound_click with href + anchor text + target.
- docs: delete copilot-analytics-strapi-spec.md, add
  copilot-analytics-spec.md (Postgres-shaped); rewrite Analytics
  section of widget-architecture.md SSOT.

KeepSimple repo gains zero Postgres deps — DB lives in sibling
container; we're a thin HTTP client.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wolf wanted a way to actually see what's landing in the copilot-events
store without a Postgres CLI. Two server-rendered pages, hard-gated
by NEXT_PUBLIC_ENV — staging and prod return 404, period.

- /admin/copilot-sessions: recent sessions table (env tabs dev/staging/
  prod, last 100), columns: started, sid, lang, event count, thread
  count, linked user, first URL.
- /admin/copilot-sessions/[sid]: per-session detail — header metadata
  + chronological event ribbon with human-readable bodies per kind
  (question/answer transcripts, card-click target, outbound href,
  dwell seconds, page entries, auth, clear).
- src/lib/copilotEventsRead.ts: thin server-side client to the
  copilot-events GET endpoints; uses a separate COPILOT_EVENTS_READ_TOKEN.

Token never crosses to the browser — all fetches happen in
getServerSideProps. Inert (404) when the token isn't set, so the
absence of read access on staging/prod is the safe default.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Header grid had four columns (dot, brand, immersion, CLEAR) but the
collapse chevron was a fifth child — grid spilled it onto a second
row, eating vertical space the chat needs. Bump grid-template-columns
to five and the chevron sits next to CLEAR as intended.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The detail page shows "Session not found" without saying why. When
the list works but the per-sid endpoint fails, the cause is ambiguous
(token? CF route? service error?). Surface the actual fetch outcome
— URL hit, HTTP status, body snippet — under the empty card so the
mismatch is debuggable from the page itself.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…-session puzzle

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
manager and others added 7 commits May 18, 2026 19:36
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Next.js was silently dropping the session + events props because the
object graph from the copilot-events service contained a value Next's
prop serializer wouldn't accept (typically a Date or an undefined-
nested field). Round-tripping through JSON.stringify yields a clean
plain-object/array that the framework forwards unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Next.js kept dropping the session object prop between getServerSideProps
and the page component even after a JSON.parse(JSON.stringify(...))
sanitize. Bypass the framework's prop serializer entirely by stringifying
the whole result into a single 'payload' string prop, then parse on the
page. Pre-build is happy, and the actual transcript renders.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The copilot-events service auto-creates the session row on the first
event of any kind via INSERT ... ON CONFLICT COALESCE, so a dedicated
session_start write was pure noise — every visitor was racking up
dozens of them in the timeline. Carry lang / userAgent / firstUrl on
every track call instead; the service still seeds the session-row
metadata from whichever non-null value lands first.

- session_start removed from EventKind union.
- ensureSession() kept as a no-op shim for any future caller.
- Question / answer / card_click / nav / page_view / dwell /
  outbound_click now all forward the session-row seed fields.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Writer side stopped emitting session_start (1a4bceb), but historic
events still sit in the DB and were rendering as empty rows with just
the timestamp + kind label. Filter the event list before render so the
timeline is clean for sessions that pre-date the writer fix.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Dwell was firing wall-clock time on a tab that may have sat hidden in
the background for hours, producing readings like "spent 7400s on
Copilot sessions — admin". And it was double-firing on title-only
DOM mutations + on both beforeunload and pagehide.

Reshape:
- Dwell now counts only the time document.visibilityState === 'visible'.
  Title-only changes no longer trigger a dwell (URL must actually
  change). Payload field renamed dwellMs → activeMs.
- New event kind 'tab_close' replaces the old "sealed dwell" boolean.
  Single-fire guarded by a sealedRef so beforeunload + pagehide don't
  duplicate.
- Admin renderer detects tab_close → next live event ≥ 60s and inserts
  a "↺ returned 2h 14m later" gap banner.
- Legacy rows still render: dwell falls back to dwellMs when activeMs
  is absent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The /api/copilot/event handler has an explicit switch over event
kinds; tab_close was missing, so the widget's new tab_close events
were 204'd but never forwarded to the copilot-events service. Add
it next to dwell.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@MaryWylde
Copy link
Copy Markdown
Contributor Author

@claude, review this PR.

@claude
Copy link
Copy Markdown

claude Bot commented May 19, 2026

Claude finished @MaryWylde's task in 2m 52s —— View job


PR Review: Feat/global widget enhancements 1

Tasks

  • Gather repository context (CLAUDE.md)
  • Review PR diff and changed files
  • Analyze new library files (copilotAnalytics.ts, copilotEventsRead.ts, copilotSafety.ts, uxcgBridge.ts)
  • Review API changes (concierge.ts, copilot/event.ts)
  • Review admin UI pages (copilot sessions)
  • Review widget changes (AskUxCore.tsx, api.ts)
  • Post comprehensive review

This is a substantial PR (4027 additions) that introduces a full analytics pipeline, safety layer, admin observability UI, and major widget enhancements. The overall architecture is solid and well-commented. Below are issues found, grouped by severity.


🔴 Bugs / Type Errors

1. tab_close missing from CopilotEventKind in widget/src/api.ts

api.ts:316 defines:

export type CopilotEventKind =
  | 'clear' | 'card_click' | 'nav' | 'page_view'
  | 'dwell' | 'outbound_click' | 'auth_probe';

But AskUxCore.tsx calls postCopilotEvent({ kind: 'tab_close', ... }) in multiple places (lines ~1429, ~1509). TypeScript will reject this as 'tab_close' is not assignable to CopilotEventKind. The handler in event.ts:126 does accept it, so the fix is just adding 'tab_close' to the type union.

Fix this →


🟠 Style/Lint Violations (repo rules)

2. Wrong import order in UXCatLayout.tsx

The reorder in src/uxcore/layouts/UXCatLayout/UXCatLayout.tsx moves third-party imports (next/router, react, react-confetti-explosion, react-loading-skeleton) after @uxcore/components/* and @uxcore/data/* entries. The repo's ESLint-enforced order requires:

Third-party → ... → @hooks → @lib → @data → @icons → @components → @layouts

This is backwards and will likely fail the ESLint CI check.

Fix this →

3. Relative imports instead of @lib alias in concierge.ts

src/pages/api/concierge.ts:3–12 uses relative paths:

import { logTurn, markAuthLink } from '../../lib/copilotAnalytics';
import { ..., scrubPii } from '../../lib/copilotSafety';

The repo requires path aliases for cross-folder imports. The new event.ts in the same PR correctly uses @lib/copilotAnalytics. The concierge imports should match.

Fix this →


🟡 Code Quality / Maintainability

4. ensureSession is a silent no-op export — confusing

src/lib/copilotAnalytics.ts:81 exports ensureSession as a backwards-compat shim that does literally nothing. Any call site that calls it believing it seeds a session row will be silently wrong. At minimum, add a @deprecated JSDoc tag or a console.warn in NODE_ENV=development so callers can be found and cleaned up.

5. READ_LIB_REVISION = 'v4' is a debug artifact

src/lib/copilotEventsRead.ts:13: this was clearly added to bust the Next.js dev module cache during development. Keeping a manual 'v4' string in prod code means it will never be bumped correctly and creates false confidence. Remove it or replace with a build-time constant.

6. auth_probe in CopilotEventKind is a ghost entry

widget/src/api.ts:316: auth_probe is in the type union but event.ts has no case for it — it falls through to default: (no-op). It's either a placeholder that should have a // TODO comment, or it should be removed from the type until implemented.

7. debug field in getSessionDetail may expose service internals

src/lib/copilotEventsRead.ts:79 builds debug strings containing the full request URL, status codes, and raw response body. These are rendered on the admin page, which is dev-only gated. But the function itself has no scope restriction — any future caller outside the admin page would get a return value with an internal URL embedded in a string field. Consider keeping the debug info as a separate optional field and marking it clearly as admin-only.


🔵 Architecture / Design Notes

8. In-memory budget counter won't survive restarts

src/lib/copilotSafety.ts:16: dailyCalls is a module-level Map. The comment acknowledges "container restarts reset it." This is fine as documented, but if the container is restarted mid-day (deploy, OOM kill) the counter resets and the day's prior spend isn't counted. In a low-traffic scenario this is acceptable, but it's worth noting explicitly that the cap is "soft" — it only applies within a single process lifetime.

9. concierge.ts is now ~1900 lines

The file has grown substantially (was ~800 lines before this PR, now ~1900). The new functions isMetaTurn, inSameFamily, topSegment, familyOf, PROJECT_FAMILIES are good candidates to extract to src/lib/widget/ utilities alongside pageIdentity.ts for testability and to keep concierge.ts manageable.

10. Cookie missing Secure flag in event.ts

src/pages/api/copilot/event.ts:35:

`${COOKIE_NAME}=${sid}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${COOKIE_MAX_AGE}`

The Secure flag is absent. In an HTTPS-only production environment this is low-risk, but it's good hygiene to add Secure so the cookie isn't accidentally transmitted over plain HTTP (e.g., during local dev tunnels).

11. pageIdentity.ts: "200+" → "100+" factual change

src/lib/widget/pageIdentity.ts changes the UX Core bias count from "200+" to "100+" in the page blurb. Verify this reflects the actual current content count — the change is subtle and easy to miss in a large PR.


✅ What's Working Well

  • Safety layer design: fail-open moderation (outage doesn't block widget), budget cap with clean UTC-day reset, PII scrub applied at every analytics boundary — solid defensive posture.
  • Prompt injection fencing: the fence() wrapping + bilingual system-prompt hardening is a meaningful improvement.
  • Thread ID architecture: localStorage-persisted, rotated on CLEAR, passed to server — clean separation from the HTTP-only sid cookie.
  • Visibility-aware dwell tracking: using visibilityState correctly avoids inflated dwell time from backgrounded tabs.
  • sendBeacon fallback in postCopilotEvent: good choice for page-unload events.
  • UXCG sibling bridge: sensible first pass with a clear Phase 2 callout for bias→question inversion.
  • Admin pages are properly dev-gated: isDevHost() returns { notFound: true } in prod — no access risk.
  • HOMEPAGE_STARTERS / PAGE_LANDINGS: zero-latency, zero-hallucination first impressions are a good UX investment.

Summary: The critical fix needed before merging is the tab_close missing from CopilotEventKind (item 1 — TypeScript error). Items 2 and 3 (ESLint import order) should also be resolved to pass CI. Items 4–7 are code quality improvements that can be addressed in a follow-up.

};
let inFlight: Promise<void> | null = null;

async function fetchQuestionsForLang(
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@claude shouldn't this be in api/ folder?

Copy link
Copy Markdown

@claude claude Bot May 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude finished @MaryWylde's task in 34s —— View job


No — src/lib/widget/uxcgBridge.ts is correctly placed. Here's why:

Why it shouldn't be in src/pages/api/

src/pages/api/ is reserved for Next.js API route handlers — files that export a default handler(req, res) function that the framework routes HTTP requests to. If uxcgBridge.ts were placed there, Next.js would try to expose it as an HTTP endpoint (e.g. GET /api/uxcgBridge) and serve a 500 because there's no default export handler.

uxcgBridge.ts is a server-side library module, not a route handler. It:

  • Fetches and caches UXCG question data from Strapi at process startup
  • Exports getUxcgBridgeEntry(slug, lang) — a utility function consumed by concierge.ts
  • Holds module-level in-memory state (cached, inFlight) that would be wrong to expose as an HTTP endpoint

The right analogy: copilotAnalytics.ts and copilotSafety.ts live in src/lib/ for the same reason — they're server-side utilities consumed by API routes, not routes themselves.

Is src/lib/widget/ the right sub-folder?

That's a reasonable follow-up question. It's currently grouped with pageIdentity.ts under src/lib/widget/, which is fine since it's widget-context logic. An equally valid home would be src/lib/ root (alongside copilotAnalytics.ts) since the module serves the concierge route, not the widget bundle. Either location works — the important thing is it stays out of src/pages/api/.


margin: 40px auto;
padding: 0 24px;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@claude, what is this? Do we have this font?

Copy link
Copy Markdown

@claude claude Bot May 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude finished @MaryWylde's task in 1m 6s —— View job


Tasks

  • Gather context
  • Read index.module.scss to identify fonts at lines 6 and 96
  • Check if those fonts exist elsewhere in the project
  • Post findings

Fonts in index.module.scss

Line 6font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;

This is the standard system UI font stack — not a custom font and not something that needs to be installed or imported. It resolves to whatever the OS provides:

OS Font rendered
macOS / iOS San Francisco (-apple-system / BlinkMacSystemFont)
Windows Segoe UI
Android / Linux Roboto
Everything else fallback sans-serif

Zero new dependencies — no @font-face, no Google Fonts, no file download.


Line 96font-family: ui-monospace, SFMono-Regular, Menlo, monospace;

Same idea — a system monospace font stack:

OS Font rendered
macOS (modern) SF Mono (ui-monospaceSFMono-Regular)
macOS (older) Menlo
Everything else fallback monospace

Again, zero new dependencies. Also used consistently at lines 131, 205, and 228 in the same file for monospace-formatted content (session IDs, event payloads, etc.).


Are these consistent with the project?

The main site uses custom loaded fonts (Sarabun, Aldrich, Tomorrow, Cormorant Garamond, Source Serif, etc.) registered via @font-face in globals.scss. The admin pages deliberately don't use those — system fonts are correct here because:

  1. The admin pages are dev-only internal tools (isDevHost() gate) — they don't need to match the public brand aesthetic.
  2. System fonts load instantly, have no FOUT risk, and are more readable at small UI sizes (13px table rows, 12px mono labels).
  3. This is the same pattern GitHub, Vercel, and most dev-tool UIs use for their dashboards.

Short answer: both fonts are fine and require nothing extra. They are intentionally different from the site's brand fonts, which is the right call for an admin tool.

}

.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@claude, do we have this font, or is it something extra added?

Copy link
Copy Markdown

@claude claude Bot May 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude finished @MaryWylde's task in 1m 0s —— View job


Font check: index.module.scss lines 6 & 96

Tasks

  • Read index.module.scss
  • Check project font definitions
  • Post findings

Both fonts referenced in this file are OS/browser system font stacks — no installation, @font-face declaration, or import is needed. They are not extra dependencies.

Line 6 (.wrap):

font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;

This is the standard system UI font stack — resolves to San Francisco on macOS/iOS, Segoe UI on Windows, Roboto on Android. It's a CSS convention, not a custom font.

Line 96 (.mono):

font-family: ui-monospace, SFMono-Regular, Menlo, monospace;

This is the standard system monospace stack — resolves to SF Mono on macOS, Cascadia Code or Consolas on Windows. Again, no file needed.


Neither font needs to be added to the project. They're zero-overhead system fonts.

That said, these are not from the keepsimple design system. The rest of the site uses custom fonts declared in src/styles/globals.scss (Sarabun, Source-Serif, IBM Plex Mono, etc.). The admin page bypasses those and uses raw system fonts — which is intentional since the admin pages are dev-only internal tools (isDevHost() guard) and don't need to match the public-facing design system.

If you'd prefer the admin pages to use the project's design tokens (e.g. IBM Plex Mono for monospace, Sarabun for body), that would be a style consistency improvement but is not a bug.

@keepsimpleio keepsimpleio deleted a comment from claude Bot May 19, 2026
- widget/api.ts: add `tab_close` to CopilotEventKind, drop unused
  `auth_probe` ghost entry that had no handler.
- concierge.ts: rewrite relative imports to use `@lib` aliases;
  extract `isMetaTurn`, `PROJECT_FAMILIES`, `topSegment`, `familyOf`,
  `inSameFamily` to `src/lib/widget/conciergeHelpers.ts`.
- copilotAnalytics.ts: delete `ensureSession` no-op shim — it had no
  callers and silently misled anyone who tried to use it.
- copilotEventsRead.ts: drop `READ_LIB_REVISION` debug constant and
  its admin-page usage; document `getSessionDetail` debug field as
  admin-only since it embeds the internal service URL.
- copilotSafety.ts: document the daily budget cap as a *soft*
  in-process counter that resets on container restart.
- copilot/event.ts + concierge.ts: add `Secure` flag to the session
  cookie when the request is HTTPS (detected via x-forwarded-proto
  or socket.encrypted); kept off for local http dev.
- .gitignore: ignore `docker-compose.dev.yml`, `docker-compose.override.yml`,
  and `/docs/` going forward. Remove `docker-compose.dev.yml` from
  tracking (was committed by accident on this branch).
- AGENTS.md: add "Commit Hygiene" section so future agents audit
  `git status` before pushing and drop personal/local files instead
  of carrying them into PRs.

UXCatLayout import order: verified clean against `eslint-plugin-simple-import-sort`
config (no reorder needed; review's claim was based on a different
ordering than what the rule actually enforces).

`pageIdentity.ts` "100+ biases" copy: matches the canonical
description used elsewhere in the project, no further change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@MaryWylde MaryWylde merged commit b2d7c5b into dev May 19, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants