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
16 changes: 16 additions & 0 deletions .changeset/12601-home-top-and-apps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@object-ui/app-shell': minor
'@object-ui/i18n': minor
---

The console home's top half follows the approved home artboard (objectui#12601): a search field in the top bar, a greeting with a one-line work summary, two compact builder chips, and 56 px app tiles with Setup moved off the tile row.

- **Search on `/home`.** `HomeLayout` now mounts the console's command palette, the same one `⌘K` opens inside an app. It is scoped to Home's host app: the app the user was last in, else their first openable app. The top bar draws its search field in the middle zone from `lg` up, 420 px wide from `xl`, labelled "Search apps, records, pages…". `⌘K` works on the page too. When there is no app to host the palette, none is mounted and no field is drawn. The internal-form route, which uses the same layout, gets the same field.
- **Greeting.** The greeting is 24 px / 600. The tagline is replaced by a 13 px summary line that counts the approval requests waiting on the user: "You have N approval(s) waiting today", or "Nothing is waiting for you today" at zero. The count comes from the shared pending-approvals read, the same read the bell uses for the approvals part of its badge; the badge adds the unread notification topics to it, so the two numbers can differ. The line speaks only once that read has answered: while it is loading, after it fails, or before it is asked, the line is empty rather than reporting nothing waiting. On a runtime without the approvals plugin the read answers with none waiting.
- **Builder entries.** The two full-width cover cards "Build an app" and "Start with a template" become two 32 px chips at the greeting's right, for admins only. A withheld chip keeps its disabled state and explains itself through a tooltip wrapper (a focusable wrapper, and an `aria-describedby` on the button pointing at a visually hidden reason). The reason is no longer a visible paragraph under the cards. The `home-build-app`, `home-start-template`, `home-authoring-gate-reason` and `home-marketplace-disabled-reason` test ids are unchanged.
- **Apps.** The "Your apps" heading is 15 px with a count pill. Tiles are 112 px wide, with a 56 px icon and a 13 px name on one line. Under the name, an 11 px line says when the user last had the app open, in the locale's compact relative time ("2 hr. ago", zh "2小时前打开"), read from the recent-items store: the newest entry the user opened inside that app. The line is never cut: a language adds a verb to the time only where both fit the tile, so the English line is the time alone. An app with no entry left in the store shows no line. Setup is no longer a tile: it is a muted "System Settings" link at the heading's right, shown when the user can open Setup. An admin who may install apps gets a trailing dashed "Add app" tile, which opens the marketplace route the "Browse App Marketplace" link opens and has the same gate.
- **Content column.** The page's content column is 1120 px wide, with 28 px above the first section and between sections.

New locale keys, in all ten packs: `home.search`, `home.summary.approvals` (a count family), `home.summary.none`, `home.addApp` and `home.appOpened`. Three keys whose only reader was the retired hero tagline and cover cards are removed from all ten packs: `home.heroTagline`, `home.build.subtitle` and `home.template.subtitle`. After this change nothing in this repository reads them.

**Clause-②: yes.** On the `@object-ui/app-shell` entry no export, prop or type is added, removed or renamed: `HomeLayout`, `HomePage` and `AppHeader` keep their props, and what changes is what they draw. On the `@object-ui/i18n` entry, the exported `en` pack is typed by its literal content, so its type gains the keys above and loses those three, and `formatRelativeTime` takes an optional third argument, `{ style, numeric }`, passed to `Intl.RelativeTimeFormat` as they are; without it the output is unchanged.
165 changes: 129 additions & 36 deletions packages/app-shell/src/console/home/HomeAppsStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,27 @@
* two different addresses for one app. "More", "Show less" and the marketplace
* shortcut stay buttons: they open nothing addressable.
*
* The strip's look is the Home artboard's apps section (objectui#12601): a
* 15 px heading with a count pill, 112 px tiles with a 56 px icon on the app's
* colour and a 13 px name on one line. Three optional parts ride along, all
* off unless the host asks: an 11 px "2 hr. ago" line under a tile's
* name (`lastOpened`, which Home reads from the recent-items store), a muted
* "System Settings" link at the heading's right (`settingsHref` — Home moves
* Setup off the tile row and onto it), and an admin's trailing dashed "Add app"
* tile, which opens the marketplace shortcut's route and is gated exactly as
* the shortcut is.
*
* @module
*/
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { LayoutGrid, Store, Star } from 'lucide-react';
import { Button, cn } from '@object-ui/components';
import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
import { LayoutGrid, Plus, Settings, Star } from 'lucide-react';
import { cn } from '@object-ui/components';
import { useDisplayLocale, useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
import { appRouteSegment, resolveKeyedI18nLabel } from '../../utils/index.js';
import { getIcon } from '../../utils/getIcon.js';
import { timeAgo } from '../../utils/relativeTime.js';
import { TILE_OPENED_TIME_FORMAT } from './appLastOpened.js';
import type { FavoriteItem } from '../../hooks/useFavorites.js';

const COMPACT_LIMIT = 19;
Expand All @@ -49,23 +61,30 @@ const ICON_GRADIENTS = [
'from-teal-500 to-green-600',
];

// Soft top-highlight + drop shadow → iOS icon depth/gloss.
const ICON_GLOSS =
'shadow-[0_4px_10px_-2px_rgb(0_0_0/0.25),inset_0_1px_0_0_rgb(255_255_255/0.45)]';

function hashStr(s: string): number {
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
return Math.abs(h);
}

/** One tile's box: 112 px wide, the 56 px icon above a one-line name. */
const TILE_CLASSES =
'group relative flex w-28 flex-col items-center gap-2 rounded-lg px-2 py-3 text-center transition-colors hover:bg-muted active:scale-[0.96]';
/** The 56 px icon square every tile draws, the app's own or a dashed stand-in. */
const ICON_BOX_CLASSES = 'relative inline-flex size-14 items-center justify-center rounded-[14px]';
/** The 12 px links at the heading's right. */
const HEADER_LINK_CLASSES =
'inline-flex items-center gap-1.5 rounded-sm text-xs transition-colors hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring';

export function HomeAppsStrip({
apps,
favorites,
onBrowseMarketplace,
isAdmin,
canAuthorMetadata = true,
marketplaceEnabled = true,
settingsHref,
lastOpened,
}: {
apps: any[];
favorites: FavoriteItem[];
Expand All @@ -89,9 +108,28 @@ export function HomeAppsStrip({
* absent.
*/
marketplaceEnabled?: boolean;
/**
* [objectui#12601] The Setup app's route, drawn as a muted "System Settings"
* link at the heading's right. Home passes it when Setup is among the apps
* the user can open, and leaves Setup off the tiles: it administers the
* workspace rather than running a business process. Omitted, no link is
* drawn and the tiles are whatever `apps` holds — the `app:launcher` block's
* case.
*/
settingsHref?: string;
/**
* [objectui#12601] When the user last had each app open, by app `name` (an
* ISO timestamp), drawn under the tile's name as "2 hr. ago" (zh "2小时前打开";
* the form is `TILE_OPENED_TIME_FORMAT`'s). Home
* reads it from the recent-items store (`lastOpenedByApp`). An app absent
* from the map draws no line; omitted, no tile does — the `app:launcher`
* block's case.
*/
lastOpened?: ReadonlyMap<string, string>;
}) {
const { t } = useObjectTranslation();
const { appLabel } = useObjectLabel();
const displayLocale = useDisplayLocale();
const [showAll, setShowAll] = useState(false);

const favNames = useMemo(
Expand All @@ -105,62 +143,88 @@ export function HomeAppsStrip({

const overflow = ordered.length - COMPACT_LIMIT;
const visible = showAll ? ordered : ordered.slice(0, COMPACT_LIMIT);
// The marketplace shortcut and the "Add app" tile open one route, so one
// gate decides both: an admin, on a session that may author metadata, on a
// runtime that mounts a marketplace.
const canInstallApps = isAdmin && canAuthorMetadata && marketplaceEnabled;

return (
<section className="mb-6">
<div className="mb-3 flex items-center gap-2.5">
<span className="inline-flex h-8 w-8 items-center justify-center rounded-xl border border-border bg-muted text-muted-foreground">
<LayoutGrid className="h-4 w-4" />
</span>
<h2 className="text-base font-semibold tracking-tight">
<section>
<div className="mb-2 flex items-center gap-2">
<h2 className="text-[15px] font-semibold tracking-tight">
{t('home.yourApps', { defaultValue: 'Your apps' })}
</h2>
<span className="text-sm text-muted-foreground">{ordered.length}</span>
{isAdmin && canAuthorMetadata && marketplaceEnabled && (
<Button
variant="ghost"
size="sm"
className="ml-auto h-8 text-muted-foreground"
onClick={onBrowseMarketplace}
data-testid="browse-marketplace-btn"
>
<Store className="mr-1.5 h-4 w-4" />
{t('home.browseMarketplace', { defaultValue: 'Browse App Marketplace' })}
</Button>
)}
<span
className="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-muted px-1.5 text-[11px] font-semibold tabular-nums text-muted-foreground"
data-testid="home-apps-count"
>
{ordered.length}
</span>
<div className="ml-auto flex items-center gap-4">
{canInstallApps && (
<button
type="button"
className={cn(HEADER_LINK_CLASSES, 'text-primary')}
onClick={onBrowseMarketplace}
data-testid="browse-marketplace-btn"
>
<LayoutGrid className="h-3.5 w-3.5" aria-hidden />
{t('home.browseMarketplace', { defaultValue: 'Browse App Marketplace' })}
</button>
)}
{settingsHref && (
<Link
to={settingsHref}
className={cn(HEADER_LINK_CLASSES, 'text-muted-foreground hover:text-foreground')}
data-testid="home-system-settings-link"
>
<Settings className="h-3.5 w-3.5" aria-hidden />
{t('home.systemSettings', { defaultValue: 'System Settings' })}
</Link>
)}
</div>
</div>

<div className="grid grid-cols-4 gap-1 sm:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10">
{/* `-ml-2` puts the first icon under the heading: a tile pads its icon 8 px. */}
<div className="-ml-2 flex flex-wrap gap-1">
{visible.map((app) => {
const Icon = getIcon(app.icon);
const label = appLabel({ name: app.name, label: resolveKeyedI18nLabel(app.label, t) });
const fav = favNames.has(app.name);
const grad = ICON_GRADIENTS[hashStr(app.name) % ICON_GRADIENTS.length];
const openedAgo = timeAgo(lastOpened?.get(app.name), displayLocale, TILE_OPENED_TIME_FORMAT);
return (
<Link
key={app.name}
// ADR-0048: the route segment is the package id, falling back to
// the app name for apps with none.
to={`/apps/${appRouteSegment(app)}`}
className="group relative flex flex-col items-center gap-2 rounded-xl p-2.5 text-center transition hover:bg-muted/40 active:scale-[0.96]"
className={TILE_CLASSES}
data-testid={`app-tile-${app.name}`}
>
<span
className={cn(
'relative inline-flex h-12 w-12 items-center justify-center rounded-[13px]',
'bg-gradient-to-br text-white transition-transform group-hover:scale-105',
ICON_GLOSS,
ICON_BOX_CLASSES,
'bg-gradient-to-br text-white shadow-sm transition-transform group-hover:scale-105',
grad,
)}
>
<Icon className="h-6 w-6" />
<Icon className="size-[26px]" />
{fav && (
<span className="absolute -right-1 -top-1 inline-flex h-4 w-4 items-center justify-center rounded-full bg-amber-400 ring-2 ring-background">
<Star className="h-2.5 w-2.5 fill-white text-white" />
</span>
)}
</span>
<span className="w-full truncate text-xs font-medium">{label}</span>
<span className="w-full truncate text-[13px] font-medium">{label}</span>
{openedAgo && (
<span
className="-mt-1 w-full truncate text-[11px] text-muted-foreground"
data-testid={`app-tile-opened-${app.name}`}
>
{t('home.appOpened', { time: openedAgo, defaultValue: '{{time}}' })}
</span>
)}
</Link>
);
})}
Expand All @@ -169,17 +233,46 @@ export function HomeAppsStrip({
<button
type="button"
onClick={() => setShowAll(true)}
className="group flex flex-col items-center gap-2 rounded-xl p-2.5 text-center transition hover:bg-muted/40 active:scale-[0.96]"
className={TILE_CLASSES}
data-testid="apps-show-all"
>
<span className="inline-flex h-12 w-12 items-center justify-center rounded-[13px] border border-dashed border-border text-sm font-medium text-muted-foreground transition-colors group-hover:border-foreground/30 group-hover:text-foreground">
<span
className={cn(
ICON_BOX_CLASSES,
'border border-dashed border-border text-sm font-medium text-muted-foreground transition-colors group-hover:border-foreground/30 group-hover:text-foreground',
)}
>
+{overflow}
</span>
<span className="w-full truncate text-xs text-muted-foreground">
<span className="w-full truncate text-[13px] text-muted-foreground">
{t('home.showMoreApps', { defaultValue: 'More' })}
</span>
</button>
)}

{/* An admin's way to add an app: the marketplace shortcut's own route,
behind its own gate (objectui#12601). A button for the reason the
shortcut is one: the strip is handed a callback, not an address. */}
{canInstallApps && (
<button
type="button"
onClick={onBrowseMarketplace}
className={cn(TILE_CLASSES, 'text-muted-foreground')}
data-testid="home-add-app"
>
<span
className={cn(
ICON_BOX_CLASSES,
'border-[1.5px] border-dashed border-border transition-colors group-hover:border-foreground/30 group-hover:text-foreground',
)}
>
<Plus className="size-[22px]" aria-hidden />
</span>
<span className="w-full truncate text-[13px] font-medium">
{t('home.addApp', { defaultValue: 'Add app' })}
</span>
</button>
)}
</div>

{showAll && overflow > 0 && (
Expand Down
52 changes: 48 additions & 4 deletions packages/app-shell/src/console/home/HomeLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,28 @@
* `home` variant so that `/home` shares chrome with the rest of the console;
* deliberately omits the sidebar.
*
* It mounts the console's command palette (objectui#12601), so the bar draws
* its search field and `⌘K` opens the palette here as it does inside an app —
* the SAME `CommandPalette`, not a second search. Home is outside every app,
* and the palette's scope is an app: it is given Home's host app, the app the
* user was last in (re-checked against the live list), else their first
* openable app — `resolveHostAppSegment`, the resolution Home already uses for
* the app-independent pages it links into. With no app to host it (the
* zero-app workspace, or the app list still loading) no palette is mounted, so
* the bar draws no search field that would open nothing (objectui#11912).
*
* @module
*/

import React, { useCallback, useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useNavigationContext } from '../../context/NavigationContext.js';
import { CommandPaletteProvider } from '../../context/CommandPaletteProvider.js';
import { CommandPalette } from '../../chrome/CommandPalette.js';
import { AppHeader } from '../../layout/AppHeader.js';
import { useMetadata } from '../../providers/MetadataProvider.js';
import { useAdapter } from '../../providers/AdapterProvider.js';
import { filterActiveApps, matchAppBySegment, resolveHostAppSegment } from '../../utils/index.js';
import { useAiSurfaceEnabled } from '../../hooks/useAiSurface.js';
import { useObjectTranslation } from '@object-ui/i18n';

Expand All @@ -29,7 +44,9 @@ interface HomeLayoutProps {
}

export function HomeLayout({ children }: HomeLayoutProps) {
const { setContext } = useNavigationContext();
const { setContext, currentAppName } = useNavigationContext();
const { apps, objects } = useMetadata();
const dataSource = useAdapter();
const { t } = useObjectTranslation();
const navigate = useNavigate();
const location = useLocation();
Expand All @@ -49,11 +66,38 @@ export function HomeLayout({ children }: HomeLayoutProps) {
setContext('home');
}, [setContext]);

// The app the palette searches — see the module doc.
const activeApps = filterActiveApps(apps);
const hostApp =
activeApps.length > 0
? matchAppBySegment(activeApps, resolveHostAppSegment(activeApps, currentAppName))
: undefined;

const header = (
<header className="sticky top-0 z-30 flex h-14 w-full shrink-0 items-center gap-2 border-b bg-background px-2 sm:px-4">
<AppHeader variant="home" />
</header>
);

return (
<div className="flex min-h-svh w-full flex-col bg-background" data-testid="home-layout">
<header className="sticky top-0 z-30 flex h-14 w-full shrink-0 items-center gap-2 border-b bg-background px-2 sm:px-4">
<AppHeader variant="home" />
</header>
{/* The provider wraps the bar and the palette only: the page below
reads neither, so mounting it once the app list arrives re-mounts
the bar and leaves the page as it is. */}
{hostApp ? (
<CommandPaletteProvider>
{header}
<CommandPalette
apps={apps ?? []}
activeApp={hostApp}
objects={objects ?? []}
onAppChange={(segment) => navigate(`/apps/${segment}`)}
dataSource={dataSource}
/>
</CommandPaletteProvider>
) : (
header
)}
<main className="flex-1 min-w-0 overflow-auto pb-20 sm:pb-0">
{children}
</main>
Expand Down
Loading
Loading