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 = () => ( +