Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 16 additions & 8 deletions LIBRARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 40 additions & 5 deletions scripts/release/library-batch-check.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};
Expand Down Expand Up @@ -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',
Expand Down
112 changes: 112 additions & 0 deletions src/components/AuthLoader/AuthLoader.module.scss
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
22 changes: 22 additions & 0 deletions src/components/AuthLoader/AuthLoader.tsx
Original file line number Diff line number Diff line change
@@ -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 = () => (
<div className={styles.screen} role="status" aria-label="Loading">
<span className={styles.brain}>
<img src="/keepsimple_/assets/spinner/brain.svg" alt="" />
<img src="/keepsimple_/assets/spinner/brain circle.svg" alt="" />
<img src="/keepsimple_/assets/spinner/brain gears.svg" alt="" />
</span>
</div>
);

export default AuthLoader;
3 changes: 3 additions & 0 deletions src/components/AuthLoader/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import AuthLoader from './AuthLoader';

export default AuthLoader;
19 changes: 17 additions & 2 deletions src/components/library/atoms/Avatar/Avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,31 @@ 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 (
<div className={classNames(className, styles.avatar)}>
{url ? (
<Image
src={url}
fill
sizes="(max-width: 590px) 100px, 208px"
sizes={sizes}
quality={AVATAR_QUALITY}
alt="Picture of the author"
/>
) : (
Expand Down
8 changes: 8 additions & 0 deletions src/components/library/atoms/Avatar/Avatar.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,11 @@ export function EditLibraryModal(props: EditLibraryModalProps): JSX.Element {
<form onSubmit={handleSubmit(onSubmit)} className={styles.form}>
<div className={styles.avatarSection}>
<div className={styles.avatarPreview}>
<Avatar url={previewUrl} className={styles.avatarImage} />
<Avatar
url={previewUrl}
className={styles.avatarImage}
sizes="270px"
/>
</div>
<div className={styles.avatarButtons}>
<Tooltip
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,7 @@ function Hotspot({

// 768–1920px shows the wide artwork; 1920px+ swaps to the panorama, which
// frames the buildings differently and so carries its own geometry.
const label =
library?.libraryName ??
(hotspot.username
? `${hotspot.username}'s library`
: 'Nothing but ghosts...');
const label = library?.libraryName ?? 'Nothing but ghosts...';
const { hit, highlight, card } = isUltraWide
? hotspot.ultraWide
: hotspot.wide;
Expand Down Expand Up @@ -259,11 +255,13 @@ export function InteractiveCover({
<Hotspot
key={hotspot.id}
hotspot={hotspot}
library={libraries.find(
library =>
library.username?.toLowerCase() ===
hotspot.username?.toLowerCase(),
)}
library={
hotspot.ownerId === undefined
? undefined
: libraries.find(
library => library.userId === hotspot.ownerId,
)
}
mode={mode}
activeId={activeId}
setActiveId={setActiveId}
Expand Down
Loading
Loading