fix mobile screen issues

This commit is contained in:
2026-08-24 13:14:22 +05:30
parent dcf9770ada
commit 02eb48af99
34 changed files with 1214 additions and 291 deletions

View File

@@ -3,16 +3,24 @@
*
* The reference app talks to a Base44 backend through this module. The demo
* keeps the module path, the export name, and the full method contract, and
* swaps the transport for the local store in `store.js` and the local AI engine
* swaps the transport for the Go API in `httpClient.js` and the local AI engine
* in `aiEngine.js`. Nothing downstream — hooks, pages, components — knows or
* cares, which is exactly the point: the seam stays where it was.
*
* Phase 2D moved the transport from a localStorage-backed store to HTTP:
*
* React → base44Client.js → HTTP → Go API → PostgreSQL
*
* The entity surface is unchanged. `store.js` and `seed.js` are no longer the
* source of data — the seeded dataset now lives in PostgreSQL, loaded by the
* backend's `make seed`. `seed.js` is still imported for one thing: the shape
* of the demo user's default preferences, which the synchronous accessor below
* needs before the first response arrives.
*/
import { createEntity, initStore, resetStore } from './store';
import { createEntity, request, isUnauthenticated, API_BASE_URL } from './httpClient';
import { invokeLLM, uploadFile } from './aiEngine';
import { DEMO_USER, seedData } from './seed';
initStore(seedData);
import { DEMO_USER } from './seed';
const ENTITY_NAMES = [
'JobPosting', 'JobApplication', 'AIInterview', 'Staff', 'WorkerProfile',
@@ -33,9 +41,28 @@ const entities = Object.fromEntries(
/* ── Auth ──────────────────────────────────────────────────────────────── */
/**
* The last user the API returned.
*
* This is a **cache of `GET /me`**, not a store. The record itself lives in
* PostgreSQL; this exists for one reason, and it is not offline support.
*
* `auth.preferences()` is synchronous, and it has to stay synchronous:
* `AssistantPanelContext` decides whether Owliver starts open in a `useState`
* initialiser, during the first render, and `krowHooks.js:42` merges the same
* accessor under the async user so the first paint already has real values. One
* tick later is a visible flash of the wrong workspace — the panel opening on
* an account that turned it off, then closing.
*
* So the last known user is mirrored to localStorage and read back at module
* load, and `GET /me` refreshes it. On a return visit the synchronous read is
* already correct; on a first-ever visit it is the seeded defaults for exactly
* as long as the request takes, which is the same thing the old store did with
* an empty key.
*/
const SESSION_KEY = 'krow_demo_user';
function loadUser() {
function readCachedUser() {
try {
const raw = localStorage.getItem(SESSION_KEY);
return raw ? { ...DEMO_USER, ...JSON.parse(raw) } : { ...DEMO_USER };
@@ -44,98 +71,207 @@ function loadUser() {
}
}
let currentUser = loadUser();
/**
* Writes the session user, and says whether it actually landed.
* Mirrors the current user for the next page load's synchronous read.
*
* The old version was `try { setItem } catch {}` — a swallowed
* `QuotaExceededError` or a private-browsing refusal, and the caller was handed
* a user object indistinguishable from a successful write. For preferences that
* is invisible; for `customSkills`, which is where every account-authored skill
* definition lives, it is the whole "I saved it and it was gone" report: the
* toast said added, the list showed it, the reload did not.
*
* The read-back matters as much as the catch. A write can be accepted and then
* evicted, and a serialisation can land truncated; comparing what came back
* with what went in is the only way to know the record is really there.
* Failures are ignored, which is a real change from the old `persistUser` and a
* safe one. That function checked its write and reported failure because
* localStorage was the *only* copy — a swallowed `QuotaExceededError` was how
* account-authored skills silently disappeared. Now the only copy is in
* PostgreSQL, and a refused mirror costs one render of default preferences, not
* data.
*/
function persistUser() {
const payload = JSON.stringify(currentUser);
function cacheUser() {
try {
localStorage.setItem(SESSION_KEY, payload);
} catch (error) {
return { persisted: false, error };
localStorage.setItem(SESSION_KEY, JSON.stringify(currentUser));
} catch {
// Private browsing or quota — the server still has the record.
}
try {
if (localStorage.getItem(SESSION_KEY) !== payload) {
return { persisted: false, error: new Error('The session record did not survive the write.') };
}
} catch (error) {
return { persisted: false, error };
}
return { persisted: true, error: null };
}
/**
* Drops the mirrored user.
*
* Signing out must not leave the next page load rendering the previous
* account's name and preferences out of localStorage while it waits for a
* `GET /me` that is going to 401.
*/
function forgetUser() {
try {
localStorage.removeItem(SESSION_KEY);
} catch {
// Ignore.
}
currentUser = { ...DEMO_USER };
}
let currentUser = readCachedUser();
/**
* Whether the last `GET /me` succeeded.
*
* A cache of the server's answer, not a decision. Nothing here grants access:
* the API refuses an unauthenticated request whatever this says, and a user who
* edits it in the console has changed a boolean in their own tab and nothing
* else. It exists because `isAuthenticated()` is synchronous.
*/
let authenticated = false;
/**
* The first `GET /me`, shared.
*
* Started at module load so the synchronous accessor is corrected as early as
* possible, and shared so the eleven `me()` call sites that fire during the
* first render make one request between them rather than eleven.
*
* A 401 here is the ordinary state of a signed-out visitor, not a failure: the
* app opens on the login page and this request is how it finds that out.
*/
let hydration = request('GET', '/me')
.then((user) => {
currentUser = user;
authenticated = true;
cacheUser();
return user;
})
.catch(() => {
authenticated = false;
return null;
});
const auth = {
/** The demo is always signed in as the seeded employer/admin user. */
/**
* The signed-in user, from the session cookie.
*
* Throws when there is no session — a `KrowApiError` with `status: 401` — and
* that throw is the app's authentication check. `AuthContext` catches it and
* renders the login page. Nothing here decides who the user is; the server
* reads its own session table and answers.
*
* Joins the in-flight hydration if there is one, so the first render's
* callers share a request; refetches afterwards so a change made in another
* tab, or a session that has since expired, is picked up.
*/
async me() {
return { ...currentUser };
if (hydration) {
const user = await hydration;
hydration = null;
if (user) {
authenticated = true;
return { ...user };
}
}
try {
const user = await request('GET', '/me');
currentUser = user;
authenticated = true;
cacheUser();
return { ...user };
} catch (error) {
if (isUnauthenticated(error)) {
authenticated = false;
forgetUser();
}
throw error;
}
},
/**
* Signs in and starts a session.
*
* The response body is the user. The session token is NOT in it — it arrives
* as an HttpOnly cookie the browser stores and this code cannot read, which
* is what stops a script on the page from stealing it. There is deliberately
* nothing here that writes a token anywhere.
*
* Every credential failure comes back as the same 401 with the same message,
* by design: telling the two apart would say whether an address is
* registered. The caller shows that message as-is.
*/
async login({ email, password, rememberMe = false }) {
const user = await request('POST', '/auth/login', {
body: { email, password, remember_me: Boolean(rememberMe) },
});
currentUser = user;
authenticated = true;
hydration = null;
cacheUser();
return { ...user };
},
async updateMe(patch) {
currentUser = { ...currentUser, ...patch };
persistUser();
return { ...currentUser };
const user = await request('PATCH', '/me', { body: patch });
currentUser = user;
cacheUser();
return { ...user };
},
/**
* Preferences, read synchronously.
*
* `me()` is async because the real client fetches, but the panel provider has
* to decide whether Owliver starts open during its first render — one tick
* later is a visible flash of the wrong workspace. The session user is already
* hydrated from localStorage at module load, so this is a plain read of the
* same record `me()` returns, not a second copy of the state.
* A plain read of the same record `me()` returns, defaulted with the shape
* from `seed.js` so a key the server has never stored still resolves. See the
* note on `currentUser` for why this must not become async.
*/
preferences() {
return { ...DEMO_USER.preferences, ...(currentUser.preferences || {}) };
},
/**
* Merges into the stored preferences and persists with the rest of the user.
* Merges into the stored preferences and persists them server-side.
*
* Returns the write's outcome alongside the record, rather than the record
* alone. Preferences are where account-authored skills live, so "did this
* survive the reload" is a question the caller has to be able to answer —
* see `persistUser`.
* `PATCH /me/preferences` shallow-merges and returns the whole merged object,
* which is where `customSkills` and `customAgents` — every account-authored
* definition — now live: `user_preferences.extra`, a real column in a real
* database rather than a browser key.
*
* The `{ user, persisted, error }` shape is kept because `saveFeedback.js`
* reads it. Over HTTP a write that did not land is a non-2xx and therefore a
* throw, so the success path is unconditionally `persisted: true` — the
* question the shape exists to answer is now answered by whether this
* function resolved at all.
*/
async updatePreferences(patch) {
currentUser = { ...currentUser, preferences: { ...auth.preferences(), ...patch } };
const write = persistUser();
return { user: { ...currentUser }, ...write };
},
isAuthenticated() {
return true;
const preferences = await request('PATCH', '/me/preferences', { body: patch });
currentUser = { ...currentUser, preferences };
cacheUser();
return { user: { ...currentUser }, persisted: true, error: null };
},
/**
* There is no identity provider to sign out of, so this clears the local
* session and returns to the requested page.
* Whether the last `GET /me` succeeded.
*
* Synchronous, and therefore only ever a cache of what the server last said.
* It is a hint for rendering, never a gate: every protected endpoint is
* refused by the API on its own authority regardless of this value.
*/
logout(redirectTo = '/') {
isAuthenticated() {
return authenticated;
},
/**
* Signs out and returns to the requested page.
*
* The server revokes the session row and expires the cookie; this clears the
* cached copy of the user so a signed-out tab cannot render a stale name from
* localStorage. The redirect happens either way — a logout that could not
* reach the API must still leave the browser signed out locally, and the
* cookie it keeps will be refused by every request it is sent on.
*/
async logout(redirectTo = '/admin/login') {
try {
localStorage.removeItem(SESSION_KEY);
await request('POST', '/auth/logout');
} catch {
// Ignore.
// Already signed out, or the API is unreachable. Neither is a reason to
// keep the user looking at a signed-in page.
}
currentUser = { ...DEMO_USER };
window.location.href = typeof redirectTo === 'string' ? redirectTo : '/';
authenticated = false;
forgetUser();
window.location.href = typeof redirectTo === 'string' ? redirectTo : '/admin/login';
},
redirectToLogin() {
window.location.href = '/';
window.location.href = '/admin/login';
},
};
@@ -158,8 +294,27 @@ const analytics = {
export const base44 = { entities, auth, integrations, analytics };
/** Restores the shipped demo data, discarding local edits. */
/** Where the entity data actually comes from, for diagnostics. */
export { API_BASE_URL };
/**
* Clears local session state and reloads.
*
* The demo dataset is no longer the browser's to restore: it lives in
* PostgreSQL, and reseeding it is `make seed` in the `krow-backend` repository,
* which upserts the shipped fixture in one transaction. All this can still do
* is drop the cached user and reload, so it says so rather than reporting a
* reset it did not perform.
*/
export function resetDemoData() {
resetStore(seedData);
try {
localStorage.removeItem(SESSION_KEY);
} catch {
// Ignore.
}
console.info(
'[krow-demo] Local session cache cleared. Entity data lives in PostgreSQL — ' +
'restore the shipped dataset with `make seed` in krow-backend.'
);
window.location.reload();
}

308
src/api/httpClient.js Normal file
View File

@@ -0,0 +1,308 @@
/**
* HTTP transport for the entity API.
*
* This is the module that replaces `store.js`. It exposes the same
* `createEntity(name)` factory with the same six methods and the same
* signatures, so `base44Client.js` swaps one import and nothing above it
* changes — not a hook, not a page, not a component.
*
* Everything here is a faithful translation of what `store.js` did locally into
* what `docs/api-contract.md` specifies over the wire. Where the two could
* differ, the local behaviour wins, because the callers were written against
* it:
*
* - `list`/`filter` return a bare array; `get`/`create`/`update` return a bare
* object; `delete` returns `{ id }`. The API's `{ data, meta }` envelope is
* unwrapped here and never seen above.
* - A failure throws an `Error` whose `message` is the server's message,
* because `store.js` threw and several callers depend on the throw
* (`useQuery`'s `isError`, and half a dozen `.catch(() => …)` fallbacks).
* - The default `sort` and `limit` on every method are the ones `store.js`
* declared, so a call site that passes neither still gets what it always
* got.
*
* The one thing that is genuinely new is the failure mode. A local store could
* not be unreachable; an API can, and "Failed to fetch" names neither the
* problem nor the fix. `request` turns that into a message that says which URL
* did not answer.
*/
/**
* Where the API lives.
*
* A same-origin path, not a host. The browser asks its own origin for
* `/api/v1/...`; in development the Vite proxy forwards that to the Go API (see
* `vite.config.js`), and in production the same path is served by the same
* origin as the app (see `nginx.conf`).
*
* This is a requirement of the session cookie rather than a preference. The
* cookie is HttpOnly with SameSite=Lax, and a Lax cookie is not sent on a
* cross-site request — so a page on `localhost:5173` fetching
* `http://127.0.0.1:8080` would authenticate once at login and then be a
* stranger on every request after it.
*
* VITE_API_BASE_URL can still point somewhere else, and `credentials` below is
* set so that it works, but the cross-origin path needs CORS credentials
* configured on the server and is not the supported arrangement.
*/
const DEFAULT_BASE_URL = '/api/v1';
export const API_BASE_URL = String(
import.meta.env?.VITE_API_BASE_URL || DEFAULT_BASE_URL
).replace(/\/+$/, '');
/**
* Shout if the API has been pointed at another origin.
*
* This exists because the failure it catches is silent and misleading. Set
* `VITE_API_BASE_URL` to `http://127.0.0.1:8080/api/v1` and the browser sends a
* preflight, the server answers it `204`, and then the real request is never
* dispatched at all — because `credentials: 'include'` obliges the browser to
* require `Access-Control-Allow-Credentials: true` on that preflight, and the
* API does not send it. The server log shows an OPTIONS and nothing else; the
* page shows a request that never completes. Nothing names the cause.
*
* Even if CORS were opened up, the session cookie is `SameSite=Lax` and would
* not be sent on a cross-site request, so login would appear to succeed once
* and then every subsequent request would arrive as a stranger.
*
* Development only: `import.meta.env.DEV` is statically replaced at build time,
* so this whole block is dropped from the production bundle.
*/
if (import.meta.env?.DEV && /^https?:\/\//i.test(API_BASE_URL)) {
const sameOrigin =
typeof window !== 'undefined' && API_BASE_URL.startsWith(window.location.origin);
if (!sameOrigin) {
console.error(
`[krow] VITE_API_BASE_URL is "${API_BASE_URL}", which is a different origin ` +
`from ${typeof window !== 'undefined' ? window.location.origin : 'this page'}. ` +
'The session cookie will not work: the login POST is blocked at the CORS ' +
'preflight, and a SameSite=Lax cookie would not be sent cross-site anyway. ' +
'Set VITE_API_BASE_URL=/api/v1 in .env and restart the dev server so requests ' +
'go through the Vite proxy.'
);
}
}
/**
* Entity name → the contract's resource path (§1: kebab-case plural, mass nouns
* singular).
*
* Declared rather than derived. A rule that turns `AIInterview` into
* `ai-interviews` and `Staff` into `staff` and `UserActivity` into
* `user-activity` is three special cases wearing a trench coat, and a wrong
* guess here is a 404 at runtime instead of a mistake anyone can see.
*/
const RESOURCE_PATHS = {
JobPosting: 'job-postings',
JobApplication: 'job-applications',
AIInterview: 'ai-interviews',
Staff: 'staff',
WorkerProfile: 'worker-profiles',
Course: 'courses',
Badge: 'badges',
LearningPath: 'learning-paths',
Certification: 'certifications',
RoleCategory: 'role-categories',
UserActivity: 'user-activity',
Evidence: 'evidence',
User: 'users',
Assignment: 'assignments',
ShiftRecord: 'shift-records',
};
/* ── Request ────────────────────────────────────────────────────────────── */
/**
* Builds a query string with the contract's filter encoding (§6).
*
* An array value becomes a repeated parameter — `?status=applied&status=hired`
* — which the server reads as `col = ANY(...)`, matching `store.js`'s
* `want.includes(got)`. `undefined` is omitted entirely: `store.js` compared
* `got === undefined` against real values and matched nothing, and sending the
* string "undefined" would be a filter on a value no column holds.
*/
function queryString(params) {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value === undefined) continue;
if (Array.isArray(value)) {
for (const item of value) {
if (item !== undefined) search.append(key, String(item));
}
continue;
}
search.append(key, String(value));
}
const encoded = search.toString();
return encoded ? `?${encoded}` : '';
}
/**
* The error a non-2xx becomes.
*
* `message` is the server's message verbatim, because §5.1 makes it
* load-bearing: `store.js` threw `"<Entity> <id> not found"` and the API
* reproduces that string exactly. The code, HTTP status and per-field details
* ride along as properties — new information a local store never had, and
* additive, so nothing that only reads `.message` notices.
*/
function apiError(status, payload) {
const body = payload?.error;
const error = new Error(body?.message || `Request failed with status ${status}`);
error.name = 'KrowApiError';
error.status = status;
error.code = body?.code || 'internal';
error.details = body?.details || {};
return error;
}
/**
* One request, unwrapped.
*
* Returns `payload.data`, so every caller above works in bare records exactly
* as it did against the local store. `meta` is deliberately dropped: nothing
* reads it (§4.2), and surfacing it would mean changing what the six methods
* return, which is the one thing Phase 2D must not do.
*/
async function request(method, path, { query, body } = {}) {
const url = `${API_BASE_URL}${path}${query ? queryString(query) : ''}`;
let response;
try {
response = await fetch(url, {
method,
// The session cookie is HttpOnly: this code cannot read it, attach it by
// hand, or store it. `credentials` is the only lever there is, and
// without it `fetch` omits cookies on cross-origin requests entirely.
// Same-origin — the supported arrangement — would send them anyway;
// saying so explicitly means the one line that makes authentication work
// is visible rather than implied.
credentials: 'include',
headers: body === undefined
? { Accept: 'application/json' }
: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
} catch (cause) {
// A transport failure, not an API response: no status, no envelope. The
// browser's own message for this is "Failed to fetch", which says nothing
// about which server or why, and it is nearly always the same cause — the
// API is not running.
const error = new Error(
`Cannot reach the Krow API at ${API_BASE_URL}. Is the Go API running on ` +
`127.0.0.1:8080, and is the Vite dev server proxying /api to it? ` +
`(${method} ${path})`
);
error.name = 'KrowApiError';
error.status = 0;
error.code = 'unreachable';
error.details = {};
error.cause = cause;
throw error;
}
const text = await response.text();
let payload = null;
if (text) {
try {
payload = JSON.parse(text);
} catch {
payload = null;
}
}
if (!response.ok) throw apiError(response.status, payload);
if (payload === null) {
const error = new Error(`${method} ${path} returned no JSON body`);
error.name = 'KrowApiError';
error.status = response.status;
error.code = 'internal';
error.details = {};
throw error;
}
return payload.data;
}
/** The request helper, for the `auth` surface in `base44Client.js`. */
export { request };
/**
* True when an error is the API saying "you are not signed in".
*
* A 401 is an ordinary, expected answer here — it is what every request gets
* before the first login and after a session expires — so callers need to tell
* it apart from a real failure rather than treating both as "something broke".
*/
export function isUnauthenticated(error) {
return Boolean(error) && (error.status === 401 || error.code === 'unauthorized');
}
/* ── Entity API ─────────────────────────────────────────────────────────── */
/**
* Builds the client surface for one entity.
*
* Signature-compatible with `store.js`'s `createEntity`, defaults included. The
* defaults matter more than they look: `store.js` declared
* `list(sort = '-created_date', limit = 100)`, and several call sites rely on
* them rather than passing their own.
*/
export function createEntity(name) {
const path = RESOURCE_PATHS[name];
if (!path) throw new Error(`No API resource path is declared for entity ${name}`);
const base = `/${path}`;
return {
entityName: name,
/**
* `sort` is always sent, even when empty. `?sort=` is not the same as
* omitting it: the contract reads an explicit empty value as "no ordering",
* which is what `applySort` did with a falsy sort, while omitting it would
* apply the endpoint's default.
*/
async list(sort = '-created_date', limit = 100) {
return request('GET', base, { query: { sort, limit } });
},
async filter(query = {}, sort = '-created_date', limit = 100) {
// Spread first so a field genuinely named `sort`, `limit` or `offset`
// could never shadow the reserved parameters (§1 records that no column
// collides with them today; this keeps that true if one ever does).
return request('GET', base, { query: { ...query, sort, limit } });
},
async get(id) {
return request('GET', `${base}/${encodeURIComponent(id)}`);
},
async create(data) {
return request('POST', base, { body: data });
},
async update(id, data) {
return request('PATCH', `${base}/${encodeURIComponent(id)}`, { body: data });
},
async delete(id) {
return request('DELETE', `${base}/${encodeURIComponent(id)}`);
},
/**
* Sequential creates, exactly as `store.js` did it.
*
* Not a batch endpoint and not `Promise.all`: the contract has no bulk
* write (§12.1), and doing them one at a time keeps the failure behaviour
* identical — the first rejection stops the run and the records before it
* are already written.
*/
async bulkCreate(records = []) {
const created = [];
for (const record of records) created.push(await this.create(record));
return created;
},
};
}

View File

@@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { Outlet } from 'react-router-dom';
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { useAuth } from '@/lib/AuthContext';
import UserNotRegisteredError from '@/components/UserNotRegisteredError';
@@ -9,8 +9,24 @@ const DefaultFallback = () => (
</div>
);
/**
* `unauthenticatedElement` defaults to the login page.
*
* It used to default to `undefined`, which renders nothing. That was invisible
* while the data client reported the seeded user as permanently signed in and
* this branch was unreachable; now that `GET /me` can genuinely answer 401, the
* default is what a signed-out visitor actually sees, and a blank screen is not
* an acceptable answer to "you are not signed in".
*
* The attempted path travels in location state so signing in returns the
* visitor to where they were going.
*/
export default function ProtectedRoute({ fallback = <DefaultFallback />, unauthenticatedElement }) {
const location = useLocation();
const { isAuthenticated, isLoadingAuth, authChecked, authError, checkUserAuth } = useAuth();
const signedOut = unauthenticatedElement ?? (
<Navigate to="/admin/login" replace state={{ from: location.pathname }} />
);
useEffect(() => {
if (!authChecked && !isLoadingAuth) {
@@ -26,11 +42,11 @@ export default function ProtectedRoute({ fallback = <DefaultFallback />, unauthe
if (authError.type === 'user_not_registered') {
return <UserNotRegisteredError />;
}
return unauthenticatedElement;
return signedOut;
}
if (!isAuthenticated) {
return unauthenticatedElement;
return signedOut;
}
return <Outlet />;

View File

@@ -23,12 +23,18 @@ export function AdminPage({ title, subtitle, meta, actions, tabs, children, clas
<div className={cn('space-y-6', className)}>
<header className="flex flex-wrap items-center justify-between gap-4 border-b border-border/50 pb-4">
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2.5">
{/* Wraps rather than compresses. The row carries three things of very
different lengths — the page name, its count and the status pill —
and on a 390px screen holding them on one line meant the count
breaking mid-phrase and the pill splitting into "Live / System".
Nothing wraps at any width where all three fit, so every desktop
layout is untouched. */}
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1.5">
<h1 className="font-heading text-title-lg font-bold tracking-tight text-ink-1 sm:text-2xl">{title}</h1>
{/* A count of whatever the page is a list of, stated beside its
name rather than only inside the list. */}
{meta && <span className="text-body-sm tabular-nums text-ink-4">{meta}</span>}
<span className="inline-flex items-center gap-1.5 rounded-full bg-blue-50 px-2.5 py-0.5 text-[11px] font-semibold text-blue-600 dark:bg-blue-950/60 dark:text-blue-400 border border-blue-200/60 dark:border-blue-800/40">
{meta && <span className="whitespace-nowrap text-body-sm tabular-nums text-ink-4">{meta}</span>}
<span className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full bg-blue-50 px-2.5 py-0.5 text-[11px] font-semibold text-blue-600 dark:bg-blue-950/60 dark:text-blue-400 border border-blue-200/60 dark:border-blue-800/40">
<span className="h-1.5 w-1.5 rounded-full bg-blue-600 dark:bg-blue-400" />
Live System
</span>
@@ -55,7 +61,10 @@ export function AdminPage({ title, subtitle, meta, actions, tabs, children, clas
export function SectionTitle({ id, title, meta, action, className }) {
return (
<div className={cn('flex flex-wrap items-baseline justify-between gap-2', className)}>
<div className="flex items-baseline gap-2">
{/* The count wraps under the section name rather than competing with it
for a 390px line. On any width where both fit — every desktop and
tablet layout — nothing wraps and the row is the row it always was. */}
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2">
<h2 id={id} className="font-heading text-body font-semibold text-ink-1">{title}</h2>
{meta && <span className="text-caption text-ink-4">{meta}</span>}
</div>

View File

@@ -4,6 +4,7 @@ import { cn } from '@/lib/utils';
import KrowAssistant from './KrowAssistant';
import { useAssistantPanel } from './AssistantPanelContext';
import { ResizeDivider } from './ResizeDivider';
import { useIsPhone, useViewportWidth, useVisualViewport } from './viewport';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
const EXPANDED_WIDTH = 620;
@@ -18,32 +19,6 @@ const LEAD_PADDING = 8;
const GUTTER = HANDLE_WIDTH + LEAD_PADDING;
/** Expanded must never dominate: the dashboard stays the primary experience. */
const MAX_VIEWPORT_SHARE = 0.42;
/**
* Below this width there is no room for a column *beside* the dashboard — a
* 380px track on a 375px phone collapses `main` to nothing — so Owliver stacks
* underneath the page instead.
*
* Deliberately Tailwind's `md` (768px), not `lg`: tablets already lay the inline
* column out acceptably, so they keep the two-column workspace and only phones
* stack.
*/
const STACK_BREAKPOINT = 768;
/** Tracks viewport width so the panel can be clamped and the layout switched. */
function useViewportWidth() {
const [width, setWidth] = React.useState(() =>
typeof window === 'undefined' ? 1440 : window.innerWidth
);
React.useEffect(() => {
const onResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return width;
}
/**
* The collapsed state — a compact docked trigger.
*
@@ -64,7 +39,12 @@ function CollapsedTrigger({ page, onRestore }) {
onClick={onRestore}
aria-label={`Show the Owliver workspace for ${page}`}
aria-expanded={false}
className="group fixed bottom-5 right-5 z-30 inline-flex items-center gap-2 rounded-full border border-border
/* `bottom` is a `max()` against the bottom safe-area inset rather than a
flat 20px: on a phone with a home indicator a flat offset puts the pill
under the gesture bar, where the tap belongs to the OS. `env()` is 0
everywhere else, so desktop keeps the offset it always had. */
className="group fixed bottom-[max(1.25rem,env(safe-area-inset-bottom))] right-[max(1.25rem,env(safe-area-inset-right))]
z-30 inline-flex items-center gap-2 rounded-full border border-border
bg-surface py-2 pl-2 pr-3.5 shadow-md transition-[box-shadow,border-color] duration-base
hover:border-krow-blue/40 hover:shadow-lg
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
@@ -77,11 +57,105 @@ function CollapsedTrigger({ page, onRestore }) {
}
/**
* AssistantPanel — the Owliver workspace column the Admin layout renders.
* MobileWorkspace — Owliver on a phone.
*
* Owliver is part of the page on every supported route, so this is a column in
* the layout rather than an overlay: the dashboard reflows beside it instead of
* being covered, and collapsing restores the original layout exactly.
* The same `KrowAssistant`, the same context, the same runtime, the same
* conversation. What changes is only where it is mounted: on a phone the
* workspace is an overlay above the page rather than a column beside it.
*
* That is forced by arithmetic, not taste. The desktop workspace is
* `main + 400px`; at 375px the 400px track leaves `main` negative, so the two
* surfaces stop being a layout and start being a fight over the same pixels —
* which is exactly what the broken state was. An overlay takes the page out of
* that arithmetic entirely: the page stays `width: 100%` whether Owliver is
* open or closed, and there is never a reserved column standing empty.
*
* Three things this is deliberately not:
*
* - Not a second chat. Nothing about the assistant is re-implemented; this
* component is a positioned container and nothing else.
* - Not a takeover. It stops below the app header, so the reader can still see
* where they are and can still leave.
* - Not a fixed height. It is sized to `visualViewport` where that exists and
* to `100dvh` where it does not, so an open keyboard shortens the sheet
* instead of pushing the composer off the bottom of it.
*/
function MobileWorkspace({ context, onClose }) {
const viewport = useVisualViewport();
/* The page behind an overlay must not scroll: on a touch screen a drag that
starts on the scrim and lands on the page is otherwise indistinguishable
from scrolling the conversation, and the reader loses their place on both
surfaces at once. Restored exactly as found — another overlay may already
own it. */
React.useEffect(() => {
const { body } = document;
const previous = body.style.overflow;
body.style.overflow = 'hidden';
return () => { body.style.overflow = previous; };
}, []);
return (
<div
className="fixed inset-x-0 top-0 z-50 h-[100dvh] md:hidden"
style={viewport ? { height: viewport.height, top: viewport.offsetTop } : undefined}
>
{/* Tapping the page dismisses, which is what a sheet over a page should
do. A button rather than a bare div so it is reachable without a
pointer. */}
<button
type="button"
aria-label="Close the Owliver workspace"
onClick={onClose}
className="absolute inset-0 bg-ink-1/30 backdrop-blur-[2px] motion-safe:animate-fade-in"
/>
{/* `top-14` is the header's own height: the sheet starts under the app
bar rather than over it, so navigation is never covered. `min-h-0` is
what lets the assistant's internal scroller own the overflow instead
of the sheet growing past the viewport. */}
<div
className="absolute inset-x-0 bottom-0 top-14 flex min-h-0 flex-col px-3
pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3"
role="dialog"
aria-modal="true"
aria-label="Owliver workspace"
>
<KrowAssistant
key={context.id}
context={context}
/* Expanded is a desktop-only width state; there is no wider to go
here, so the control is not offered rather than offered and inert. */
expanded={false}
onClose={onClose}
onExpand={null}
onRestore={null}
className="h-full min-h-0"
/>
</div>
</div>
);
}
/**
* AssistantPanel — the Owliver workspace the Admin layout renders.
*
* One panel, two presentations, chosen by how much room there is beside the
* page rather than by what kind of device is asking:
*
* ≥ 768px a column in the layout. Owliver is part of the page on every
* supported route, so the dashboard reflows beside it instead of
* being covered, and collapsing restores the original layout
* exactly. This is the protected desktop geometry and everything
* below describes it.
* < 768px an overlay (`MobileWorkspace`), because a 400px track does not fit
* beside anything on a 375px phone. The page is `width: 100%` in
* both states and never participates in a two-column width
* calculation it cannot satisfy.
*
* Both presentations mount the same `KrowAssistant` with the same context and
* read the same open/collapsed state, so there is one assistant in the product
* and one set of actions that change it.
*
* Five structural details matter, and every one of them was a bug at some point:
*
@@ -111,10 +185,24 @@ export function AssistantPanel({ stickyClassName, panelHeightClassName }) {
setWidth, resetWidth, open, close, expand, restore,
} = useAssistantPanel();
const viewportWidth = useViewportWidth();
const isPhone = useIsPhone();
// No assistant on this route: no column, no rail, no trace in the layout.
if (!context) return null;
/* Phones: Owliver is never a column, in either state.
Closed, the layout is one column and `main` has the whole viewport — there
is no reserved 400px gutter to leave a blank strip down the right. Open,
the workspace is an overlay, so the page keeps that full width underneath
rather than being asked to share it with a track wider than the phone.
Both states are rendered from the same panel state the desktop column uses,
so opening, collapsing and reopening are the same three actions here. */
if (isPhone) {
return isOpen
? <MobileWorkspace context={context} onClose={close} />
: <CollapsedTrigger page={context.page} onRestore={open} />;
}
/* Expanded overrides the dragged width; otherwise the user's own width wins.
Both are clamped against the viewport so the dashboard is never squeezed. */
const viewportCap = Math.round(viewportWidth * MAX_VIEWPORT_SHARE);
@@ -131,25 +219,6 @@ export function AssistantPanel({ stickyClassName, panelHeightClassName }) {
const trackWidth = panelWidth + GUTTER;
/* Phones: stack Owliver under the dashboard. A 380px column beside the page is
not a layout at this width, and an overlay that opens on load would put a
sheet between the user and the page they asked for. */
if (viewportWidth < STACK_BREAKPOINT) {
return (
<aside aria-label="Owliver workspace" className="pb-6 pt-2">
<KrowAssistant
key={context.id}
context={context}
expanded={false}
onClose={close}
onExpand={null}
onRestore={null}
className="h-[32rem]"
/>
</aside>
);
}
return (
<aside
aria-label="Owliver workspace"

View File

@@ -1,6 +1,7 @@
import * as React from 'react';
import { base44 } from '@/api/base44Client';
import { resolveAssistantContext } from './placement';
import { PHONE_QUERY } from './viewport';
import { PageContextProvider } from './PageContext';
import { AgentProvider } from './AgentContext';
@@ -58,6 +59,24 @@ function writeFlag(key, value) {
}
}
/**
* Whether this session is starting on a phone.
*
* Read once, to pick the *default* — never to decide how the panel renders.
* That decision belongs to `AssistantPanel`, which watches the same query and
* re-renders when it changes; a resize past the breakpoint must move the panel
* between presentations without silently reopening or closing it.
*
* The same media query the layout switches on, imported rather than repeated.
*/
function startsOnPhone() {
try {
return window.matchMedia(PHONE_QUERY).matches;
} catch {
return false;
}
}
export function AssistantPanelProvider({ role, pathname, children }) {
/* Two different things, deliberately kept apart: the account preference is
whether Owliver *should* open by default, and the session flag is whether
@@ -65,8 +84,19 @@ export function AssistantPanelProvider({ role, pathname, children }) {
panel collapsed for the afternoon does not rewrite the account setting, and
an account with Owliver off does not get the panel pushed back at it on
every new tab. */
/* The default differs by presentation, and only the default.
On the desktop workspace Owliver *is* part of the page, so it opens with
it. On a phone the workspace is an overlay (see AssistantPanel), and an
overlay that opens on load is a sheet between the reader and the page they
asked for — so a phone starts on the docked pill, one tap from the same
conversation. Everything after that first render is the shared state:
opening on a phone is the same `open()` the desktop header calls, and the
session remembers it either way. */
const [open, setOpen] = React.useState(
() => readFlag(OPEN_KEY, base44.auth.preferences().owliverDefault !== false)
() => readFlag(
OPEN_KEY,
!startsOnPhone() && base44.auth.preferences().owliverDefault !== false
)
);
const [expanded, setExpanded] = React.useState(() => readFlag(EXPANDED_KEY));
const [width, setWidth] = React.useState(() => {

View File

@@ -0,0 +1,102 @@
/**
* Viewport facts the Owliver workspace is laid out from.
*
* Separate from both the panel and its context because both need them: the
* panel decides which presentation to render, the provider decides which state
* to start in, and a breakpoint that two modules each keep their own copy of is
* a breakpoint that will eventually disagree with itself. Keeping it here also
* keeps the two from importing each other in a cycle.
*/
import * as React from 'react';
/**
* Below this width there is no room for a column *beside* the dashboard — a
* 380px track on a 375px phone collapses `main` to nothing — so Owliver stops
* being a column at all and becomes an overlay over the page.
*
* Deliberately Tailwind's `md` (768px), not `lg`: tablets already lay the inline
* column out acceptably, so they keep the two-column workspace and only phones
* change presentation. Exported so the provider can start a phone with the
* workspace closed from the same number the layout switches on — two places
* asking "is this a phone" must never be able to disagree.
*/
export const STACK_BREAKPOINT = 768;
export const PHONE_QUERY = `(max-width: ${STACK_BREAKPOINT - 1}px)`;
/** Tracks viewport width so the panel can be clamped. */
export function useViewportWidth() {
const [width, setWidth] = React.useState(() =>
typeof window === 'undefined' ? 1440 : window.innerWidth
);
React.useEffect(() => {
const onResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return width;
}
/**
* Whether the shell is in its phone layout.
*
* `matchMedia` rather than a width comparison, so the switch happens on exactly
* the same boundary the stylesheet uses. `window.innerWidth` is not that
* boundary on a phone: when anything on the page overflows, mobile browsers
* widen the layout viewport to fit it, `innerWidth` reports the widened value,
* and the layout that was supposed to rescue the page reads the viewport as a
* tablet and hands it a 400px sidebar instead. A media query is evaluated
* against the media, not against the content, so it cannot be talked out of the
* mobile layout by the very overflow the mobile layout exists to prevent.
*/
export function useIsPhone() {
const [phone, setPhone] = React.useState(() => {
try {
return window.matchMedia(PHONE_QUERY).matches;
} catch {
return false;
}
});
React.useEffect(() => {
const mql = window.matchMedia(PHONE_QUERY);
const onChange = (e) => setPhone(e.matches);
setPhone(mql.matches);
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}, []);
return phone;
}
/**
* The height the user can actually see, while a keyboard is open.
*
* `100dvh` follows the address bar but knows nothing about the on-screen
* keyboard: with the keyboard up, a `100dvh` sheet keeps its full height and
* puts its own composer underneath the keys — the one control the sheet exists
* to offer. `visualViewport` is the region left over, so the sheet is sized to
* that and the composer stays on screen.
*
* Returns `null` where the API is missing, and the caller falls back to the
* `100dvh` class. Nothing here is a fixed pixel height.
*/
export function useVisualViewport() {
const [box, setBox] = React.useState(null);
React.useEffect(() => {
const vv = window.visualViewport;
if (!vv) return undefined;
const update = () => setBox({ height: Math.round(vv.height), offsetTop: Math.round(vv.offsetTop) });
update();
vv.addEventListener('resize', update);
vv.addEventListener('scroll', update);
return () => {
vv.removeEventListener('resize', update);
vv.removeEventListener('scroll', update);
};
}, []);
return box;
}

View File

@@ -456,7 +456,7 @@ export function DepartmentPerformance({ items = [], className = '' }) {
className="overflow-hidden rounded-xl border border-border bg-surface"
>
<div className="overflow-x-auto">
<table className="w-full text-left text-body-sm">
<table className="w-full min-w-[40rem] text-left text-body-sm">
<thead>
<tr className="border-b border-border bg-surface-subtle text-[10px] font-semibold uppercase tracking-wider text-ink-4">
<th className="px-4 py-3">Rank</th>

View File

@@ -194,7 +194,7 @@ export function DataTable({
<LoadingOverlay show={refreshing} />
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<table className="w-full min-w-[36rem] border-collapse">
{caption && <caption className="sr-only">{caption}</caption>}
<thead className={cn(stickyHeader && 'sticky top-0 z-10')}>

View File

@@ -83,7 +83,14 @@ export function MetricStrip({ items = [], columns, className, loading = false })
<div>
{/* Header: Label & Icon */}
<div className="flex items-center justify-between gap-1.5">
<p className="truncate text-[10px] font-bold uppercase tracking-wider text-ink-4">
{/* Wraps on a phone, clips on everything wider.
Two of these sit side by side at 320px, which leaves about
110px for the label — enough for "OPEN POSITIONS" over two
lines and not enough for it on one, so a flat `truncate`
turned every metric on the Control Center into "OPEN POSI…".
From `sm` the strip is three or six across with room for the
label on one line, which is where clipping was the intent. */}
<p className="min-w-0 line-clamp-2 text-[10px] font-bold uppercase tracking-wider text-ink-4 sm:line-clamp-1">
{item.label}
</p>
{IconComponent && (

View File

@@ -43,7 +43,11 @@ export function Modal({
return (
<Dialog open={open} onOpenChange={busy ? undefined : onOpenChange}>
<DialogContent
className={cn('p-0 gap-0 overflow-hidden max-h-[90vh] flex flex-col', WIDTHS[size], className)}
/* `dvh`, not `vh`: on a phone `100vh` is the *large* viewport, which
includes the browser chrome that is currently covering the bottom of
the screen — so a `90vh` dialog put its footer, and its confirm
button, under the address bar. */
className={cn('p-0 gap-0 overflow-hidden max-h-[calc(100dvh-2rem)] flex flex-col', WIDTHS[size], className)}
onInteractOutside={busy ? (e) => e.preventDefault() : undefined}
onEscapeKeyDown={busy ? (e) => e.preventDefault() : undefined}
>

View File

@@ -80,7 +80,7 @@ export default function ActivityLogTable({ activities }) {
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-[13px]">
<table className="w-full min-w-[40rem] text-[13px]">
<thead className="bg-[#F9FAFB] text-[#6B7280]">
<tr>
<th className="text-left font-medium px-4 py-2.5">Event</th>

View File

@@ -139,8 +139,16 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
return (
<div className={cn('border rounded-xl p-4 transition-all hover:shadow-sm', style.card)}>
{/* The row wraps below `sm`, and only below `sm`.
Its fixed furniture — rank, portrait, score ring and the action
cluster — adds up to about 400px before the name has a single pixel,
so on a 320px phone the flexible middle was being squeezed to nothing
and the candidate's name truncated to one character while the buttons
hung off the side of the screen. Wrapping gives the name the width and
moves the controls to their own line; `sm:flex-nowrap` restores the
single desktop row exactly. */}
<div
className="flex items-center gap-4 cursor-pointer"
className="flex flex-wrap items-center gap-3 cursor-pointer sm:flex-nowrap sm:gap-4"
onClick={() => setExpanded(!expanded)}
>
{/* Rank */}
@@ -149,7 +157,7 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
</div>
{/* Avatar — 88-110px target size */}
<div className="w-20 h-20 sm:w-24 sm:h-24 rounded-2xl bg-[#E5E7EB] flex items-center justify-center text-white font-semibold text-[15px] shrink-0 overflow-hidden shadow-sm">
<div className="w-16 h-16 sm:w-24 sm:h-24 rounded-2xl bg-[#E5E7EB] flex items-center justify-center text-white font-semibold text-[15px] shrink-0 overflow-hidden shadow-sm">
<Image
src={application.selfie_url || `https://images.unsplash.com/${getPortraitSeed(application.applicant_name)}?w=200&h=200&fit=crop&crop=faces`}
alt={application.applicant_name}
@@ -158,8 +166,10 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
/>
</div>
{/* Info */}
<div className="flex-1 min-w-0">
{/* Info. `basis-40` rather than the flex default of 0 so the name has a
floor to claim on a phone — under it the row wraps instead of
shaving the one column that carries meaning. */}
<div className="flex-1 basis-40 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold text-[#111827] text-[14px] truncate">{application.applicant_name}</h3>
<span className={cn('px-2 py-0.5 rounded-full text-[10px] font-medium', style.badge)}>{style.label}</span>
@@ -201,76 +211,84 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
)}
</div>
{/* Score */}
{status !== 'applied' && <ScoreRing score={score} />}
{/* Score and actions.
`sm:contents` dissolves this wrapper from the tablet breakpoint up,
so above `sm` the ring and the buttons are direct children of the
row exactly as they always were — same order, same gaps, same
desktop geometry. Below it they travel together onto the row's
second line. */}
<div className="flex w-full items-center justify-between gap-3 sm:contents">
{/* Score */}
{status !== 'applied' && <ScoreRing score={score} />}
{/* Actions */}
<div className="flex items-center gap-2 shrink-0" onClick={(e) => e.stopPropagation()}>
{/* The full record, at the route that already serves it.
Clicking the row expands a summary in place, which is the right
default while comparing people — but the complete profile had no
way in from this list at all, so the same candidate opened from a
recommendation and from here led to two different depths of the
same person. One link, one route, one profile. */}
<button
type="button"
onClick={() => navigate(`/admin/candidates/${application.id}`)}
className="hidden sm:inline-flex items-center gap-1 rounded-lg px-2.5 py-1.5 text-[12px] font-semibold text-[#0838E0]
transition-colors hover:bg-[#EEF3FE] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0838E0]/40"
>
View profile
<ArrowRight className="w-3 h-3" aria-hidden="true" />
</button>
{onAction && canTakeAction && (
{/* Actions */}
<div className="flex items-center gap-2 shrink-0" onClick={(e) => e.stopPropagation()}>
{/* The full record, at the route that already serves it.
Clicking the row expands a summary in place, which is the right
default while comparing people — but the complete profile had no
way in from this list at all, so the same candidate opened from a
recommendation and from here led to two different depths of the
same person. One link, one route, one profile. */}
<button
onClick={() => onAction(application)}
className="px-3 py-1.5 rounded-lg bg-[#0838E0] hover:bg-[#062BAF] text-white text-[12px] font-medium transition-colors whitespace-nowrap"
type="button"
onClick={() => navigate(`/admin/candidates/${application.id}`)}
className="hidden sm:inline-flex items-center gap-1 rounded-lg px-2.5 py-1.5 text-[12px] font-semibold text-[#0838E0]
transition-colors hover:bg-[#EEF3FE] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0838E0]/40"
>
{status === 'interview' ? 'AI Interview' : 'Interview'}
View profile
<ArrowRight className="w-3 h-3" aria-hidden="true" />
</button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="inline-flex items-center gap-1 px-3 py-1.5 rounded-lg border border-[#E5E7EB] bg-white hover:bg-[#F9FAFB] text-[#374151] text-[12px] font-medium transition-colors">
Actions
<ChevronDown className="w-3.5 h-3.5" />
{onAction && canTakeAction && (
<button
onClick={() => onAction(application)}
className="px-3 py-1.5 rounded-lg bg-[#0838E0] hover:bg-[#062BAF] text-white text-[12px] font-medium transition-colors whitespace-nowrap"
>
{status === 'interview' ? 'AI Interview' : 'Interview'}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52 p-1.5 bg-white border border-[#E5E7EB] shadow-xl rounded-xl">
<DropdownMenuItem onClick={handleMessage} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
<MessageSquare className="w-4 h-4 mr-2.5 text-[#0838E0]" />
<span className="text-[#374151] text-[13px] font-medium">Message</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleCall} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
<Phone className="w-4 h-4 mr-2.5 text-[#059669]" />
<span className="text-[#059669] text-[13px] font-medium">Call</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleSchedule} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
<Calendar className="w-4 h-4 mr-2.5 text-[#0838E0]" />
<span className="text-[#0838E0] text-[13px] font-medium">Set up interview</span>
</DropdownMenuItem>
{onHire && (
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onHire(application); }} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
<UserCheck className="w-4 h-4 mr-2.5 text-[#059669]" />
<span className="text-[#059669] text-[13px] font-medium">Hire candidate</span>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="inline-flex items-center gap-1 px-3 py-1.5 rounded-lg border border-[#E5E7EB] bg-white hover:bg-[#F9FAFB] text-[#374151] text-[12px] font-medium transition-colors">
Actions
<ChevronDown className="w-3.5 h-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52 p-1.5 bg-white border border-[#E5E7EB] shadow-xl rounded-xl">
<DropdownMenuItem onClick={handleMessage} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
<MessageSquare className="w-4 h-4 mr-2.5 text-[#0838E0]" />
<span className="text-[#374151] text-[13px] font-medium">Message</span>
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={handleDecline} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
<X className="w-4 h-4 mr-2.5 text-[#DC2626]" />
<span className="text-[#DC2626] text-[13px] font-medium">Decline candidate</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleCall} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
<Phone className="w-4 h-4 mr-2.5 text-[#059669]" />
<span className="text-[#059669] text-[13px] font-medium">Call</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleDelete} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
<Trash2 className="w-4 h-4 mr-2.5 text-[#DC2626]" />
<span className="text-[#DC2626] text-[13px] font-medium">Delete candidate</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenuItem onClick={handleSchedule} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
<Calendar className="w-4 h-4 mr-2.5 text-[#0838E0]" />
<span className="text-[#0838E0] text-[13px] font-medium">Set up interview</span>
</DropdownMenuItem>
{onHire && (
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onHire(application); }} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
<UserCheck className="w-4 h-4 mr-2.5 text-[#059669]" />
<span className="text-[#059669] text-[13px] font-medium">Hire candidate</span>
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={handleDecline} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
<X className="w-4 h-4 mr-2.5 text-[#DC2626]" />
<span className="text-[#DC2626] text-[13px] font-medium">Decline candidate</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleDelete} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
<Trash2 className="w-4 h-4 mr-2.5 text-[#DC2626]" />
<span className="text-[#DC2626] text-[13px] font-medium">Delete candidate</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
{expanded && <CandidateExpandedDetails application={application} />}

View File

@@ -71,7 +71,7 @@ export default function TalentDetailModal({ open, onClose, profile, onContact, o
const identityClass = identityVerified ? 'bg-[#EEF3FE] text-[#1E40AF]' : 'bg-[#FFF7ED] text-[#B45309]';
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto p-0">
<DialogContent className="max-w-2xl max-h-[calc(100dvh-2rem)] overflow-y-auto p-0">
<DialogHeader className="sr-only">
<DialogTitle>{profile.full_name} — KROW Identity</DialogTitle>
</DialogHeader>

View File

@@ -164,7 +164,7 @@ function TableSection({ data }) {
return (
<div className="overflow-x-auto rounded-xl border border-border">
<table className="w-full border-collapse text-body-sm">
<table className="w-full min-w-[32rem] border-collapse text-body-sm">
<thead>
<tr className="border-b border-border bg-surface-subtle">
{columns.map((c) => (

View File

@@ -33,7 +33,7 @@ const DialogContent = React.forwardRef(({ className, children, ...props }, ref)
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
"fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg max-h-[calc(100dvh-2rem)] overflow-y-auto translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 sm:w-full data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}>

View File

@@ -195,6 +195,43 @@
body {
@apply bg-background text-foreground font-body;
/* Horizontal safe area, declared once for the whole product.
`env()` resolves to 0 on every device without a notch or rounded corner,
so this is inert on a desktop browser and only ever adds the inset a
landscape phone actually needs. Applied to `body` rather than to each
shell because the shells differ and the inset does not. */
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
/**
* A grid column that is allowed to be narrower than its contents.
*
* This is the single largest source of horizontal overflow in the product,
* and it is structural rather than a mistake anyone made on a page.
*
* `grid gap-6 xl:grid-cols-2` — the shape used all over Admin — declares no
* column track below `xl`, so on a phone the browser creates one *implicit*
* column sized by `grid-auto-columns: auto`. An `auto` track's base size is
* the min-content of what it holds, and a track never shrinks below its base
* size: a 436px card in a 328px column simply overflows the column, the
* column overflows `main`, `main` overflows the shell, and the whole page
* ends up wider than the phone. Chrome then widens the layout viewport to
* match, which is why the symptom reads as "the desktop layout, clipped"
* rather than as one card sticking out.
*
* `minmax(0, 1fr)` is what Tailwind's own `grid-cols-1` compiles to, so this
* says: an implicit column behaves exactly like a declared one. Above the
* breakpoint the track is explicit (`grid-cols-*` sets
* `grid-template-columns`) and this rule does not participate, which is what
* keeps every desktop grid byte-identical.
*
* The alternative was adding `grid-cols-1` to ~35 call sites and to every
* grid written afterwards. This is the same fix, declared once, in the layer
* the rest of the layout contract already lives in.
*/
.grid {
grid-auto-columns: minmax(0, 1fr);
}
/**

View File

@@ -183,7 +183,11 @@ export default function AdminLayout() {
An opaque shell here would cover it and every Admin page would lose the
tint at once — which is exactly why the canvas is one layer and not a
background repeated on each page. */}
<div className="min-h-screen">
{/* `dvh` rather than `vh`: `100vh` on a phone is the viewport with the
browser chrome retracted, so a `min-h-screen` shell is always taller
than the screen actually showing it and every page starts life
scrollable by the height of the address bar. */}
<div className="min-h-dvh">
{/* Single bar header in the new Admin design system */}
<header className="sticky top-0 z-40 border-b border-border bg-white/85 backdrop-blur-xl">
<div className="flex h-14 items-center justify-between gap-4 px-4 lg:px-6">
@@ -323,9 +327,20 @@ export default function AdminLayout() {
</SheetContent>
</Sheet>
{/* Main layout body */}
{/* Main layout body.
One column on a phone and two from `md`, which is the same
breakpoint AssistantPanel switches presentation on: below it Owliver
is an overlay and contributes nothing to this row, so `main` has the
whole shell rather than sharing it with a track wider than the
screen.
The extra bottom padding is for the docked Owliver pill, which is
fixed to the bottom-right corner. Without it the last row of a list
sits underneath the pill and cannot be read. Phone only: the desktop
spacing is unchanged. */}
<div className="flex flex-col px-4 md:flex-row lg:px-6">
<main className="min-w-0 flex-1 py-6">
<main className="min-w-0 flex-1 pb-24 pt-6 md:py-6">
<Outlet />
</main>

View File

@@ -4,11 +4,17 @@ import { base44 } from '@/api/base44Client';
/**
* Auth context.
*
* The reference app resolves an identity provider before rendering. The demo
* has no provider, so it resolves the seeded user immediately — but it keeps
* the same context shape (`isLoadingAuth`, `isLoadingPublicSettings`,
* `authError`, `navigateToLogin`, …) so `App.jsx`, `ProtectedRoute` and every
* consumer stay unchanged.
* The single source of truth for "is anyone signed in", and it is not a flag
* this app sets. `checkUserAuth` calls `GET /me`; the server reads its own
* session table and either returns a user or answers 401. Success means there
* is a session, failure means there is not, and there is no third answer this
* code can manufacture.
*
* The context shape is unchanged from the demo's version — `isLoadingAuth`,
* `isLoadingPublicSettings`, `authError`, `navigateToLogin` and the rest — so
* `App.jsx`, `ProtectedRoute` and every consumer stay as they were. What
* changed is where the answer comes from: a real session cookie rather than a
* client that reported the seeded user as permanently signed in.
*/
const AuthContext = createContext(/** @type {any} */ (null));
@@ -27,6 +33,10 @@ export const AuthProvider = ({ children }) => {
setUser(currentUser);
setIsAuthenticated(true);
} catch {
// A 401 is the ordinary state of a signed-out visitor, and it is also
// what an expired session looks like on the first request after it
// lapses. Both mean the same thing here.
setUser(null);
setIsAuthenticated(false);
} finally {
setIsLoadingAuth(false);
@@ -38,10 +48,27 @@ export const AuthProvider = ({ children }) => {
checkUserAuth();
}, []);
const logout = (shouldRedirect = true) => {
/**
* Signs in.
*
* Resolves to the user on success and throws the API's error otherwise. The
* error's message is already the generic one the server chose — it does not
* say whether the address exists — so callers show it as-is rather than
* writing their own.
*/
const login = async ({ email, password, rememberMe = false }) => {
const currentUser = await base44.auth.login({ email, password, rememberMe });
setUser(currentUser);
setIsAuthenticated(true);
setAuthChecked(true);
return currentUser;
};
const logout = async (redirectTo = '/admin/login') => {
setUser(null);
setIsAuthenticated(false);
base44.auth.logout(shouldRedirect ? '/' : undefined);
// Revokes the session server-side, expires the cookie, then redirects.
await base44.auth.logout(redirectTo);
};
const navigateToLogin = () => {
@@ -59,6 +86,7 @@ export const AuthProvider = ({ children }) => {
authError: null,
appPublicSettings: null,
authChecked,
login,
logout,
navigateToLogin,
checkUserAuth,

View File

@@ -1,50 +1,49 @@
/**
* Admin sign-in state.
*
* The demo's data client (`base44.auth`) reports the seeded user as permanently
* signed in — there is no identity provider behind it. That is fine for the
* Employer and Talent demos, but the Admin console is supposed to be reached
* through its own sign-in, and "always authenticated" would mean /admin opened
* straight into the Control Center with the login page never seen.
* This file used to BE the Admin session: a `sessionStorage` flag, set by the
* login page and read by the route guard, standing in for a real one. The
* comment on it said so plainly — "it is not security — a browser flag never
* is — it is the state a real session would occupy … so swapping in a real
* provider later means changing this file and nothing else."
*
* So Admin keeps a session marker of its own. It is deliberately thin: a single
* flag, set by the login page and read by the route guard. It is not security —
* a browser flag never is — it is the state a real session would occupy, put in
* the one place that both the guard and the login screen can agree on, so the
* flow is right now and swapping in a real provider later means changing this
* file and nothing else.
* That swap has happened. The session is now a row in PostgreSQL, addressed by
* an HttpOnly cookie this code cannot read, and the question "is anyone signed
* in" is answered by `GET /me` through `AuthContext` — see `AdminRoute`, which
* now asks the context instead of asking here.
*
* `sessionStorage`, not `localStorage`: closing the tab should end the session,
* which is both the more defensible default for an administrative console and
* what makes the login page the app's actual starting point.
* The three functions survive because `AdminLayout` and the Admin `Profile`
* page call `endAdminSession()` on their sign-out buttons, and those files are
* outside this phase. They are honest about doing nothing that matters: there
* is no local flag left that could disagree with the server, which is the point
* — a browser value that grants access is exactly the thing being removed.
*/
const SESSION_KEY = 'krow_admin_session';
/** True when this tab has signed in to the Admin console. */
/**
* Whether this tab has signed in to the Admin console.
*
* @deprecated The server decides. Use `useAuth().isAuthenticated`, which
* reflects `GET /me`, rather than anything stored in the browser.
* @returns {boolean} always false — no local flag can grant admin access.
*/
export function hasAdminSession() {
try {
return sessionStorage.getItem(SESSION_KEY) === '1';
} catch {
// Private mode with storage denied: fail closed, so the login page shows.
return false;
}
return false;
}
/** Records a successful sign-in. */
export function startAdminSession() {
try {
sessionStorage.setItem(SESSION_KEY, '1');
} catch {
// Nothing to do — the guard will simply ask for sign-in again.
}
}
/**
* Records a successful sign-in.
*
* @deprecated A no-op. Signing in is `POST /api/v1/auth/login`, and what it
* produces is an HttpOnly cookie, not a value in this module.
*/
export function startAdminSession() {}
/** Clears the session. Used by sign-out. */
export function endAdminSession() {
try {
sessionStorage.removeItem(SESSION_KEY);
} catch {
// Ignore.
}
}
/**
* Clears the session.
*
* @deprecated A no-op, kept because the sign-out buttons in `AdminLayout` and
* the Admin `Profile` page still call it before `base44.auth.logout()`. The
* logout call is what ends the session: it revokes the row server-side and
* expires the cookie.
*/
export function endAdminSession() {}

View File

@@ -121,7 +121,10 @@ export function toPositionPayload(draft = {}, { status = 'active' } = {}) {
engine compares it against assignment windows. Null is open-ended. */
duration_months: record.duration_months === '' || record.duration_months == null
? null
: Number(record.duration_months),
: (Number(record.duration_months) || null),
start_date: record.start_date && String(record.start_date).trim() !== ''
? String(record.start_date).trim()
: null,
company: String(record.company || '').trim(),
};
}

View File

@@ -4,7 +4,7 @@ import { Sparkles, ChevronDown, ChevronUp, Sliders, Loader2, Star } from 'lucide
import { useCreateJobPosting, useUpdateJobPosting, useGenerateJobDescription, useJobPosting, useRoleCategories, useCreateRoleCategory } from '@/lib/krowHooks';
import { toast } from 'react-hot-toast';
import { ROLE_CATEGORIES } from '@/lib/roleCategories';
import { CERT_OPTIONS, CRITERIA_LABELS, ENGLISH_LEVELS, defaultPosition } from '@/lib/positionModel';
import { CERT_OPTIONS, CRITERIA_LABELS, ENGLISH_LEVELS, defaultPosition, toPositionPayload } from '@/lib/positionModel';
import { SkillRequirementsField } from '@/components/krow/SkillRequirementsField';
import { SkillSurface } from '@/components/skills/SkillSurface';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
@@ -130,11 +130,11 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
// Create draft if not exists
let id = draftId;
if (!id) {
const draft = await createJob.mutateAsync({ ...form, status: 'draft', pay_range_min: Number(form.pay_range_min) || 0, pay_range_max: Number(form.pay_range_max) || 0 });
const draft = await createJob.mutateAsync(toPositionPayload(form, { status: 'draft' }));
id = draft.id;
setDraftId(id);
}
const result = await generateDesc.mutateAsync({ data: { ...form, pay_range_min: Number(form.pay_range_min) || 0, pay_range_max: Number(form.pay_range_max) || 0 }, draftId: id });
const result = await generateDesc.mutateAsync({ data: toPositionPayload(form), draftId: id });
setAiResult(result);
toast.success('Job description generated!');
} catch {
@@ -153,11 +153,7 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
}
try {
const payload = {
...form,
status,
company: String(form.company || '').trim(),
pay_range_min: Number(form.pay_range_min) || 0,
pay_range_max: Number(form.pay_range_max) || 0,
...toPositionPayload(form, { status }),
...(aiResult ? { description: aiResult.description, responsibilities: aiResult.responsibilities, qualifications: aiResult.qualifications, nice_to_haves: aiResult.nice_to_haves, ai_generated: true } : {}),
};
let createdId = draftId;
@@ -190,12 +186,15 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
<div className={embedded ? '' : 'max-w-3xl mx-auto'}>
{/* Header */}
{!embedded && (
<div className="flex items-center justify-between mb-6">
<div>
<div className="flex items-start justify-between gap-4 mb-6">
<div className="min-w-0">
<h1 className="text-[22px] font-bold text-[#111827]">Create a Position</h1>
<p className="text-[13px] text-[#6B7280] mt-0.5">Set hiring criteria once — KROW interviews and ranks every applicant automatically.</p>
</div>
<button onClick={() => navigate('/admin/positions')} className="text-[13px] text-[#6B7280] hover:text-[#111827]">Cancel</button>
{/* `shrink-0` and a real gap: without them the subtitle's intrinsic
width pushed Cancel against the edge of a phone screen with the
title wrapping underneath it. */}
<button onClick={() => navigate('/admin/positions')} className="shrink-0 pt-1 text-[13px] text-[#6B7280] hover:text-[#111827]">Cancel</button>
</div>
)}

View File

@@ -1,13 +1,19 @@
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { hasAdminSession } from '@/lib/admin/session';
import { useAuth } from '@/lib/AuthContext';
/**
* Gate on the Admin console.
*
* Without this, `/admin` renders the Control Center for anyone who types the URL,
* because the demo's data client reports the seeded user as permanently signed in
* (see session.js). The console is meant to be entered through its own sign-in,
* so the guard sends an unauthenticated visitor to the login page.
* It asks the auth context, which reflects `GET /me` — a real question to the
* server about a real session — rather than the `sessionStorage` flag it used
* to read. That flag could be set from the browser console; a session row keyed
* by an HttpOnly cookie cannot.
*
* This guard is deliberately kept even though it now sits inside
* `ProtectedRoute`, which checks the same thing. It is not redundant defence in
* depth for its own sake: it is the component that owns "the Admin console is
* entered through its own sign-in", and it is where a role check will go in
* Phase 3D, when being signed in stops being sufficient to be here.
*
* The attempted path travels along in location state, so signing in returns you
* to where you were going rather than dropping you on the Control Center. That
@@ -16,8 +22,14 @@ import { hasAdminSession } from '@/lib/admin/session';
*/
export default function AdminRoute() {
const location = useLocation();
const { isAuthenticated, isLoadingAuth, authChecked } = useAuth();
if (!hasAdminSession()) {
// ProtectedRoute has already waited for the check, so this is belt and
// braces: without it a direct render of this route would redirect to the
// login page during the first tick and bounce a signed-in operator out.
if (isLoadingAuth || !authChecked) return null;
if (!isAuthenticated) {
return <Navigate to="/admin/login" replace state={{ from: location.pathname }} />;
}

View File

@@ -250,7 +250,7 @@ export default function AdminAnalytics() {
/>
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden border border-border">
<div className="overflow-x-auto">
<table className="w-full border-collapse text-body-sm">
<table className="w-full min-w-[36rem] border-collapse text-body-sm">
<caption className="sr-only">Hires, quality, speed and review outcome by role</caption>
<thead>
<tr className="border-b border-border bg-surface-subtle">

View File

@@ -325,7 +325,7 @@ export default function AdminCandidatesAnalysis() {
<SectionTitle id="fit" title="Position fit" meta={`${positionFit.length} open roles`} />
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full border-collapse text-body-sm">
<table className="w-full min-w-[40rem] border-collapse text-body-sm">
<caption className="sr-only">
Applicant supply against candidates clearing the bar, per open role
</caption>

View File

@@ -922,7 +922,7 @@ export default function ControlCenter() {
>
<div className="overflow-hidden rounded-xl border border-border bg-surface">
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<table className="w-full min-w-[36rem] border-collapse">
<caption className="sr-only">Most recent platform activity</caption>
<thead>
<tr className="border-b border-border bg-surface-subtle">

View File

@@ -4,14 +4,27 @@ import { AlertCircle, ArrowRight, Loader2, Eye, EyeOff, Sparkles, CheckCircle2 }
import { cn } from '@/lib/utils';
import { KROW_LOGO_URL } from '@/assets/brand';
import { Checkbox } from '@/components/ds';
import { base44 } from '@/api/base44Client';
import { startAdminSession } from '@/lib/admin/session';
import { useAuth } from '@/lib/AuthContext';
const DEMO_EMAIL = 'demo@krow.app';
/**
* The one message every failed sign-in shows.
*
* The API answers a wrong password, an unknown address, an account with no
* password and a suspended account with the same 401 and the same body, so that
* a stranger cannot learn which addresses are registered. Rendering the server's
* message verbatim would be equally correct; this is the same statement in the
* product's own voice, and — importantly — it is used for every failure, so this
* page does not reintroduce the distinction the API removes.
*/
const GENERIC_AUTH_ERROR =
'That email and password do not match an account. Check both and try again.';
export default function AdminLogin() {
const navigate = useNavigate();
const location = useLocation();
const { login } = useAuth();
const from = location.state?.from;
const returnTo = typeof from === 'string' && from.startsWith('/admin') && from !== '/admin/login'
@@ -28,8 +41,11 @@ export default function AdminLogin() {
const next = {};
if (!form.email.trim()) next.email = 'Enter your work email';
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) next.email = 'Enter a valid email address';
// Only "did you type anything". A client-side length rule here would be a
// guess at the server's policy that goes stale the moment the policy moves,
// and it tells someone with a valid short legacy password that their own
// password is invalid.
if (!form.password) next.password = 'Enter your password';
else if (form.password.length < 8) next.password = 'Passwords are at least 8 characters';
setErrors(next);
return Object.keys(next).length === 0;
};
@@ -40,17 +56,26 @@ export default function AdminLogin() {
if (!validate()) return;
setStatus('loading');
await new Promise((r) => setTimeout(r, 900));
if (form.password.toLowerCase() === 'wrong') {
try {
// The session is created server-side and arrives as an HttpOnly cookie.
// Nothing on this page sees, stores or forwards a token — there is no
// token here to mishandle.
await login({
email: form.email.trim(),
password: form.password,
rememberMe: form.remember,
});
} catch (error) {
setStatus('idle');
setAuthError('That email and password do not match an administrator account.');
// One message for every rejection. A rate-limited attempt is the single
// exception worth distinguishing, because "wait a moment" is advice the
// person can act on and it reveals nothing about any account.
setAuthError(error?.status === 429
? (error.message || 'Too many sign-in attempts. Wait a few minutes and try again.')
: GENERIC_AUTH_ERROR);
return;
}
await base44.auth.updateMe({ account_type: 'admin' });
localStorage.setItem('activeRole', 'admin');
startAdminSession();
setStatus('success');
setTimeout(() => navigate(returnTo, { replace: true }), 320);
};
@@ -192,7 +217,7 @@ export default function AdminLogin() {
<button
type="button"
disabled={busy}
onClick={() => setAuthError('Password resets are disabled in the demo.')}
onClick={() => setAuthError('Password resets are not available yet — ask an administrator to set a new password.')}
className="text-[#D97706] hover:text-[#B45309] font-semibold transition-colors"
>
Forgot password?
@@ -213,7 +238,8 @@ export default function AdminLogin() {
</form>
<p className="text-center text-xs text-[#94A3B8] leading-relaxed pt-2">
Demo environment — enter any 8+ character password to sign in. Use <span className="text-[#64748B] font-semibold">wrong</span> to test error validation.
Sign in with your Krow account. Passwords are set by an administrator with
the <span className="text-[#64748B] font-semibold">setpassword</span> utility.
</p>
</div>
</div>

View File

@@ -91,8 +91,10 @@ export default function AdminProfile() {
<AdminPage
title="Admin Profile"
subtitle="Manage your platform credentials, system permissions and security governance."
/* Three buttons need 295px; a 320px phone has 273 after the page gutters,
so the row wraps instead of pushing Sign Out off the edge. */
actions={
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<button
onClick={() => navigate('/admin/settings')}
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-xl bg-white border border-[#E0E4EA] text-[#101820] text-[13px] font-semibold hover:bg-[#F8F8F8] transition-all shadow-xs"
@@ -173,13 +175,13 @@ export default function AdminProfile() {
</div>
{/* Quick stats in Hero */}
<div className="flex items-center gap-3 border-t md:border-t-0 md:border-l border-white/20 pt-4 md:pt-0 md:pl-6">
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
<div className="flex flex-wrap items-center gap-3 border-t md:border-t-0 md:border-l md:flex-nowrap border-white/20 pt-4 md:pt-0 md:pl-6">
<div className="flex-1 md:flex-none bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">Scopes</p>
<p className="text-2xl font-bold text-white mt-0.5">{PERMISSIONS.length}</p>
<span className="text-[10px] text-blue-200">Full Control</span>
</div>
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
<div className="flex-1 md:flex-none bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">Security Level</p>
<p className="text-2xl font-bold text-emerald-300 mt-0.5">Tier 1</p>
<span className="text-[10px] text-blue-200">Max Privileges</span>

View File

@@ -55,18 +55,18 @@ export default function AdminSettings() {
</p>
</div>
<div className="flex items-center gap-3 shrink-0">
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
<div className="flex flex-wrap items-center gap-3 sm:flex-nowrap sm:shrink-0">
<div className="flex-1 sm:flex-none bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">Active Staff</p>
<p className="text-2xl font-bold text-white mt-0.5">{staff.length}</p>
<span className="text-[10px] text-blue-200">On Platform</span>
</div>
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
<div className="flex-1 sm:flex-none bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">Positions</p>
<p className="text-2xl font-bold text-emerald-300 mt-0.5">{postings.length}</p>
<span className="text-[10px] text-blue-200">Active Roles</span>
</div>
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
<div className="flex-1 sm:flex-none bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">Clients</p>
<p className="text-2xl font-bold text-sky-300 mt-0.5">{clientCount}</p>
<span className="text-[10px] text-blue-200">Organizations</span>

View File

@@ -83,8 +83,8 @@ export default function AdminWorkspace() {
</p>
</div>
<div className="flex items-center gap-3 shrink-0">
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
<div className="flex flex-wrap items-center gap-3 sm:flex-nowrap sm:shrink-0">
<div className="flex-1 sm:flex-none bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">Registered Skills</p>
<p className="text-2xl font-bold text-white mt-0.5">{skills.length}</p>
<span className="text-[10px] text-emerald-300 font-medium">{activeSkills} Active</span>
@@ -92,7 +92,7 @@ export default function AdminWorkspace() {
{/* Was "Training Paths". Replaced rather than dropped: the slot is
useful, and the split of agent skills across the two lists is
what this page actually governs. */}
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
<div className="flex-1 sm:flex-none bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">Skill Kinds</p>
<p className="text-2xl font-bold text-sky-300 mt-0.5">{owliverCount + boardCount}</p>
<span className="text-[10px] text-blue-200">{owliverCount} Owliver · {boardCount} Board</span>

View File

@@ -197,7 +197,12 @@ function AgentTableRow({ agent, shipped, overridden, onOpen, onAction }) {
<button
type="button"
onClick={onOpen}
className="font-heading text-body-sm font-semibold text-ink-1 hover:text-krow-blue transition-colors text-left focus-visible:outline-none"
/* On a phone the identity column claims the width of the name
rather than stacking "Control Center Agent" three words tall,
which it can afford because the table already scrolls inside
its own container there. Released at `sm`, so every wider
layout distributes its columns exactly as it did before. */
className="whitespace-nowrap sm:whitespace-normal font-heading text-body-sm font-semibold text-ink-1 hover:text-krow-blue transition-colors text-left focus-visible:outline-none"
>
{agent.name}
</button>
@@ -493,7 +498,7 @@ export default function AdminWorkspaceAgents() {
/* Clean Modern Directory Table View */
<div className="overflow-hidden rounded-2xl border border-border bg-surface shadow-xs">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<table className="w-full min-w-[48rem] text-left border-collapse">
<thead>
<tr className="border-b border-border bg-surface-subtle/60 text-[11px] font-semibold uppercase tracking-wider text-ink-4">
<th className="py-3 pl-4 pr-3 font-semibold">Agent</th>