From d501ccdc34c931528856e90d1c6f8bf3df9cb1b2 Mon Sep 17 00:00:00 2001 From: MaryWylde Date: Tue, 30 Jun 2026 15:20:14 +0200 Subject: [PATCH 1/8] library: scope tags per-account, add create-library gating, and normalize field/control sizing Filter tags by the owning user so accounts no longer see each other's tags, and surface them in guest mode from the populated objects. Add a Create Library button on the home page and disable both it and the dropdown's My Library entry when the user can neither create nor already owns a library. Route library cards by id to sidestep the username->id filter the API currently 500s on. Standardize library inputs, buttons, date picker, and dropdown to a 44px height with 16px input text, plus assorted toolbar, overview, and step-indicator polish. Co-Authored-By: Claude Opus 4.7 --- src/api/library/tag/getTagsList.ts | 16 ++++- src/components/Header/Header.tsx | 21 ++++++ src/components/UserProfile/UserProfile.tsx | 17 ++++- .../molecules/Button/Button.module.scss | 2 + .../DatePicker/DatePicker.module.scss | 4 +- .../molecules/Dropdown/Dropdown.module.scss | 3 +- .../library/molecules/Input/Input.module.scss | 3 +- .../StepIndicator/StepIndicator.module.scss | 1 + .../AddObjectModal/AddObjectModal.tsx | 4 +- .../organisms/LibraryCard/LibraryCard.tsx | 6 +- .../LibraryToolbar/LibraryToolbar.module.scss | 6 ++ .../LibraryToolbar/LibraryToolbar.tsx | 4 +- .../ObjectOverviewModal.module.scss | 5 -- .../ObjectOverviewModal.tsx | 2 - .../library/organisms/Sidebar/Sidebar.tsx | 17 +++-- src/layouts/library/Home/Home.module.scss | 23 +++++++ src/layouts/library/Home/Home.tsx | 69 ++++++++++++++++--- .../library/Library/Library.module.scss | 1 - 18 files changed, 167 insertions(+), 37 deletions(-) diff --git a/src/api/library/tag/getTagsList.ts b/src/api/library/tag/getTagsList.ts index 956a4718..42946ced 100644 --- a/src/api/library/tag/getTagsList.ts +++ b/src/api/library/tag/getTagsList.ts @@ -6,9 +6,21 @@ export interface GetTagsListResponse { data: ITag[]; } -export const getTagsList = async (): Promise => { +// Tags are owner-scoped: each is stamped with `user` on create. The default +// GET /api/tags returns every account's tags, so always filter by the current +// user's id. Without an id there's nothing safe to return — refuse rather than +// fall back to the unscoped list, which would leak other accounts' tags. +export const getTagsList = async ( + userId?: number | string, +): Promise => { + if (userId == null || userId === '') { + return { data: [] }; + } + try { - const { data } = await axiosInstance.get('/api/tags'); + const { data } = await axiosInstance.get('/api/tags', { + params: { 'filters[user][id][$eq]': userId }, + }); return data; } catch (error) { diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index 4d7814a2..31668f65 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -17,6 +17,7 @@ import type { TRouter } from '@local-types/global'; import useGlobals from '@hooks/useGlobals'; import { useIsWidthLessThan } from '@hooks/useScreenSize'; +import { getMyLibrary } from '@api/library/getMyLibrary'; import { userInfoUpdate } from '@api/settings'; import { getMyInfo } from '@api/strapi'; @@ -51,6 +52,24 @@ const Header: FC = () => { const canCreateLibrary = accountData?.featureNames?.includes('can-create-library') ?? false; + // "My Library" is only reachable once a library exists, or could be + // bootstrapped by a flag-holder. With neither, the user has no library page, + // so the dropdown item is disabled. Check via the owner-scoped lookup. + const [hasLibrary, setHasLibrary] = useState(false); + useEffect(() => { + if (!accountData?.id) { + setHasLibrary(false); + return; + } + let cancelled = false; + getMyLibrary(accountData.id).then(lib => { + if (!cancelled) setHasLibrary(lib !== null); + }); + return () => { + cancelled = true; + }; + }, [accountData?.id]); + useEffect(() => { const storedToken = localStorage.getItem('accessToken'); setToken(storedToken); @@ -167,6 +186,7 @@ const Header: FC = () => { userImage={accountData?.picture} handleOpenSettings={handleOpenSettings} canCreateLibrary={canCreateLibrary} + hasLibrary={hasLibrary} hideDropdown={isOpenedSidebar} hideUsername /> @@ -247,6 +267,7 @@ const Header: FC = () => { userImage={accountData?.picture} handleOpenSettings={handleOpenSettings} canCreateLibrary={canCreateLibrary} + hasLibrary={hasLibrary} /> )} diff --git a/src/components/UserProfile/UserProfile.tsx b/src/components/UserProfile/UserProfile.tsx index 807aab52..70f09deb 100644 --- a/src/components/UserProfile/UserProfile.tsx +++ b/src/components/UserProfile/UserProfile.tsx @@ -20,6 +20,7 @@ type UserProfileProps = { hideDropdown?: boolean; hideUsername?: boolean; canCreateLibrary?: boolean; + hasLibrary?: boolean; setAccountData?: (updater: (prev: boolean) => boolean) => void; setOpenLoginModal?: (openModal: boolean) => void; handleOpenSettings?: () => void; @@ -54,6 +55,7 @@ const UserProfile: FC = ({ hideDropdown, hideUsername, canCreateLibrary, + hasLibrary, setAccountData, setOpenLoginModal, handleOpenSettings, @@ -82,10 +84,15 @@ const UserProfile: FC = ({ handleOpenSettings?.(); }, [handleOpenSettings]); + // With neither an existing library nor create permission, the user has no + // library page to open, so the item is inert. + const myLibraryDisabled = !hasLibrary && !canCreateLibrary; + const handleMyLibrary = useCallback(() => { + if (myLibraryDisabled) return; setIsDropdownOpen(false); router.push(`/library/${username}`); - }, [router, username]); + }, [router, username, myLibraryDisabled]); // A library has no standalone create step — it's bootstrapped on the owner's // own page once they add content (gated server-side by the same feature @@ -172,7 +179,13 @@ const UserProfile: FC = ({ {isDropdownOpen && isAccessTokenExist && (
e.stopPropagation()}> {username && ( -
+
{ let cancelled = false; - getTagsList().then(res => { + getTagsList(accountData?.id).then(res => { if (cancelled) return; const opts: TagOption[] = res.data.map(t => ({ id: t.id, @@ -293,7 +293,7 @@ export function AddObjectModal(props: AddObjectModalProps): JSX.Element { return () => { cancelled = true; }; - }, []); + }, [accountData?.id]); // Preset the object's existing tags exactly once, from the object's OWN // populated tag data — not by filtering the fetched options. An unpublished diff --git a/src/components/library/organisms/LibraryCard/LibraryCard.tsx b/src/components/library/organisms/LibraryCard/LibraryCard.tsx index d131d08a..d4338cd3 100644 --- a/src/components/library/organisms/LibraryCard/LibraryCard.tsx +++ b/src/components/library/organisms/LibraryCard/LibraryCard.tsx @@ -24,7 +24,11 @@ export function LibraryCard(props: LibraryCardProps): JSX.Element { const router = useRouter(); const handleViewLibrary = () => { - router.push(`/library/${username ?? id}`); + // Route by numeric id, not username: the route resolver short-circuits a + // numeric param to a findOne-by-id, sidestepping the username→id filter + // lookup that the public API currently 500s on. Falls back to username only + // if an id is somehow absent. + router.push(`/library/${id ?? username}`); }; return ( diff --git a/src/components/library/organisms/LibraryToolbar/LibraryToolbar.module.scss b/src/components/library/organisms/LibraryToolbar/LibraryToolbar.module.scss index 655d412b..c822ecde 100644 --- a/src/components/library/organisms/LibraryToolbar/LibraryToolbar.module.scss +++ b/src/components/library/organisms/LibraryToolbar/LibraryToolbar.module.scss @@ -43,6 +43,12 @@ } } +// Guest view of someone else's library: the divider is dropped, so restore the +// vertical breathing room on the controls row itself. +.controlsGuest { + padding: 24px 10px; +} + // Visitor / guest-preview banner that replaces the owner's shelf controls. .welcome { display: flex; diff --git a/src/components/library/organisms/LibraryToolbar/LibraryToolbar.tsx b/src/components/library/organisms/LibraryToolbar/LibraryToolbar.tsx index 6c44ff3c..4e475029 100644 --- a/src/components/library/organisms/LibraryToolbar/LibraryToolbar.tsx +++ b/src/components/library/organisms/LibraryToolbar/LibraryToolbar.tsx @@ -298,9 +298,7 @@ export function LibraryToolbar(props: LibraryToolbarProps): JSX.Element { if (!isOwner) { return (
-
- -
+
( ))}
diff --git a/src/components/library/organisms/Sidebar/Sidebar.tsx b/src/components/library/organisms/Sidebar/Sidebar.tsx index ec2dae80..35fc9ebc 100644 --- a/src/components/library/organisms/Sidebar/Sidebar.tsx +++ b/src/components/library/organisms/Sidebar/Sidebar.tsx @@ -172,7 +172,7 @@ export function Sidebar() { (isMyLibrary ? accountData?.picture : undefined); const aboutAuthorText = stripHtml(currentOwner?.aboutMe); - // Owner sees their full, editable tag palette; a visitor sees the tags + // Owner sees their full tag palette; a true visitor sees only the tags // actually used on this library's objects — no cross-account tag fetch. const libraryTags = useMemo(() => { const byName = new Map(); @@ -186,7 +186,10 @@ export function Sidebar() { return Array.from(byName.values()); }, [currentShelves]); - const displayedTags = canEdit + // Show the owner's palette whenever it's their own library — including guest + // mode (only an owner can toggle that, so we always have their tags loaded). + // Editing stays gated on `canEdit`, so guest preview shows them read-only. + const displayedTags = isMyLibrary ? tags.map(t => ({ name: t.attributes.name, color: t.attributes.color })) : libraryTags; @@ -228,7 +231,7 @@ export function Sidebar() { }; await createTag(body); - const { data } = await getTagsList(); + const { data } = await getTagsList(accountData?.id); setTags(data); } catch (error) { @@ -251,7 +254,7 @@ export function Sidebar() { }; await updateTag(selectedTag.id, body); - const { data } = await getTagsList(); + const { data } = await getTagsList(accountData?.id); setTags(data); setIsOpenTagModal(null); @@ -267,7 +270,7 @@ export function Sidebar() { try { await deleteTag(selectedTag.id); - const { data } = await getTagsList(); + const { data } = await getTagsList(accountData?.id); setTags(data); setIsOpenTagModal(null); @@ -308,13 +311,13 @@ export function Sidebar() { // fresh page load until the user mutates a tag. useEffect(() => { let cancelled = false; - getTagsList().then(({ data }) => { + getTagsList(accountData?.id).then(({ data }) => { if (!cancelled) setTags(data); }); return () => { cancelled = true; }; - }, [setTags]); + }, [setTags, accountData?.id]); // Hide the right panel entirely when the owner lacks permission to create a // library — the page shows only the centered no-permission message. diff --git a/src/layouts/library/Home/Home.module.scss b/src/layouts/library/Home/Home.module.scss index 0a14f38d..0590437e 100644 --- a/src/layouts/library/Home/Home.module.scss +++ b/src/layouts/library/Home/Home.module.scss @@ -60,6 +60,18 @@ } } + .searchGroup { + display: flex; + align-items: center; + gap: 12px; + + @media (max-width: 590px) { + width: 100%; + flex-direction: column; + align-items: stretch; + } + } + .input { max-width: 348px; border-radius: 6px; @@ -77,6 +89,17 @@ top: 9px; } } + + .createButton { + flex-shrink: 0; + white-space: nowrap; + + // The shared plus icon ships a hardcoded brown fill, which disappears on + // the brown primary button — force it (and the label) white. + svg path { + fill: var(--white); + } + } } .content { diff --git a/src/layouts/library/Home/Home.tsx b/src/layouts/library/Home/Home.tsx index aaa63195..40c8a29d 100644 --- a/src/layouts/library/Home/Home.tsx +++ b/src/layouts/library/Home/Home.tsx @@ -1,10 +1,15 @@ import { mapStrapiLibrariesResponseToCards } from '@utils/library/mapStrapiLibraries'; +import { useRouter } from 'next/router'; import React, { useEffect, useMemo, useState } from 'react'; import type { HomeLibraryCardView } from '@local-types/library/library'; import { getLibrariesPaginated } from '@api/library/getLibrariesPaginated'; +import { getMyLibrary } from '@api/library/getMyLibrary'; +import PlusIcon from '@icons/library/svg/plus.svg'; + +import { useAuth } from '@components/Context/library/AuthContext'; import { Text, TypographyVariant } from '@components/library/atoms/Text'; import { AboutLibraryModal } from '@components/library/molecules/AboutLibraryModal'; import { @@ -26,6 +31,41 @@ const sectionId = 'libraries-section'; const perPage = 6; export function HomeTemplate({ data: dataOverride }: HomeTemplateProps) { + const router = useRouter(); + const { accountData } = useAuth(); + + // Creating a library is gated by the `can-create-library` feature flag from + // GET /api/users/me — the same gate the user dropdown's "Create library" item + // uses. A library has no standalone create step: it's bootstrapped on the + // owner's own page, so the button just routes there when the flag is present. + const canCreateLibrary = + accountData?.featureNames?.includes('can-create-library') ?? false; + + // A user may create at most one library, so the button is also disabled once + // they already own one. Check via the owner-scoped lookup the library page + // uses, not the home grid (which is paginated and may not include theirs). + const [hasLibrary, setHasLibrary] = useState(false); + useEffect(() => { + if (!accountData?.id) { + setHasLibrary(false); + return; + } + let cancelled = false; + getMyLibrary(accountData.id).then(lib => { + if (!cancelled) setHasLibrary(lib !== null); + }); + return () => { + cancelled = true; + }; + }, [accountData?.id]); + + const createDisabled = !canCreateLibrary || hasLibrary; + + const handleCreateLibrary = () => { + if (createDisabled || !accountData?.username) return; + router.push(`/library/${accountData.username}`); + }; + const [value, setValue] = useState(''); const [debouncedQuery, setDebouncedQuery] = useState(''); const [isOpen, setIsOpen] = useState(false); @@ -199,15 +239,26 @@ export function HomeTemplate({ data: dataOverride }: HomeTemplateProps) { > Libraries - setValue('')} - wrapperClassName={styles.input} - /> +
+ setValue('')} + wrapperClassName={styles.input} + /> +
diff --git a/src/layouts/library/Library/Library.module.scss b/src/layouts/library/Library/Library.module.scss index d53f7abe..0613f127 100644 --- a/src/layouts/library/Library/Library.module.scss +++ b/src/layouts/library/Library/Library.module.scss @@ -3,7 +3,6 @@ min-width: 0; @media (max-width: 1024px) { - padding: 16px; min-height: calc(100vh - 168px); } From 58121040b99da61d8f3dfb722d0a90a606fc7733 Mon Sep 17 00:00:00 2001 From: MaryWylde Date: Tue, 30 Jun 2026 15:20:37 +0200 Subject: [PATCH 2/8] docs: auto-import AGENTS.md into CLAUDE.md so it loads automatically Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index a07a15aa..7380e021 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,9 @@ MemPalace wing: `keepsimple` (protocol lives in `~/.claude/CLAUDE.md`). -Human-readable agent guidelines live in `AGENTS.md` next to this file; this file is the machine-facing version. See `AGENTS.md` for repo conventions, build/test commands, and contribution rules. +Human-readable agent guidelines live in `AGENTS.md` next to this file; this file is the machine-facing version. See `AGENTS.md` for repo conventions, build/test commands, and contribution rules — imported below so it loads automatically. + +@AGENTS.md ## Code search — prefer CodeGraph over Grep From b9e28cd4e3b32702a63374971476ef26d9bf1923 Mon Sep 17 00:00:00 2001 From: MaryWylde Date: Wed, 15 Jul 2026 16:19:50 +0200 Subject: [PATCH 3/8] library: self-host Source Sans Pro for tags and polish sidebar/toolbar - Tag labels now use self-hosted Source Sans Pro (400) via a new @font-face and the --font-source-sans token, overriding the inherited serif face - Sidebar: hide the About divider when there is no About text, drop the fixed height on Edit buttons (keep padding 4px 8px), and show a "No tags yet." empty state at 50px - LibraryToolbar: remove the owner-view divider, preserving its 16px spacing on the controls row Co-Authored-By: Claude Opus 4.7 --- .../SourceSansPro/SourceSansPro-Regular.woff2 | Bin 0 -> 13036 bytes .../library/molecules/Tag/Tag.module.scss | 3 +++ .../LibraryToolbar/LibraryToolbar.module.scss | 21 +++++++----------- .../LibraryToolbar/LibraryToolbar.tsx | 2 -- .../organisms/Sidebar/Sidebar.module.scss | 12 +++++++++- .../library/organisms/Sidebar/Sidebar.tsx | 14 ++++++++++-- src/styles/globals.scss | 9 ++++++++ src/styles/library/variables.scss | 2 ++ 8 files changed, 45 insertions(+), 18 deletions(-) create mode 100644 public/fonts/SourceSansPro/SourceSansPro-Regular.woff2 diff --git a/public/fonts/SourceSansPro/SourceSansPro-Regular.woff2 b/public/fonts/SourceSansPro/SourceSansPro-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..e49928e8297a96a91c41824c0362e354e6a5c867 GIT binary patch literal 13036 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 0,1,1) so tag labels + // use the sans face. + font-family: var(--font-source-sans); } .removeButton { diff --git a/src/components/library/organisms/LibraryToolbar/LibraryToolbar.module.scss b/src/components/library/organisms/LibraryToolbar/LibraryToolbar.module.scss index c822ecde..7fa63695 100644 --- a/src/components/library/organisms/LibraryToolbar/LibraryToolbar.module.scss +++ b/src/components/library/organisms/LibraryToolbar/LibraryToolbar.module.scss @@ -12,24 +12,14 @@ } } -.divider { - width: 100%; - height: 1px; - background: var(--black-100); - margin: 16px 0; - - // Mobile: collapse the top margin so the divider connects to the fixed - // header (paired with the layout wrapper dropping its top padding). - @media (max-width: 768px) { - margin-top: 0; - } -} - .controls { display: flex; align-items: center; justify-content: space-between; gap: 24px; + // Replaces the removed divider's 16px top margin so the controls keep their + // breathing room below the global header. + margin-top: 16px; animation: toolbar-fade 0.3s ease; // On phones everything crammed onto one row collapsed the flex:1 pill @@ -40,12 +30,17 @@ flex-direction: column; align-items: stretch; gap: 12px; + // Collapse the top margin on mobile so the controls connect to the fixed + // header (paired with the layout wrapper dropping its top padding), matching + // the removed divider's mobile behavior. + margin-top: 0; } } // Guest view of someone else's library: the divider is dropped, so restore the // vertical breathing room on the controls row itself. .controlsGuest { + margin-top: 0; padding: 24px 10px; } diff --git a/src/components/library/organisms/LibraryToolbar/LibraryToolbar.tsx b/src/components/library/organisms/LibraryToolbar/LibraryToolbar.tsx index 4e475029..b3be94e9 100644 --- a/src/components/library/organisms/LibraryToolbar/LibraryToolbar.tsx +++ b/src/components/library/organisms/LibraryToolbar/LibraryToolbar.tsx @@ -332,8 +332,6 @@ export function LibraryToolbar(props: LibraryToolbarProps): JSX.Element { return (
-
-
{isReordering ? 'Drag to reorder →' : 'Jump to →'} diff --git a/src/components/library/organisms/Sidebar/Sidebar.module.scss b/src/components/library/organisms/Sidebar/Sidebar.module.scss index af36f9bb..f661b41d 100644 --- a/src/components/library/organisms/Sidebar/Sidebar.module.scss +++ b/src/components/library/organisms/Sidebar/Sidebar.module.scss @@ -87,7 +87,8 @@ .button { width: auto; - padding: 3px 8px; + height: auto; + padding: 4px 8px; } .text { @@ -132,6 +133,15 @@ display: flex; flex-wrap: wrap; + &.tagsEmpty { + height: 50px; + align-items: center; + } + + .emptyTags { + color: var(--gray-medium); + } + .button { color: var(--brown); height: 26px; diff --git a/src/components/library/organisms/Sidebar/Sidebar.tsx b/src/components/library/organisms/Sidebar/Sidebar.tsx index 35fc9ebc..4980a73c 100644 --- a/src/components/library/organisms/Sidebar/Sidebar.tsx +++ b/src/components/library/organisms/Sidebar/Sidebar.tsx @@ -171,6 +171,9 @@ export function Sidebar() { resolveStrapiUrl(currentOwner?.avatar) ?? (isMyLibrary ? accountData?.picture : undefined); const aboutAuthorText = stripHtml(currentOwner?.aboutMe); + const aboutLibraryText = stripHtml( + currentLibrary?.attributes.libraryDetails?.aboutLibrary, + ); // Owner sees their full tag palette; a true visitor sees only the tags // actually used on this library's objects — no cross-account tag fetch. @@ -397,7 +400,7 @@ export function Sidebar() { ''}
-
+ {aboutLibraryText &&
}
Total objects: @@ -468,7 +471,14 @@ export function Sidebar() { )}
-
+
+ {displayedTags.length === 0 && ( + No tags yet. + )} {displayedTags.map(tag => ( ))} diff --git a/src/styles/globals.scss b/src/styles/globals.scss index 8056fa45..650ac572 100644 --- a/src/styles/globals.scss +++ b/src/styles/globals.scss @@ -267,6 +267,15 @@ body { font-display: block; } +@font-face { + font-family: 'Source Sans Pro'; + src: url('/keepsimple_/fonts/SourceSansPro/SourceSansPro-Regular.woff2') + format('woff2'); + font-weight: 400; + font-style: normal; + font-display: block; +} + // Aldrich is being used in Company Management(new) @font-face { font-family: 'Aldrich'; diff --git a/src/styles/library/variables.scss b/src/styles/library/variables.scss index 2be45c5f..5d0d8f46 100644 --- a/src/styles/library/variables.scss +++ b/src/styles/library/variables.scss @@ -113,5 +113,7 @@ /* Fonts — the upstream library injected these via next/font on ; here we declare them directly with web-safe fallbacks. */ --font-source-serif: 'Source Serif 4', Georgia, 'Times New Roman', serif; + --font-source-sans: + 'Source Sans Pro', system-ui, -apple-system, Arial, sans-serif; --font-lato: 'Lato', system-ui, -apple-system, Arial, sans-serif; } From acce4b463267a955e56f64032d959f090cca8af0 Mon Sep 17 00:00:00 2001 From: MaryWylde Date: Wed, 15 Jul 2026 16:58:45 +0200 Subject: [PATCH 4/8] ci: harden Claude PR review workflow, mirroring agents-forge-frontend Replace the stock code-review plugin config with a tailored setup: concurrency cancellation, draft/fork skip, scoped permissions, a 15m timeout, track_progress, and a KeepSimpleOSS-specific review prompt (Pages Router, SCSS tokens, no App Router/Tailwind, UX Core data guard). Lock --allowedTools to the inline-comment tool to resist prompt injection from untrusted PR content. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/claude-code-review.yml | 101 +++++++++++++++++------ 1 file changed, 77 insertions(+), 24 deletions(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 21cb993f..b0f3b52d 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -1,44 +1,97 @@ -name: Claude Code Review +name: Claude PR Review on: pull_request: types: [opened, synchronize, ready_for_review, reopened] - # Optional: Only run on specific file changes - # paths: - # - "src/**/*.ts" - # - "src/**/*.tsx" - # - "src/**/*.js" - # - "src/**/*.jsx" + +# Only review the latest push: rapid pushes to the same PR would otherwise +# spawn overlapping runs that race on the tracking comment and burn +# Max-subscription quota. +concurrency: + group: claude-review-${{ github.event.pull_request.number }} + cancel-in-progress: true jobs: claude-review: - # Optional: Filter by PR author - # if: | - # github.event.pull_request.user.login == 'external-contributor' || - # github.event.pull_request.user.login == 'new-developer' || - # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' - + # Skip draft PRs, and skip fork PRs: forked pull_request runs get no + # repo secrets and a read-only GITHUB_TOKEN, so the job would fail + # loudly on auth instead of skipping cleanly. + if: >- + github.event.pull_request.draft == false && + github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: read pull-requests: write - issues: write - id-token: write - + actions: read steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 1 - - name: Run Claude Code Review - id: claude-review + - name: Claude PR Review uses: anthropics/claude-code-action@v1 with: + # Authenticates against Mary's Claude Max subscription (OAuth token, + # not an API key). Secret set at repo level: CLAUDE_CODE_OAUTH_TOKEN. claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' - plugins: 'code-review@claude-code-plugins' - prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://code.claude.com/docs/en/cli-reference for available options + # Use the workflow's own token so we don't need to install the + # third-party Claude GitHub App on the org. Requires the job + # permissions block below (pull-requests: write). + github_token: ${{ secrets.GITHUB_TOKEN }} + + # Live progress checklist comment on the PR while reviewing. + track_progress: true + + prompt: | + REPO: ${{ github.repository }} + PR NUMBER: ${{ github.event.pull_request.number }} + + You are reviewing the KeepSimpleOSS codebase: a Next.js **Pages + Router** app (React 19, TypeScript with strict off), styled with + SCSS Modules. See AGENTS.md for the full conventions. + + Review this PR and focus on: + + 1. Correctness & React best practices + - Hooks rules, effect dependencies, stale closures + - Unnecessary re-renders, missing keys, prop drilling + - SSR/hydration safety: no `window`/`localStorage`/`document` + at module top level (guard in effects or use ssr:false) + 2. TypeScript quality + - Avoid `any`, prefer precise types, exhaustive unions + 3. Project conventions (AGENTS.md) — flag violations: + - App Router patterns (`'use client'`, `next/navigation`, + `src/app/`) + - Tailwind, styled-components, CSS-in-JS, or inline styles + - New state libraries (Redux, Zustand, Jotai, SWR, React Query) + - Global CSS imported anywhere except `_app.tsx` + - `` instead of importing SVGs as components + - Named exports from `index.ts` barrels, or empty barrels + - Import-order / path-alias violations + - Changes to UX Core bias data, slugs, or schema (these need + explicit approval — flag, don't wave through) + 4. Accessibility & UX + - Semantic HTML, aria attributes, keyboard nav + 5. Security + - XSS via dangerouslySetInnerHTML, unsanitized input, + leaked secrets/env, unsafe URL handling + 6. Styling + - SCSS module hygiene; no hardcoded colors/spacing/breakpoints + that bypass the design tokens (keepsimple-style) + + Leave inline comments for specific issues via the inline-comment + tool. Put your overall assessment and any praise in the tracking + comment summary. Be concise and actionable; skip nitpicks that a + linter would catch. + + # Only the PR/commit-scoped inline-comment tool is granted. We + # deliberately do NOT grant raw `gh pr comment/view/diff`: those are + # unscoped, and since the review reads untrusted PR content (diff, + # description) a prompt-injection payload could steer them at other + # PRs/issues. PR context + diff are already injected via track_progress. + claude_args: | + --allowedTools "mcp__github_inline_comment__create_inline_comment" From d4171faca3aa9be547250fce78dc20c1258d7260 Mon Sep 17 00:00:00 2001 From: MaryWylde Date: Mon, 3 Aug 2026 16:38:19 +0200 Subject: [PATCH 5/8] fix(uxcat): load bias descriptions during the test uxCoreData was hardcoded to null in the UX Core context value, so every consumer of the shared bias list saw an empty array. On the ongoing test the bias name comes from the test API and rendered fine, but the description came from context and sat on a skeleton forever. Reported as a geo/VPN issue; it affected everyone. Populate it client-side from a slim Strapi query gated to /uxcat routes. The full bias payload is ~1.4 MB and those screens only read number, title, description, slug and mentionedQuestionsIds, so select just those and cache per module. mentionedQuestionsIds is required: test-result JSON.parses it to pick recommended reading and would throw on null. Also fixes QuestionAnalyse, which built its list in an effect with an empty dependency array and so never saw the data arrive. Co-Authored-By: Claude Opus 4.7 --- src/pages/_app.tsx | 23 +++++++++- src/uxcore/api/biases.ts | 44 +++++++++++++++++++ .../QuestionAnalyse/QuestionAnalyse.tsx | 20 ++++----- 3 files changed, 74 insertions(+), 13 deletions(-) diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index df1730b9..f0399a94 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -1,3 +1,4 @@ +import { getSlimBiases } from '@uxcore/api/biases'; import { getOurProjects } from '@uxcore/api/our-projects'; import { GlobalContext as UXCoreGlobalContext } from '@uxcore/components/Context/GlobalContext'; import { NewUpdateModalContainer } from '@uxcore/components/NewUpdateModal'; @@ -42,6 +43,7 @@ function AppContent({ Component, pageProps: { session, ...pageProps } }: TApp) { const [selectedTitle, setSelectedTitle] = useState(''); const [updatedUsername, setUpdatedUsername] = useState(''); const [ourProjectsModalData, setOurProjectsModalData] = useState(null); + const [uxCoreData, setUxCoreData] = useState(null); const isIndexingOn = process.env.NEXT_PUBLIC_INDEXING === 'on'; const isProduction = process.env.NEXT_PUBLIC_ENV === 'prod'; @@ -276,6 +278,24 @@ function AppContent({ Component, pageProps: { session, ...pageProps } }: TApp) { }; }, [isUxcoreRoute, router.locale]); + const isUxcatRoute = router.pathname.startsWith('/uxcat'); + + useEffect(() => { + if (!isUxcatRoute || uxCoreData) return; + let cancelled = false; + (async () => { + try { + const data = await getSlimBiases(); + if (!cancelled) setUxCoreData(data); + } catch (err) { + console.warn('[uxcat-biases] fetch failed:', err); + } + })(); + return () => { + cancelled = true; + }; + }, [isUxcatRoute, uxCoreData]); + const uxcoreContextValue = useMemo( () => ({ accountData, @@ -289,7 +309,7 @@ function AppContent({ Component, pageProps: { session, ...pageProps } }: TApp) { setUpdatedUsername, ourProjectsModalData, setOurProjectsModalData, - uxCoreData: null, + uxCoreData, uxcgLocalizedData: null, uxcgData: null, }), @@ -299,6 +319,7 @@ function AppContent({ Component, pageProps: { session, ...pageProps } }: TApp) { selectedTitle, updatedUsername, ourProjectsModalData, + uxCoreData, ], ); diff --git a/src/uxcore/api/biases.ts b/src/uxcore/api/biases.ts index 20cbbd26..a51ab586 100644 --- a/src/uxcore/api/biases.ts +++ b/src/uxcore/api/biases.ts @@ -1,8 +1,52 @@ let cachedBiases: any = null; +let cachedSlimBiases: any = null; const LOCALES = ['en', 'ru', 'hy']; const PAGE_SIZE = 100; const TOTAL_ITEMS_EXPECTED = 105; +const SLIM_FIELDS = [ + 'number', + 'title', + 'description', + 'slug', + // test-result.tsx JSON.parses this to pick recommended reading — omitting it + // makes that page throw once the bias list is populated. + 'mentionedQuestionsIds', +]; +const SLIM_LOCALES = ['en', 'ru']; + +// UXCAT renders bias titles/descriptions from the shared bias list, but the +// full payload (SEO, OG images, usage sections) is ~30x larger than what those +// screens read. Fetch only the fields UXCAT needs. +export const getSlimBiases = async () => { + if (cachedSlimBiases) return cachedSlimBiases; + + const fieldsQuery = SLIM_FIELDS.map( + (field, index) => `fields[${index}]=${field}`, + ).join('&'); + + const allData = { en: [], ru: [], hy: [] }; + + for (const locale of SLIM_LOCALES) { + let page = 1; + while (true) { + const url = `${process.env.NEXT_PUBLIC_STRAPI}/api/biases?locale=${locale}&sort=number&pagination[pageSize]=${PAGE_SIZE}&pagination[page]=${page}&${fieldsQuery}`; + const res = await fetch(url); + const json = await res.json(); + + if (!json.data || json.data.length === 0) break; + + allData[locale].push(...json.data); + + if (json.data.length < PAGE_SIZE) break; + page++; + } + } + + cachedSlimBiases = allData; + return allData; +}; + export const getStrapiBiases = async () => { if (cachedBiases) return cachedBiases; diff --git a/src/uxcore/components/QuestionAnalyse/QuestionAnalyse.tsx b/src/uxcore/components/QuestionAnalyse/QuestionAnalyse.tsx index bddd04fc..af8a07ba 100644 --- a/src/uxcore/components/QuestionAnalyse/QuestionAnalyse.tsx +++ b/src/uxcore/components/QuestionAnalyse/QuestionAnalyse.tsx @@ -1,17 +1,13 @@ -import cn from 'classnames'; -import { useRouter } from 'next/router'; -import { FC, useContext, useEffect, useState } from 'react'; - -import { StrapiBiasType } from '@uxcore/local-types/data'; -import { TRouter } from '@uxcore/local-types/global'; - -import { mergeBiasesLocalization } from '@uxcore/lib/helpers'; - -import questionAnalyseData from '@uxcore/data/uxcat/questionAnalyse'; - import ContentParser from '@uxcore/components/ContentParser'; import { GlobalContext } from '@uxcore/components/Context/GlobalContext'; import Modal from '@uxcore/components/Modal'; +import questionAnalyseData from '@uxcore/data/uxcat/questionAnalyse'; +import { mergeBiasesLocalization } from '@uxcore/lib/helpers'; +import { StrapiBiasType } from '@uxcore/local-types/data'; +import { TRouter } from '@uxcore/local-types/global'; +import cn from 'classnames'; +import { useRouter } from 'next/router'; +import { FC, useContext, useEffect, useState } from 'react'; import styles from './QuestionAnalyse.module.scss'; @@ -73,7 +69,7 @@ const QuestionAnalyse: FC = ({ ), ); } - }, []); + }, [uxCoreData]); return ( Date: Mon, 3 Aug 2026 16:38:41 +0200 Subject: [PATCH 6/8] fix(uxcore): let the shared modal scroll instead of clipping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wrapper had no height ceiling, so on short viewports it grew past the screen and the centred overlay clipped it at both ends — the header and the footer buttons were unreachable. Reported against the "Our projects" modal on /ru, but it is the shared UX Core modal, so every consumer had it. Cap the wrapper, stop the header shrinking, and give the body min-height:0 — without that the flex child cannot shrink below its content height and overflow:auto never engages. The mobile fullHeightMobile override still wins by source order. Co-Authored-By: Claude Opus 4.7 --- src/uxcore/components/Modal/Modal.module.scss | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/uxcore/components/Modal/Modal.module.scss b/src/uxcore/components/Modal/Modal.module.scss index 4fa08fba..52487e1f 100644 --- a/src/uxcore/components/Modal/Modal.module.scss +++ b/src/uxcore/components/Modal/Modal.module.scss @@ -25,6 +25,9 @@ position: relative; z-index: 80; border-radius: 4px; + // Without a ceiling the wrapper grows past the viewport and the centered + // overlay clips it at both ends, putting the header and footer out of reach. + max-height: calc(100vh - 40px); animation: wrapperIn 0.2s ease-out; &.wrapperClosing { @@ -44,6 +47,7 @@ .header { width: 100%; display: flex; + flex-shrink: 0; justify-content: space-between; padding-top: 13px; padding-bottom: 8px; @@ -74,6 +78,10 @@ .body { padding: 16px 28px; + flex: 1 1 auto; + // min-height:0 lets this flex child shrink below its content height — + // otherwise overflow:auto never engages and nothing scrolls. + min-height: 0; overflow: auto; // Slim styled scrollbar so the inside of the modal does not look From 8b2cf721157564d9b90960d8301c1e2d292b20ab Mon Sep 17 00:00:00 2001 From: MaryWylde Date: Mon, 3 Aug 2026 16:39:02 +0200 Subject: [PATCH 7/8] fix(uxcat): emit a valid .ics so Apple Calendar accepts the reminder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The downloaded file was missing PRODID, UID and DTSTAMP — all required by RFC 5545 — used LF instead of CRLF, and wrote NaN for the dates: nextTestTime arrives as an epoch string and new Date('1758...') is an Invalid Date, so the timestamps never rendered. Apple Calendar rejects the file outright; more lenient clients had been hiding it. Replace react-icalendar-link with a small local builder that emits the required properties, escapes TEXT per 3.3.11, leaves URL unescaped since it is a URI value, and folds lines at 75 octets rather than characters (Cyrillic copy overruns a character-based fold). The UID is derived from the start time so re-downloading updates the event instead of stacking duplicates. Co-Authored-By: Claude Opus 4.7 --- .../AddToCalendar/AddToCalendar.tsx | 34 ++--- .../CalendarItems/CalendarItems.tsx | 13 +- src/uxcore/lib/ics.ts | 133 ++++++++++++++++++ 3 files changed, 159 insertions(+), 21 deletions(-) create mode 100644 src/uxcore/lib/ics.ts diff --git a/src/uxcore/components/AddToCalendar/AddToCalendar.tsx b/src/uxcore/components/AddToCalendar/AddToCalendar.tsx index 0def0567..aaae7978 100644 --- a/src/uxcore/components/AddToCalendar/AddToCalendar.tsx +++ b/src/uxcore/components/AddToCalendar/AddToCalendar.tsx @@ -1,17 +1,14 @@ +import CalendarItems from '@uxcore/components/CalendarItems'; +import Modal from '@uxcore/components/Modal'; +import calendar from '@uxcore/data/uxcat/calendar'; +import { useClickOutside } from '@uxcore/hooks/useClickOutside'; +import useMobile from '@uxcore/hooks/useMobile'; +import { getEventWindow, toICalUTC } from '@uxcore/lib/ics'; +import type { TRouter } from '@uxcore/local-types/global'; import cn from 'classnames'; import { useRouter } from 'next/router'; import { FC, useState } from 'react'; -import type { TRouter } from '@uxcore/local-types/global'; - -import { useClickOutside } from '@uxcore/hooks/useClickOutside'; -import useMobile from '@uxcore/hooks/useMobile'; - -import calendar from '@uxcore/data/uxcat/calendar'; - -import CalendarItems from '@uxcore/components/CalendarItems'; -import Modal from '@uxcore/components/Modal'; - import styles from './AddToCalendar.module.scss'; type AddToCalendarProps = { @@ -33,18 +30,23 @@ const AddToCalendar: FC = ({ const currentLocale = locale === 'ru' ? 'ru' : 'en'; const { addToCalendar, title, description } = calendar[currentLocale]; - const calendarDescription = `${description} ${process.env.NEXT_PUBLIC_DOMAIN}/uxcat/start-test`; + const testUrl = `${process.env.NEXT_PUBLIC_DOMAIN}/uxcat/start-test`; + const calendarDescription = `${description} ${testUrl}`; + + const eventWindow = getEventWindow(startTime); + if (!eventWindow) return null; const event = { - title: title, - startTime: startTime?.toString(), + title, + start: eventWindow.start, + end: eventWindow.end, description: calendarDescription, - url: `${process.env.NEXT_PUBLIC_DOMAIN}/uxcat/start-test`, + url: testUrl, }; - const googleCalendarUrl = `https://www.google.com/calendar/render?action=TEMPLATE&text=${encodeURIComponent(event.title)}&dates=${event.startTime}&details=${encodeURIComponent(calendarDescription)}`; + const googleCalendarUrl = `https://www.google.com/calendar/render?action=TEMPLATE&text=${encodeURIComponent(title)}&dates=${toICalUTC(eventWindow.start)}/${toICalUTC(eventWindow.end)}&details=${encodeURIComponent(calendarDescription)}`; - const outlookCalendarUrl = `https://outlook.live.com/calendar/0/deeplink/compose?subject=${encodeURIComponent(event.title)}&body=${encodeURIComponent(calendarDescription)}&startdt=${event.startTime}`; + const outlookCalendarUrl = `https://outlook.live.com/calendar/0/deeplink/compose?subject=${encodeURIComponent(title)}&body=${encodeURIComponent(calendarDescription)}&startdt=${eventWindow.start.toISOString()}&enddt=${eventWindow.end.toISOString()}`; return ( <> diff --git a/src/uxcore/components/CalendarItems/CalendarItems.tsx b/src/uxcore/components/CalendarItems/CalendarItems.tsx index 85dc8af4..d9d5ffd5 100644 --- a/src/uxcore/components/CalendarItems/CalendarItems.tsx +++ b/src/uxcore/components/CalendarItems/CalendarItems.tsx @@ -1,11 +1,11 @@ +import { buildICS, CalendarEvent, downloadICS } from '@uxcore/lib/ics'; import Image from 'next/image'; import { FC } from 'react'; -import ICalendarLink from 'react-icalendar-link'; import styles from './CalendarItems.module.scss'; type CalendarItemsProps = { - event: any; + event: CalendarEvent; googleCalendarUrl: string; outlookCalendarUrl: string; }; @@ -17,8 +17,11 @@ const CalendarItems: FC = ({ return (
- {/*@ts-ignore*/} - +
String(value).padStart(2, '0'); + +// nextTestTime arrives as an epoch timestamp, and `new Date('1758...')` parses +// as Invalid Date — numeric strings have to be coerced to a number first. +export const parseEventDate = ( + value?: string | number | Date | null, +): Date | null => { + if (value === null || value === undefined || value === '') return null; + + const raw = value instanceof Date ? value : String(value).trim(); + const normalized = + typeof raw === 'string' && /^\d+$/.test(raw) ? Number(raw) : raw; + + const date = new Date(normalized); + return Number.isNaN(date.getTime()) ? null : date; +}; + +export const getEventWindow = (value?: string | number | Date | null) => { + const start = parseEventDate(value); + if (!start) return null; + + return { start, end: new Date(start.getTime() + DEFAULT_DURATION_MS) }; +}; + +// RFC 5545 UTC date-time: YYYYMMDDTHHMMSSZ +export const toICalUTC = (date: Date) => + [ + date.getUTCFullYear(), + pad(date.getUTCMonth() + 1), + pad(date.getUTCDate()), + 'T', + pad(date.getUTCHours()), + pad(date.getUTCMinutes()), + pad(date.getUTCSeconds()), + 'Z', + ].join(''); + +// RFC 5545 §3.3.11 +const escapeText = (value: string) => + value + .replace(/\\/g, '\\\\') + .replace(/;/g, '\\;') + .replace(/,/g, '\\,') + .replace(/\r?\n/g, '\\n'); + +// RFC 5545 §3.1 — content lines are folded at 75 octets, and Cyrillic copy +// blows past that in half the characters, so fold by byte length not length. +const foldLine = (line: string) => { + const encoder = new TextEncoder(); + if (encoder.encode(line).length <= MAX_LINE_OCTETS) return line; + + const chunks: string[] = []; + let current = ''; + let currentOctets = 0; + + for (const char of line) { + const octets = encoder.encode(char).length; + if (currentOctets + octets > MAX_LINE_OCTETS) { + chunks.push(current); + current = ''; + currentOctets = 0; + } + current += char; + currentOctets += octets; + } + chunks.push(current); + + return chunks.join(`${CRLF} `); +}; + +export const buildICS = ({ + title, + start, + end, + description, + url, +}: CalendarEvent) => { + const startUTC = toICalUTC(start); + const endUTC = toICalUTC( + end ?? new Date(start.getTime() + DEFAULT_DURATION_MS), + ); + + const lines = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + `PRODID:${PRODID}`, + 'CALSCALE:GREGORIAN', + 'METHOD:PUBLISH', + 'BEGIN:VEVENT', + // Deterministic UID so re-downloading updates the event instead of + // stacking duplicates in the user's calendar. + `UID:uxcat-${startUTC}@keepsimple.io`, + `DTSTAMP:${toICalUTC(new Date())}`, + `DTSTART:${startUTC}`, + `DTEND:${endUTC}`, + `SUMMARY:${escapeText(title)}`, + ]; + + if (description) lines.push(`DESCRIPTION:${escapeText(description)}`); + // URL is a URI value, not TEXT — it must not be backslash-escaped. + if (url) lines.push(`URL:${url}`); + + lines.push('END:VEVENT', 'END:VCALENDAR'); + + return `${lines.map(foldLine).join(CRLF)}${CRLF}`; +}; + +export const downloadICS = (content: string, filename: string) => { + const blob = new Blob([content], { type: 'text/calendar;charset=utf-8' }); + const objectUrl = URL.createObjectURL(blob); + + const link = document.createElement('a'); + link.href = objectUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + setTimeout(() => URL.revokeObjectURL(objectUrl), 0); +}; From 187353593aa38190a09fdc9528d0dcc034f43d54 Mon Sep 17 00:00:00 2001 From: MaryWylde Date: Mon, 3 Aug 2026 16:39:30 +0200 Subject: [PATCH 8/8] fix(widget): lift the Copilot pill 70px on UX Core pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Those pages keep their own controls in the bottom-right corner, where the pill sits. Add clearance via a --ks-aux-lift custom property, set from the uxcorePage body class that _app.tsx already applies to /uxcore, /uxcg, /uxcp, /uxcat and /uxcore-api. The margin goes on the root rather than the pill: the panel is positioned against the root, so raising the pill alone would leave the panel anchored low and overlapping it. On mobile the panel height comes from --ks-aux-panel-h, and a JS-set custom property overrides the CSS fallback entirely, so the lift has to be subtracted there too — otherwise the panel keeps its full height and runs off the top of short screens. Co-Authored-By: Claude Opus 4.7 --- widget/src/AskUxCore.tsx | 12 +++++++++++- widget/src/styles.css | 16 +++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/widget/src/AskUxCore.tsx b/widget/src/AskUxCore.tsx index b98d25d1..b5579048 100644 --- a/widget/src/AskUxCore.tsx +++ b/widget/src/AskUxCore.tsx @@ -2184,9 +2184,19 @@ export function AskUxCore({ lang }: { lang: Lang }) { const occluded = vv ? Math.max(0, window.innerHeight - (vv.height + vv.offsetTop)) : 0; + // Host pages can lift the widget off the bottom edge (--ks-aux-lift). + // That clearance eats into the room the panel has, so subtract it or + // the panel overflows the top of short screens. + const widget = document.querySelector('.ks-aux-root'); + const lift = widget + ? parseFloat(getComputedStyle(widget).getPropertyValue('--ks-aux-lift')) + : 0; root.style.setProperty('--ks-aux-vh', `${h}px`); root.style.setProperty('--ks-aux-bottom-offset', `${occluded}px`); - root.style.setProperty('--ks-aux-panel-h', `${Math.max(220, h - 96)}px`); + root.style.setProperty( + '--ks-aux-panel-h', + `${Math.max(220, h - 96 - (lift || 0))}px`, + ); }; setVh(); const vv = window.visualViewport; diff --git a/widget/src/styles.css b/widget/src/styles.css index 8d027253..046f47ed 100644 --- a/widget/src/styles.css +++ b/widget/src/styles.css @@ -2,6 +2,10 @@ position: fixed; right: 24px; bottom: 24px; + /* Extra clearance for host pages that park their own controls in the + bottom-right corner. Margin (not `bottom`) so it composes with the + mobile keyboard offset below without rewriting that calc. */ + margin-bottom: var(--ks-aux-lift, 0px); z-index: 2147483600; font-family: 'Jost-Regular', 'Jost', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; @@ -24,6 +28,13 @@ } } +/* UX Core surfaces (uxcore + bias pages, uxcg + questions, uxcp, uxcat, + uxcore-api) keep their own chrome in the bottom-right corner. `_app.tsx` + sets body.uxcorePage for exactly those routes. */ +body.uxcorePage .ks-aux-root { + --ks-aux-lift: 70px; +} + @keyframes ks-aux-pulse { 0%, 100% { box-shadow: 0 4px 16px rgba(31, 29, 26, 0.16); @@ -1312,7 +1323,10 @@ right: 0; bottom: 60px; /* sits just above the pill */ width: auto; - height: var(--ks-aux-panel-h, calc(100dvh - 96px)); + height: var( + --ks-aux-panel-h, + calc(100dvh - 96px - var(--ks-aux-lift, 0px)) + ); max-height: 640px; border-radius: 14px; }