diff --git a/LIBRARY.md b/LIBRARY.md index 4a4b3c91..a94d6c3d 100644 --- a/LIBRARY.md +++ b/LIBRARY.md @@ -57,17 +57,25 @@ owner's address. Phones and tablets stay read-only, so a signed-in owner without a library reads "Use a desktop to create your library" there. A visitor at an address no library answers to reads "No such library". -The AI shelf and the magic books are behind the `library-ai` account flag, -read from `GET /api/users/me` as `featureNames` (LIBRARY_AI_FLAG). The page -draws neither surface without it, and `/api/library/ai-shelf` and -`/api/library/magic-book` answer 403 without it through `ownerOfLibrary`, so -the hidden shelf is not the gate. Both surfaces are the owner's alone on -every account: a visitor never sees them. An operator hands the flag out in +The AI shelf and the magic books open to an owner who holds the `library-ai` +account flag, read from `GET /api/users/me` as `featureNames` +(LIBRARY_AI_FLAG), or whose library holds more than 15 books +(LIBRARY_AI_BOOKS_OVER, Wolf, 2026-09-25). Only objects of type book count, +on every shelf, private ones included; the count is read from the library on +every check, so the AI arrives with the sixteenth book and leaves if the +library drops back to fifteen, while the flag holds regardless. One check, +`opensLibraryAi` in `src/lib/library/flags.ts`, decides for the page and the +routes: the page draws neither surface without it, and +`/api/library/ai-shelf` and `/api/library/magic-book` answer 403 without it +through `ownerOfLibrary`, so the hidden shelf is not the gate. The AI shelf +itself stays locked until 30 books (AI_SHELF_MIN_BOOKS). Both surfaces are +the owner's alone on every account: a visitor never sees them. An operator hands the flag out in the CMS admin panel (the user's Feature Flags relation) or ahead of signup through the Mail Permission List; it takes effect on the account's next page load, no new sign-in. Wolf names the accounts, one at a time, to the agent; -on 2026-09-12 they are Alina, Mary, Lemongrass and Wolf. Cover, video and -audio autofill stay open to everyone: they cost no model call. +on 2026-09-12 they are Alina, Mary, Lemongrass and Wolf. On 2026-09-25 +Lilith and Maksim got it for holding more than 15 books, before the rule +above shipped. Cover, video and audio autofill stay open to everyone: they cost no model call. A library carries `hidden`, set only in the CMS admin panel (the content API refuses it on update). Hidden, it leaves the home list and diff --git a/scripts/release/library-batch-check.cjs b/scripts/release/library-batch-check.cjs index 478a697b..01f27456 100644 --- a/scripts/release/library-batch-check.cjs +++ b/scripts/release/library-batch-check.cjs @@ -3,6 +3,7 @@ const path = require('node:path'); const vm = require('node:vm'); const assert = require('node:assert/strict'); const ts = require('typescript'); + process.chdir(path.resolve(__dirname, '../..')); function load(file, mocks = {}) { const exports = {}; @@ -279,27 +280,61 @@ function check() { // LIBRARY ACCESS. Creation needs no flag; the AI does, and the routes // behind it check the same flag the page draws by, so a direct call is // stopped where the shelf is not drawn. - const { holdsFlag } = load('src/lib/library/flags.ts'); + const flags = load('src/lib/library/flags.ts', { + '@constants/library/common': { + LIBRARY_AI_FLAG: 'library-ai', + LIBRARY_AI_BOOKS_OVER: 15, + }, + }); + const { holdsFlag, countBooks, opensLibraryAi } = flags; assert(holdsFlag({ featureNames: ['library-ai'] }, 'library-ai')); assert(!holdsFlag({ featureNames: ['can-create-library'] }, 'library-ai')); assert(!holdsFlag({ featureNames: 'library-ai' }, 'library-ai')); assert(!holdsFlag({}, 'library-ai')); assert(!holdsFlag(null, 'library-ai')); + // More than 15 books opens the AI without the flag (Wolf, 2026-09-25); + // only books count, on every shelf. + const shelf = (...types) => ({ + attributes: { + objects: { data: types.map(type => ({ attributes: { type } })) }, + }, + }); + const libraryOf = (...shelves) => ({ + attributes: { singleShelves: { data: shelves } }, + }); + const fifteen = libraryOf( + shelf(...Array(10).fill('book'), 'audio', 'video'), + shelf(...Array(5).fill('book'), 'audio'), + ); + const sixteen = libraryOf( + shelf(...Array(10).fill('book')), + shelf(...Array(6).fill('book')), + ); + assert.equal(countBooks(fifteen), 15); + assert.equal(countBooks(sixteen), 16); + assert.equal(countBooks(null), 0); + assert(!opensLibraryAi({}, fifteen)); + assert(opensLibraryAi({}, sixteen)); + assert(opensLibraryAi({ featureNames: ['library-ai'] }, fifteen)); + assert(!opensLibraryAi(null, null)); // The constants file carries icon components for its sample cards; the // icons are not what is checked here. const common = load('src/constants/library/common.ts', { '@icons/library/svg': new Proxy({}, { get: () => () => null }), }); assert.equal(common.LIBRARY_AI_FLAG, 'library-ai'); + assert.equal(common.LIBRARY_AI_BOOKS_OVER, 15); assert.equal(common.MAX_OBJECTS_PER_LIBRARY, 300); for (const route of [ 'src/pages/api/library/ai-shelf.ts', 'src/pages/api/library/magic-book.ts', ]) - assert( - fs.readFileSync(route, 'utf8').includes('flag: LIBRARY_AI_FLAG'), - route, - ); + assert(fs.readFileSync(route, 'utf8').includes('libraryAi: true'), route); + assert( + fs + .readFileSync('src/layouts/library/Library/Library.tsx', 'utf8') + .includes('opensLibraryAi(accountData, library)'), + ); for (const file of [ 'src/layouts/library/Library/Library.tsx', 'src/layouts/library/Home/Home.tsx', diff --git a/src/components/AuthLoader/AuthLoader.module.scss b/src/components/AuthLoader/AuthLoader.module.scss new file mode 100644 index 00000000..5f2eb2fb --- /dev/null +++ b/src/components/AuthLoader/AuthLoader.module.scss @@ -0,0 +1,112 @@ +// Covers the whole viewport for the length of the login handshake. Sits above +// the header so the auth pages read as one deliberate surface rather than a +// half-built page. +.screen { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 1100; + display: flex; + align-items: center; + justify-content: center; + background-color: #f9fafb; + background-image: url('/keepsimple_/assets/landingPage/landing-bg.webp'); + background-size: 560px 420px; + background-repeat: repeat; + animation: auth-screen-in 0.25s ease-out both; +} + +.brain { + position: relative; + display: inline-block; + width: 72px; + height: 72px; + + & > img { + position: absolute; + top: 0; + left: 0; + width: 72px; + height: 72px; + // The house loader art is white, drawn for the dark route-change scrim. + // Inverted here so it reads as line art on the paper surface. + filter: invert(1); + opacity: 0.85; + + &:nth-child(2) { + animation: auth-brain-reverse 3s linear infinite; + } + + &:nth-child(3) { + animation: auth-brain-spin 3s linear infinite; + } + } +} + +:global(body.darkTheme) .screen { + background-color: #1b1e26; + background-image: none; +} + +:global(body.darkTheme) .brain > img { + filter: none; + opacity: 1; +} + +@keyframes auth-screen-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes auth-brain-spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +@keyframes auth-brain-reverse { + from { + transform: rotate(360deg); + } + + to { + transform: rotate(0deg); + } +} + +// Rotation is the only motion here, so reduced motion keeps the loader alive +// with a slow breath instead of freezing it into a still image. +@keyframes auth-brain-breathe { + 0%, + 100% { + opacity: 0.85; + } + + 50% { + opacity: 0.35; + } +} + +@media (prefers-reduced-motion: reduce) { + .screen { + animation: none; + } + + .brain > img { + &:nth-child(2), + &:nth-child(3) { + animation: auth-brain-breathe 2.4s ease-in-out infinite; + } + } +} diff --git a/src/components/AuthLoader/AuthLoader.tsx b/src/components/AuthLoader/AuthLoader.tsx new file mode 100644 index 00000000..b9f9f258 --- /dev/null +++ b/src/components/AuthLoader/AuthLoader.tsx @@ -0,0 +1,22 @@ +import type { FC } from 'react'; + +import styles from './AuthLoader.module.scss'; + +/** + * Full-screen cover for the auth round-trip (/auth, magic link, email change). + * Those pages carry almost no markup, so without a cover the visitor watches a + * bare document while the provider handshake runs. This paints the KeepSimple + * paper surface over the whole viewport and runs the house brain loader on it, + * so every entry point — keepsimple.io or UX Core — hands off the same way. + */ +const AuthLoader: FC = () => ( +
+ + + + + +
+); + +export default AuthLoader; diff --git a/src/components/AuthLoader/index.ts b/src/components/AuthLoader/index.ts new file mode 100644 index 00000000..8dd1d2a6 --- /dev/null +++ b/src/components/AuthLoader/index.ts @@ -0,0 +1,3 @@ +import AuthLoader from './AuthLoader'; + +export default AuthLoader; diff --git a/src/components/library/atoms/Avatar/Avatar.tsx b/src/components/library/atoms/Avatar/Avatar.tsx index d9915d54..a4466f8b 100644 --- a/src/components/library/atoms/Avatar/Avatar.tsx +++ b/src/components/library/atoms/Avatar/Avatar.tsx @@ -8,8 +8,22 @@ import type { AvatarProps } from './Avatar.types'; import styles from './Avatar.module.scss'; +// The home-grid card renders a 208px square (100px under 590px) and crops the +// source with `object-fit: cover`. Declaring the bare box width made the +// browser pick a candidate that only had 208px across its LONG side, so a +// landscape avatar was upscaled to fill the square: a 1456x816 upload arrived +// as 480x269 and had its 269px short side stretched over a 416px retina box. +// 1.78x headroom covers a 16:9 source, and the browser's own DPR multiplier +// rides on top of it. +const DEFAULT_SIZES = '(max-width: 590px) 180px, 370px'; + +// Uploads are already lossy, and next/image re-encodes them; the default +// quality of 75 stacks a second generation of artifacts on a face shown at +// small size. Avatars are a few tens of KB, so buy the fidelity back. +const AVATAR_QUALITY = 90; + export function Avatar(props: AvatarProps): JSX.Element { - const { className, url } = props; + const { className, url, sizes = DEFAULT_SIZES } = props; return (
@@ -17,7 +31,8 @@ export function Avatar(props: AvatarProps): JSX.Element { Picture of the author ) : ( diff --git a/src/components/library/atoms/Avatar/Avatar.types.ts b/src/components/library/atoms/Avatar/Avatar.types.ts index 4a997991..06f9e0dc 100644 --- a/src/components/library/atoms/Avatar/Avatar.types.ts +++ b/src/components/library/atoms/Avatar/Avatar.types.ts @@ -3,4 +3,12 @@ import { StaticImageData } from 'next/image'; export interface AvatarProps { className?: string; url?: string | StaticImageData; + /** + * Rendered box width per breakpoint, as a plain `sizes` string. It must be + * larger than the CSS box: the image is cropped with `object-fit: cover`, so + * a non-square source only contributes its short side and the browser, which + * sizes its pick from this value alone, has no way to know that. Defaults to + * the 100/208px boxes of the home-grid card with headroom for a 16:9 source. + */ + sizes?: string; } diff --git a/src/components/library/organisms/EditLibraryModal/EditLibraryModal.tsx b/src/components/library/organisms/EditLibraryModal/EditLibraryModal.tsx index 4ba09c24..787286e1 100644 --- a/src/components/library/organisms/EditLibraryModal/EditLibraryModal.tsx +++ b/src/components/library/organisms/EditLibraryModal/EditLibraryModal.tsx @@ -308,7 +308,11 @@ export function EditLibraryModal(props: EditLibraryModalProps): JSX.Element {
- +
- library.username?.toLowerCase() === - hotspot.username?.toLowerCase(), - )} + library={ + hotspot.ownerId === undefined + ? undefined + : libraries.find( + library => library.userId === hotspot.ownerId, + ) + } mode={mode} activeId={activeId} setActiveId={setActiveId} diff --git a/src/components/library/organisms/InteractiveCover/coverHotspots.ts b/src/components/library/organisms/InteractiveCover/coverHotspots.ts index 3ceae2e9..d4dedba3 100644 --- a/src/components/library/organisms/InteractiveCover/coverHotspots.ts +++ b/src/components/library/organisms/InteractiveCover/coverHotspots.ts @@ -39,7 +39,13 @@ export interface CoverHotspot { * Derived from `wide` (see `toUltraWide`), with optional per-hotspot tweaks. */ ultraWide: HotspotGeometry; - username?: string; + /** + * Account id of the library's owner. Bound by id, not username: owners rename + * themselves (Mary13 became Mary, alinamarg became Alina) and a username + * binding then silently drops the library's data from the card. Staging's + * database is a copy of production's, so the ids hold on both. + */ + ownerId?: number; } // At the 1920px breakpoint the full-bleed cover frame is 1920px wide and the @@ -109,13 +115,13 @@ const applyOverride = ( const makeHotspot = ( id: string, wide: HotspotGeometry, - username?: string, + ownerId?: number, ultraWideOverride?: GeometryOverride, ): CoverHotspot => ({ id, wide, ultraWide: applyOverride(toUltraWide(wide), ultraWideOverride), - username, + ownerId, }); // Hit boxes are sized to the glow silhouette each hotspot lights up, so the @@ -139,7 +145,8 @@ export const coverHotspots: CoverHotspot[] = [ }, card: { left: 53.0, top: 19.01 }, }, - 'Wolf', + // Wolf + 7, ), makeHotspot( 'house-1', @@ -193,7 +200,8 @@ export const coverHotspots: CoverHotspot[] = [ }, card: { left: 30.62, top: 26.4 }, }, - 'Mary13', + // Mary + 10, { hit: { top: 60.83 }, highlight: { left: 35.552, top: 28 }, @@ -215,6 +223,7 @@ export const coverHotspots: CoverHotspot[] = [ }, // Wolf, 2026-09-11: this library stands on the lantern now, not on the // house above the water. - 'alinamarg', + // Alina + 538, ), ]; diff --git a/src/components/library/organisms/Shelf/Shelf.module.scss b/src/components/library/organisms/Shelf/Shelf.module.scss index 201c96fd..6802c4c2 100644 --- a/src/components/library/organisms/Shelf/Shelf.module.scss +++ b/src/components/library/organisms/Shelf/Shelf.module.scss @@ -712,3 +712,28 @@ cursor: grabbing; filter: drop-shadow(0 18px 24px rgba(60, 40, 24, 0.28)); } + +// Fresh-library nudge: the header Add control breathes until the first object +// lands anywhere in the library (Library passes `highlightAdd`). The control +// has a transparent background, where a halo would paint a solid block behind +// the label, so it breathes its opacity instead. It takes and gives up no space. +.pulseText { + animation: add-pulse-fade 2.2s ease-in-out infinite; +} + +@keyframes add-pulse-fade { + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.55; + } +} + +@media (prefers-reduced-motion: reduce) { + .pulseText { + animation: none; + } +} diff --git a/src/components/library/organisms/Shelf/Shelf.tsx b/src/components/library/organisms/Shelf/Shelf.tsx index 6bf67b4c..58652fec 100644 --- a/src/components/library/organisms/Shelf/Shelf.tsx +++ b/src/components/library/organisms/Shelf/Shelf.tsx @@ -189,6 +189,7 @@ export function Shelf(props: ShelfProps): JSX.Element { visibleObjectIds = null, reorderLocked = false, onShelfVisibilityChanged, + highlightAdd = false, onObjectCreated, onObjectUpdated, onObjectDeleted, @@ -305,6 +306,10 @@ export function Shelf(props: ShelfProps): JSX.Element { // stops a doomed attempt and says which cap it hit. const shelfFull = objects.length >= MAX_OBJECTS_PER_SHELF; const atObjectLimit = shelfFull || libraryFull; + + // Pulse the Add control only while it can actually be used: the viewer owns + // the library, the library is still empty, and the shelf has room. + const pulseAdd = isOwner && highlightAdd && !atObjectLimit; const fullMessage = shelfFull ? `${SHELF_FULL_MESSAGE} Delete an item to add a new one.` : LIBRARY_OBJECTS_FULL_MESSAGE; @@ -1082,7 +1087,9 @@ export function Shelf(props: ShelfProps): JSX.Element { size={ButtonSize.Default} Icon={} iconPosition={IconPosition.Right} - className={styles.button} + className={classNames(styles.button, { + [styles.pulseText]: pulseAdd, + })} disabled={atObjectLimit} /> diff --git a/src/components/library/organisms/Shelf/Shelf.types.ts b/src/components/library/organisms/Shelf/Shelf.types.ts index 6a1ac74e..2482b58e 100644 --- a/src/components/library/organisms/Shelf/Shelf.types.ts +++ b/src/components/library/organisms/Shelf/Shelf.types.ts @@ -33,6 +33,12 @@ export interface ShelfProps { * grip stays in its slot, disabled, so the header does not reflow. */ reorderLocked?: boolean; + /** + * True while the whole library still holds no objects. Pulses this shelf's + * Add control so a fresh library points at its own next step; the library + * turns it off the moment the first object lands anywhere. + */ + highlightAdd?: boolean; /** Fired after the privacy switch saved, so the library tree carries it. */ onShelfVisibilityChanged?: ( shelfId: number, diff --git a/src/components/library/organisms/Sidebar/Sidebar.module.scss b/src/components/library/organisms/Sidebar/Sidebar.module.scss index 1b844047..c75b7bb7 100644 --- a/src/components/library/organisms/Sidebar/Sidebar.module.scss +++ b/src/components/library/organisms/Sidebar/Sidebar.module.scss @@ -56,6 +56,8 @@ // drawer's own top controls aren't hidden beneath it when open. z-index: 130; transform: translateX(100%); + // The host that clips it passes taps through; the panel itself takes them. + pointer-events: auto; } @media (max-width: 768px) { @@ -364,6 +366,12 @@ } } +// Host for the panel. On desktop it is transparent to layout, so the panel +// stays a flex item of the dashboard row exactly as before. +.drawerHost { + display: contents; +} + // Edge tab that opens the drawer — mobile only. A half-pill anchored to the // right edge, vertically centered so it clears both the fixed header (top) and // the share-selection bar (bottom). @@ -377,6 +385,25 @@ } @media (max-width: 1024px) { + // The closed drawer parks itself one screen-width to the right. A fixed box + // sitting outside the right edge still counts towards the page's scrollable + // width in Safari, so every library page measured about two screens wide on a + // phone: blocks laid out at that width, text was cut off at the screen edge, + // and the page scrolled sideways. This host is fixed, viewport-sized and + // paint-contained, which makes it the containing block for the panel and + // clips it — the parked drawer no longer widens the page, and only the + // vertical scroll remains. It must not swallow taps meant for the page + // behind it, hence pointer-events. + .drawerHost { + display: block; + position: fixed; + inset: 0; + z-index: 130; + overflow: hidden; + contain: paint; + pointer-events: none; + } + .openTab { position: fixed; top: 50%; @@ -447,3 +474,16 @@ animation: none; } } + +// Readers who ask the system for less motion get the panel and the copy +// feedback as state changes, not as movement. +@media (prefers-reduced-motion: reduce) { + .sidebar, + .sidebar .copyButton { + transition: none; + } + + .sidebar .copied { + animation: none; + } +} diff --git a/src/components/library/organisms/Sidebar/Sidebar.tsx b/src/components/library/organisms/Sidebar/Sidebar.tsx index 18f307e4..994f9a0e 100644 --- a/src/components/library/organisms/Sidebar/Sidebar.tsx +++ b/src/components/library/organisms/Sidebar/Sidebar.tsx @@ -58,6 +58,11 @@ import { EditLibraryModal } from '@components/library/organisms/EditLibraryModal import styles from './Sidebar.module.scss'; +// motion-passport: exempt. The drawer host added below is layout only +// (display: contents on desktop, a fixed clipping layer on the drawer +// breakpoints) and animates nothing. The panel-slide transition and its +// prefers-reduced-motion branch live in Sidebar.module.scss. + // aboutMe / aboutLibrary hold rich text: the owner's line breaks and marks are // part of what they wrote, so the panel renders the stored markup instead of // flattening it. Emptiness is judged on the text alone, never on the tags. @@ -328,267 +333,281 @@ export function Sidebar() { {/* Desktop: the column folds away on the toolbar's panel toggle and unfolds from it; the choice is per account and survives a refresh (GlobalState). The drawer states above are phone/tablet only, and - the CSS scopes each to its own breakpoint. */} -