diff --git a/.changeset/12601-home-top-and-apps.md b/.changeset/12601-home-top-and-apps.md new file mode 100644 index 0000000000..d677f9bb7c --- /dev/null +++ b/.changeset/12601-home-top-and-apps.md @@ -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. diff --git a/packages/app-shell/src/console/home/HomeAppsStrip.tsx b/packages/app-shell/src/console/home/HomeAppsStrip.tsx index cdfcb17f8e..8aa6f32096 100644 --- a/packages/app-shell/src/console/home/HomeAppsStrip.tsx +++ b/packages/app-shell/src/console/home/HomeAppsStrip.tsx @@ -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; @@ -49,16 +61,21 @@ 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, @@ -66,6 +83,8 @@ export function HomeAppsStrip({ isAdmin, canAuthorMetadata = true, marketplaceEnabled = true, + settingsHref, + lastOpened, }: { apps: any[]; favorites: FavoriteItem[]; @@ -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; }) { const { t } = useObjectTranslation(); const { appLabel } = useObjectLabel(); + const displayLocale = useDisplayLocale(); const [showAll, setShowAll] = useState(false); const favNames = useMemo( @@ -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 ( -
-
- - - -

+
+
+

{t('home.yourApps', { defaultValue: 'Your apps' })}

- {ordered.length} - {isAdmin && canAuthorMetadata && marketplaceEnabled && ( - - )} + + {ordered.length} + +
+ {canInstallApps && ( + + )} + {settingsHref && ( + + + {t('home.systemSettings', { defaultValue: 'System Settings' })} + + )} +
-
+ {/* `-ml-2` puts the first icon under the heading: a tile pads its icon 8 px. */} +
{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 ( - + {fav && ( )} - {label} + {label} + {openedAgo && ( + + {t('home.appOpened', { time: openedAgo, defaultValue: '{{time}}' })} + + )} ); })} @@ -169,17 +233,46 @@ export function HomeAppsStrip({ )} + + {/* 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 && ( + + )}
{showAll && overflow > 0 && ( diff --git a/packages/app-shell/src/console/home/HomeLayout.tsx b/packages/app-shell/src/console/home/HomeLayout.tsx index 70bb7b7327..19dd96dc2d 100644 --- a/packages/app-shell/src/console/home/HomeLayout.tsx +++ b/packages/app-shell/src/console/home/HomeLayout.tsx @@ -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'; @@ -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(); @@ -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 = ( +
+ +
+ ); + return (
-
- -
+ {/* 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 ? ( + + {header} + navigate(`/apps/${segment}`)} + dataSource={dataSource} + /> + + ) : ( + header + )}
{children}
diff --git a/packages/app-shell/src/console/home/HomePage.tsx b/packages/app-shell/src/console/home/HomePage.tsx index 27fecd9064..0c1bfe8fad 100644 --- a/packages/app-shell/src/console/home/HomePage.tsx +++ b/packages/app-shell/src/console/home/HomePage.tsx @@ -15,7 +15,7 @@ * @module */ -import { useMemo } from 'react'; +import { useId, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { useMetadata } from '../../providers/MetadataProvider.js'; import { useRecentItems } from '../../hooks/useRecentItems.js'; @@ -29,17 +29,30 @@ import { useAgents, isAskAgent, agentHasCapability } from '@object-ui/plugin-cha // unknown→fail-open) lives with the hook. import { useCanAuthorMetadata } from '../../hooks/useCanAuthorMetadata.js'; import { HomeAppsStrip } from './HomeAppsStrip.js'; +import { lastOpenedByApp } from './appLastOpened.js'; import { RecoveryPasswordReminder } from './RecoveryPasswordReminder.js'; import { HomeActionCenter, HomeContinue, HomeActivity } from './HomeRail.js'; import { useHomeInbox } from '../../hooks/useHomeInbox.js'; import { useNavigationContext } from '../../context/NavigationContext.js'; import { + appRouteSegment, filterActiveApps, resolveHostAppSegment, resolveNotificationTarget, } from '../../utils/index.js'; -import { Empty, EmptyTitle, EmptyDescription, Button, cn } from '@object-ui/components'; -import { Sparkles, UploadCloud, MessageSquareText, Hammer, LayoutTemplate } from 'lucide-react'; +import { resolveSetupAppPath } from '../ConsoleShell.js'; +import { + Empty, + EmptyTitle, + EmptyDescription, + Button, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, + cn, +} from '@object-ui/components'; +import { Sparkles, UploadCloud, MessageSquareText, Hammer, LayoutGrid } from 'lucide-react'; import { usePendingDrafts } from '../../preview/usePendingDrafts.js'; import { usePublishAllDrafts } from '../../preview/usePublishAllDrafts.js'; import { resolveAiApiBase } from '../../hooks/useAiSurface.js'; @@ -72,49 +85,80 @@ function useHomeAiAvailability(): { } /** - * One card of the builder cover. Disabled (not hidden) when the session cannot - * author metadata: a vanished primary CTA reads as a broken page, while a - * dimmed one plus the reason line below explains the deployment's posture. The - * reason also rides the native `title` so it is reachable from the card itself. + * One 32 px builder chip at the greeting's right (objectui#12601): "Start with + * a template" outlined, "Build an app" on the soft primary tint with its icon in + * the primary colour. Admins only — the caller decides that. + * + * Disabled (not hidden) when the session cannot use it: a vanished front door + * reads as a broken page, while a dimmed one explains the deployment's + * posture. The explanation rides the objectui#12303 pattern instead of a + * `title` on the disabled button, which no hover, focus or assistive + * technology reaches: a wrapping span is the tooltip trigger and a tab stop, + * and the button and the span both point `aria-describedby` at a visually + * hidden copy of the reason, `reasonId` — rendered once by the caller, since + * both chips can share one reason. */ -function BuilderCoverCard({ +function BuilderChip({ icon: Icon, - title, - subtitle, + label, onClick, - disabled, + tone, disabledReason, + reasonId, testId, }: { icon: React.ElementType; - title: string; - subtitle: string; + label: string; onClick: () => void; - disabled: boolean; - disabledReason: string; + tone: 'outline' | 'soft'; + /** The reason the chip is withheld, or `undefined` when it is live. */ + disabledReason: string | undefined; + /** The id of the visually hidden reason the chip is described by. */ + reasonId: string | undefined; testId: string; }) { - return ( + const disabled = disabledReason !== undefined; + const button = ( ); + if (!disabled) return button; + return ( + + + + + {button} + + + {disabledReason} + + + ); } /** @@ -128,10 +172,10 @@ function BuilderCoverCard({ * "Build with AI" routes to the AI authoring studio, whose output is * draft metadata nobody without `manage_metadata` can publish — so it is one of * the "any other metadata-authoring CTA" the objectstack#8270 ruling covers. - * It is HIDDEN rather than disabled: unlike the builder cover it is not the - * page's structural front door, the cover below already carries the visible - * reason, and a disabled hero button next to a live "Ask AI" would read as a - * transient failure. "Ask AI" is read-only and never gated. + * It is HIDDEN rather than disabled: unlike the builder chips it is not the + * page's structural front door, the chips beside it already carry the reason, + * and a disabled hero button next to a live "Ask AI" would read as a transient + * failure. "Ask AI" is read-only and never gated. */ function HomeAiActions({ askAvailable, @@ -154,10 +198,12 @@ function HomeAiActions({ layout === 'stack' ? 'mt-6 flex flex-col sm:flex-row items-center gap-3' : 'flex shrink-0 items-center gap-2'; + // In the greeting row the buttons take the builder chips' 32 px height. + const buttonClass = layout === 'stack' ? undefined : 'h-8 px-3 text-[13px]'; return (
{showBuild && ( - @@ -169,6 +215,7 @@ function HomeAiActions({ {askAvailable && !showBuild && (
- {/* Your apps — compact, scalable launcher (favorites first) */} + {/* Your apps — compact, scalable launcher (favorites first); Setup is + the heading's "System Settings" link rather than a tile */} navigate('/apps/setup/system/marketplace')} isAdmin={isAdmin} - /* objectstack#8270 — this button targets the SAME marketplace route - as the gated "Start with a template" cover card above, so leaving - it live would make that gate cosmetic: the withheld dead end is - still one click away. Gated together, hidden here (a secondary - strip-header link, with the cover's reason line already on - screen). */ + /* objectstack#8270 — the strip's marketplace shortcut and its "Add + app" tile target the SAME marketplace route as the gated "Start + with a template" chip above, so leaving them live would make that + gate cosmetic: the withheld dead end would still be one click + away. Gated together, hidden there (secondary entries, with the + chip's reason already reachable). */ canAuthorMetadata={canAuthorMetadata} - /* objectui#5504 — same reason as the gate above: this button - targets the very route that 404s on a marketplace-less runtime, - so it goes with the capability rather than leading into the + /* objectui#5504 — same reason as the gate above: these entries + target the very route that 404s on a marketplace-less runtime, + so they go with the capability rather than leading into the error card. */ marketplaceEnabled={marketplaceEnabled} /> {/* Action center — what needs the user; leads the dashboard */} -
+
({ useAdapter: () => adapter let approvalsFixture = 0; vi.mock('../../../hooks/sharedUserFeeds', async (importOriginal) => ({ ...(await importOriginal>()), - useSharedPendingApprovalsCount: () => approvalsFixture, + // `useHomeInbox` reads the approvals feed WITH its status (objectui#12601); + // the dial is still the count, always an answer here. + useSharedPendingApprovals: () => ({ value: approvalsFixture, status: 'ready' }), useHumanActivityFeed: () => ({ value: [], status: 'ready', readable: true }), })); diff --git a/packages/app-shell/src/console/home/__tests__/HomeLayout.searchOpensPalette-12601.test.tsx b/packages/app-shell/src/console/home/__tests__/HomeLayout.searchOpensPalette-12601.test.tsx new file mode 100644 index 0000000000..2d682f622e --- /dev/null +++ b/packages/app-shell/src/console/home/__tests__/HomeLayout.searchOpensPalette-12601.test.tsx @@ -0,0 +1,257 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#12601 — Home's top bar has a search field, and it opens the + * console's command palette: the SAME `CommandPalette` `⌘K` opens inside an + * app, not a second search. + * + * Before, no palette was mounted on `/home`, so the bar drew no search at all + * (objectui#11912 withheld the trigger where it would open nothing). + * `HomeLayout` now mounts `CommandPaletteProvider` and the palette, scoped to + * Home's host app. + * + * Real subjects: `HomeLayout`, `AppHeader`, `CommandPaletteProvider` and a + * `MemoryRouter`. The palette itself is a probe that reads the provider's open + * state the way `CommandPalette` does (`useCommandPalette().open`, backed by + * `?palette=1`) and records the app it was handed; what the palette lists is + * its own files' pins. The chrome this card does not touch (menus, presence, + * switchers, icons, the inbox) is stubbed as + * `AppHeader.searchTriggerNeedsPalette-11912.test.tsx` stubs it. + * + * Layout is not measured — the test DOM applies no stylesheet — so the zone + * structure and the width classes are what is asserted; the browser readings + * are in the pull request. + */ +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter, useLocation } from 'react-router-dom'; + +const state = vi.hoisted(() => ({ + apps: [] as Array>, + currentAppName: undefined as string | undefined, +})); + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ + language: 'en', + t: (key: string, options?: Record) => String(options?.defaultValue ?? key), + }), + useObjectLabel: () => { + const label = (x: { name?: string; label?: string } | string) => + typeof x === 'string' ? x : String(x?.label ?? x?.name ?? ''); + return { + objectLabel: label, + dashboardLabel: label, + pageLabel: label, + reportLabel: label, + viewLabel: (_o: string, _v: string, fallback?: string) => fallback ?? '', + appLabel: label, + }; + }, +})); + +vi.mock('@object-ui/components', async (importOriginal) => { + const actual = await importOriginal>(); + const strip = (p: any) => { + const { asChild, variant, size, align, sideOffset, ...rest } = p ?? {}; + return rest; + }; + const Pass = ({ children, ...p }: any) =>
{children}
; + return { + ...actual, + Button: ({ children, asChild, variant, size, ...p }: any) => + asChild ? {children} : , + DropdownMenu: Pass, + DropdownMenuTrigger: Pass, + DropdownMenuContent: () => null, + DropdownMenuItem: Pass, + DropdownMenuLabel: Pass, + DropdownMenuSeparator: () => null, + DropdownMenuGroup: Pass, + Avatar: Pass, + AvatarImage: () => null, + AvatarFallback: Pass, + Popover: Pass, + PopoverTrigger: Pass, + PopoverContent: () => null, + cn: (...c: any[]) => c.filter(Boolean).join(' '), + }; +}); + +vi.mock('lucide-react', () => { + const Icon = () => ; + return new Proxy({ __esModule: true } as Record, { + get: (target, prop) => { + if (prop === 'then' || prop === '__esModule' || typeof prop === 'symbol') return target[prop]; + return Icon; + }, + has: (_target, prop) => prop !== 'then', + }); +}); + +vi.mock('@object-ui/react', async (importOriginal) => ({ + ...(await importOriginal>()), + useOffline: () => ({ isOnline: true }), +})); +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + PresenceAvatars: () => null, + useTenantPresence: () => [], +})); +vi.mock('../../../layout/ModeToggle', () => ({ ModeToggle: () => null })); +vi.mock('../../../layout/WorkspaceSwitcher', () => ({ WorkspaceSwitcher: () => null })); +vi.mock('../../../layout/LocaleSwitcher', () => ({ LocaleSwitcher: () => null })); +vi.mock('../../../layout/ConnectionStatus', () => ({ ConnectionStatus: () => null })); +vi.mock('../../../layout/AppSwitcher', () => ({ AppSwitcher: () => null })); +vi.mock('../../../layout/LocalizedSidebarTrigger', () => ({ LocalizedSidebarTrigger: () => null })); +vi.mock('../../../layout/PreviewBadge', () => ({ PreviewBadge: () => null })); +// Rendered in every frame, so it doubles as the "the bar drew" control. +vi.mock('../../../layout/InboxPopover', () => ({ InboxPopover: () =>
})); +vi.mock('../../../hooks/useAiSurface', () => ({ + useAiSurfaceEnabled: () => ({ enabled: false, isLoading: false }), +})); +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ + user: { id: 'u1', name: 'Dev Admin', email: 'dev.admin@example.com' }, + signOut: vi.fn(), + isAuthEnabled: true, + organizations: [], + activeOrganization: null, + isOrganizationsLoading: false, + getAuthConfig: undefined, + }), + getUserInitials: () => 'DA', + useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }), +})); +vi.mock('../../../providers/MetadataProvider', () => ({ + useMetadata: () => ({ apps: state.apps, objects: [], dashboards: [], pages: [], reports: [] }), +})); +vi.mock('../../../providers/AdapterProvider', () => ({ + useAdapter: () => ({ find: () => Promise.resolve({ data: [] }), getClient: () => undefined }), +})); +vi.mock('../../../context/NavigationContext', () => ({ + useNavigationContext: () => ({ + setContext: () => {}, + currentAppName: state.currentAppName, + recordTitle: undefined, + }), +})); + +// The palette: a probe on the provider's open state, recording the app it searches. +vi.mock('../../../chrome/CommandPalette', async () => { + const { useCommandPalette } = await import('../../../context/CommandPaletteProvider'); + return { + CommandPalette: (props: { activeApp?: { name?: string } | null }) => { + const { open } = useCommandPalette(); + return ( + + ); + }, + }; +}); + +import { HomeLayout } from '../HomeLayout'; + +const DESKTOP = 'action:command-palette:open'; +const MOBILE = 'action:command-palette:open-mobile'; + +function LocationProbe() { + const { search } = useLocation(); + return ; +} + +function renderHome() { + return render( + + +
+ + + , + ); +} + +const classesOf = (el: Element) => (el.getAttribute('class') ?? '').split(/\s+/).filter(Boolean); + +beforeEach(() => { + state.apps = [ + { name: 'crm', label: 'CRM' }, + { name: 'ops', label: 'Ops' }, + ]; + state.currentAppName = undefined; + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ data: [] }) }))); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('Home’s top bar searches through the console’s command palette (objectui#12601)', () => { + it('the search field opens the command palette', () => { + renderHome(); + const probe = screen.getByTestId('palette-probe'); + expect(probe).toHaveAttribute('data-open', 'false'); + + fireEvent.click(screen.getByTestId(DESKTOP)); + + expect(probe).toHaveAttribute('data-open', 'true'); + expect(screen.getByTestId('location').getAttribute('data-search')).toContain('palette=1'); + }); + + it('⌘K opens the same palette, and the narrow-width icon button does too', () => { + const { unmount } = renderHome(); + fireEvent.keyDown(document, { key: 'k', metaKey: true }); + expect(screen.getByTestId('palette-probe')).toHaveAttribute('data-open', 'true'); + unmount(); + + renderHome(); + fireEvent.click(screen.getByTestId(MOBILE)); + expect(screen.getByTestId('palette-probe')).toHaveAttribute('data-open', 'true'); + }); + + it('from `lg` up the field is the bar’s middle zone, 420 px from `xl`, named for what it finds', () => { + renderHome(); + const trigger = screen.getByTestId(DESKTOP); + const zone = trigger.closest('[data-topbar-zone="search"]'); + expect(zone).not.toBeNull(); + expect(classesOf(zone!)).toEqual(expect.arrayContaining(['hidden', 'lg:flex'])); + // Not in the utility cluster: the cluster is the zone's next sibling. + expect(zone!.nextElementSibling).toContainElement(screen.getByTestId('inbox-bell')); + expect(zone!.nextElementSibling).not.toContainElement(trigger); + expect(classesOf(trigger)).toEqual(expect.arrayContaining(['h-8', 'w-80', 'xl:w-[420px]'])); + expect(trigger).toHaveAttribute('aria-label', 'Search apps, records, pages…'); + expect(trigger).toHaveTextContent('Search apps, records, pages…'); + expect(trigger).toHaveAttribute('aria-keyshortcuts', 'Meta+K Control+K'); + }); + + it('the palette searches Home’s host app: the app the user was last in, else the first openable app', () => { + state.currentAppName = 'ops'; + const { unmount } = renderHome(); + expect(screen.getByTestId('palette-probe')).toHaveAttribute('data-active-app', 'ops'); + unmount(); + + state.currentAppName = undefined; + renderHome(); + expect(screen.getByTestId('palette-probe')).toHaveAttribute('data-active-app', 'crm'); + }); + + it('CONTROL — with no app to host the palette there is none, and no search field that would open nothing', () => { + state.apps = []; + renderHome(); + // The bar and the page did render. + expect(screen.getByTestId('inbox-bell')).toBeInTheDocument(); + expect(screen.getByTestId('home-page')).toBeInTheDocument(); + expect(screen.queryByTestId('palette-probe')).toBeNull(); + expect(screen.queryByTestId(DESKTOP)).toBeNull(); + expect(screen.queryByTestId(MOBILE)).toBeNull(); + }); +}); diff --git a/packages/app-shell/src/console/home/__tests__/HomePage.authoringCapabilityGate.test.tsx b/packages/app-shell/src/console/home/__tests__/HomePage.authoringCapabilityGate.test.tsx index 5e9b26d4c4..0f5d420aee 100644 --- a/packages/app-shell/src/console/home/__tests__/HomePage.authoringCapabilityGate.test.tsx +++ b/packages/app-shell/src/console/home/__tests__/HomePage.authoringCapabilityGate.test.tsx @@ -247,11 +247,15 @@ describe('HomePage authoring-CTA capability gate (objectstack#8270)', () => { expect(screen.getByTestId('home-build-app')).toBeDisabled(); expect(screen.getByTestId('home-start-template')).toBeDisabled(); - // The reason is on screen — not only in a tooltip, and not English text - // baked into the component. - expect(screen.getByTestId('home-authoring-gate-reason')).toHaveTextContent( - '«home.build.noCapability»', - ); + // The reason is reachable from each withheld chip — its tooltip wrapper + // and an `aria-describedby` on the button (objectui#12601, the + // objectui#12303 pattern) — and is not English text baked into the + // component. The wrapper itself is pinned in + // `HomePage.greetingAndApps-12601.test.tsx`. + const reason = screen.getByTestId('home-authoring-gate-reason'); + expect(reason).toHaveTextContent('«home.build.noCapability»'); + expect(screen.getByTestId('home-build-app')).toHaveAttribute('aria-describedby', reason.id); + expect(screen.getByTestId('home-start-template')).toHaveAttribute('aria-describedby', reason.id); }); it('does not navigate into the dead end when the withheld CTA is clicked', async () => { diff --git a/packages/app-shell/src/console/home/__tests__/HomePage.greetingAndApps-12601.test.tsx b/packages/app-shell/src/console/home/__tests__/HomePage.greetingAndApps-12601.test.tsx new file mode 100644 index 0000000000..f054734ac3 --- /dev/null +++ b/packages/app-shell/src/console/home/__tests__/HomePage.greetingAndApps-12601.test.tsx @@ -0,0 +1,363 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#12601 — the Home greeting row and the apps strip, as the artboard + * "系统首页 · 四段式" draws its sections ① and ②. + * + * What these cases pin, on the real `HomePage` with the real `HomeAppsStrip`: + * + * - the line under the greeting sums up what is waiting from the + * pending-approvals feed, the count the bell badges, and says nothing is + * waiting at zero — only when that count is an answer (`ready`): while + * the read is in flight, after it fails or before it is asked, the line + * claims nothing. It names approvals only: no source classifies an inbox + * row as "assigned to me" yet (the card's fork, reported on the PR); + * - the builder entries are two chips at the greeting's right, for an admin + * only — a non-admin renders neither; + * - a withheld chip explains itself through the objectui#12303 tooltip + * wrapper: a tab stop that triggers the tooltip, the wrapper and the + * button both described by one visually hidden copy of the reason, which + * keeps the `home-authoring-gate-reason` / `home-marketplace-disabled-reason` + * test ids; + * - Setup is not a tile: it is the strip heading's "System Settings" link, + * on the `/setup` alias's own route, and the count pill does not count it; + * - a tile shows when its app was last opened, read from the recent-items + * store: the newest entry opened inside the app; + * - an admin who may install apps gets a trailing "Add app" tile on the + * marketplace route, gated exactly as the marketplace shortcut is. + * + * `t` echoes the KEY (and the count or time it was handed), so a hardcoded string + * fails here; that every key exists in all ten packs is + * `all-locales-key-parity.test.ts`'s and `check:i18n-keys`'s. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import React from 'react'; +import { render, screen, cleanup, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +const navigateMock = vi.fn(); + +const state = vi.hoisted(() => ({ + isAdmin: true, + canAuthorMetadata: true, + marketplaceEnabled: true, + pendingApprovalsCount: 0, + approvalsStatus: 'ready' as 'idle' | 'loading' | 'ready' | 'error', + apps: [] as Array>, + recentItems: [] as Array>, +})); + +vi.mock('react-router-dom', async () => { + const { createElement } = await import('react'); + return { + useNavigate: () => navigateMock, + // The tiles and the settings link are router `Link`s; this harness has no + // router, so the stand-in renders the anchor `Link` would, minus the basename. + Link: ({ to, ...rest }: { to: string } & Record) => + createElement('a', { ...rest, href: to }), + }; +}); + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ + t: (key: string, opts?: { count?: number; time?: string }) => + `«${key}»` + + (opts?.count === undefined ? '' : `[count=${opts.count}]`) + + (opts?.time === undefined ? '' : `[time=${opts.time}]`), + language: 'en', + }), + // Every labeller echoes the label, else the name: the tiles read `appLabel`, + // and the recent rail beside them labels the store's entries. + useObjectLabel: () => { + const label = (x: { name?: string; label?: string }) => String(x?.label || x?.name || ''); + return { + appLabel: label, + objectLabel: label, + objectPluralLabel: label, + dashboardLabel: label, + pageLabel: label, + reportLabel: label, + viewLabel: (_object: string, _view: string, fallback?: string) => fallback ?? '', + }; + }, +})); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'Dev Admin', email: 'dev.admin@example.com' } }), + useWorkspaceAdminStatus: () => ({ isAdmin: state.isAdmin, isResolved: true }), +})); + +// No AI agent: the greeting row holds the builder chips alone. +vi.mock('@object-ui/plugin-chatbot', async (importOriginal) => ({ + ...(await importOriginal()), + useAgents: () => ({ agents: [] }), + isAskAgent: () => false, + agentHasCapability: () => false, +})); + +vi.mock('../../../providers/MetadataProvider', () => ({ + useMetadata: () => ({ apps: state.apps, objects: [], dashboards: [], pages: [], reports: [], loading: false }), +})); +vi.mock('../../../context/NavigationContext', () => ({ + useNavigationContext: () => ({ currentAppName: undefined }), +})); +vi.mock('../../../hooks/useCanAuthorMetadata', () => ({ + useCanAuthorMetadata: () => state.canAuthorMetadata, +})); +// The page's one inbox seam: the approvals count AND whether it is an answer. +vi.mock('../../../hooks/useHomeInbox', () => ({ + useHomeInbox: () => ({ + pendingApprovalsCount: state.pendingApprovalsCount, + pendingApprovalsStatus: state.approvalsStatus, + notifications: [], + unreadTopicCount: 0, + notificationsStatus: 'ready', + activity: { value: [], status: 'ready', readable: true }, + }), +})); +vi.mock('../../../runtime-config', () => ({ + getRuntimeConfig: () => ({ branding: { productName: 'ObjectStack' } }), + isMarketplaceEnabled: () => state.marketplaceEnabled, + isAiStudioEnabled: () => true, +})); + +// The tiles' last-opened time reads the recent-items store. +vi.mock('../../../hooks/useRecentItems', () => ({ + useRecentItems: () => ({ recentItems: state.recentItems }), +})); + +// --- surfaces these cases do not read --------------------------------------- +vi.mock('../../../hooks/useFavorites', () => ({ useFavorites: () => ({ favorites: [] }) })); +vi.mock('../../../hooks/useAiSurface', () => ({ resolveAiApiBase: () => '' })); +vi.mock('../../../preview/usePendingDrafts', () => ({ usePendingDrafts: () => ({ count: 0 }) })); +vi.mock('../../../preview/usePublishAllDrafts', () => ({ + usePublishAllDrafts: () => ({ publishAll: async () => ({ ok: true }), publishing: false }), +})); + +import { HomePage } from '../HomePage'; + +const CRM = { name: 'crm', label: 'CRM', icon: 'Building2' }; +const SETUP = { name: 'setup', label: 'Setup', icon: 'Settings', _packageId: 'com.objectstack.setup' }; + +/** Every URL the page handed the global `fetch` — none is expected. */ +let fetchCalls: string[] = []; + +beforeEach(() => { + navigateMock.mockReset(); + state.isAdmin = true; + state.canAuthorMetadata = true; + state.marketplaceEnabled = true; + state.pendingApprovalsCount = 0; + state.approvalsStatus = 'ready'; + state.apps = [CRM, SETUP]; + state.recentItems = []; + fetchCalls = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: unknown) => { + fetchCalls.push(String(input)); + return { ok: false, status: 404, json: async () => ({}) }; + }), + ); +}); + +afterEach(() => { + expect(fetchCalls).toEqual([]); + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('the line under the greeting (objectui#12601)', () => { + it('reads the approvals count the bell badges', () => { + state.pendingApprovalsCount = 2; + render(); + expect(screen.getByTestId('home-work-summary')).toHaveTextContent('«home.summary.approvals»[count=2]'); + }); + + it('says nothing is waiting at zero', () => { + state.pendingApprovalsCount = 0; + render(); + expect(screen.getByTestId('home-work-summary')).toHaveTextContent('«home.summary.none»'); + }); + + it.each([ + ['in flight', 'loading', 0], + ['failed', 'error', 0], + ['not asked', 'idle', 0], + // A count kept from an earlier read is not vouched for once a re-read fails. + ['failed after an earlier answer', 'error', 3], + ] as const)('claims nothing while the approvals read is %s', (_when, status, count) => { + state.approvalsStatus = status; + state.pendingApprovalsCount = count; + render(); + const line = screen.getByTestId('home-work-summary'); + // The page did render; the line holds its place and says nothing. + expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument(); + expect(line).toBeEmptyDOMElement(); + expect(screen.queryByText(/home\.summary\./)).toBeNull(); + }); +}); + +describe('the builder chips (objectui#12601)', () => { + it('a non-admin renders no builder chips', () => { + state.isAdmin = false; + render(); + // The page did render: the greeting row's summary is there. + expect(screen.getByTestId('home-work-summary')).toBeInTheDocument(); + expect(screen.queryByTestId('home-build-app')).toBeNull(); + expect(screen.queryByTestId('home-start-template')).toBeNull(); + }); + + it('CONTROL — an admin gets both chips, live, beside the greeting', async () => { + const user = userEvent.setup(); + render(); + const build = screen.getByTestId('home-build-app'); + const template = screen.getByTestId('home-start-template'); + expect(build).toBeEnabled(); + expect(template).toBeEnabled(); + expect(build).toHaveTextContent('«home.build.title»'); + expect(template).toHaveTextContent('«home.template.title»'); + // One row: the chips share the summary's section, with no cover card. + const section = screen.getByTestId('home-work-summary').closest('section')!; + expect(section).toContainElement(build); + expect(section).toContainElement(template); + expect(screen.queryByTestId('home-authoring-gate-reason')).toBeNull(); + + await user.click(build); + expect(navigateMock).toHaveBeenCalledWith('/studio'); + await user.click(template); + expect(navigateMock).toHaveBeenCalledWith('/apps/setup/system/marketplace'); + }); + + it('a chip withheld for want of the capability explains itself through the tooltip wrapper', async () => { + const user = userEvent.setup(); + state.canAuthorMetadata = false; + render(); + + const reason = screen.getByTestId('home-authoring-gate-reason'); + expect(reason).toHaveTextContent('«home.build.noCapability»'); + expect(reason).toHaveClass('sr-only'); + expect(reason.id).not.toBe(''); + + for (const testId of ['home-build-app', 'home-start-template']) { + const chip = screen.getByTestId(testId); + expect(chip).toBeDisabled(); + // The button and its wrapper are both described by the one reason. + expect(chip).toHaveAttribute('aria-describedby', reason.id); + const wrapper = chip.parentElement!; + expect(wrapper.tagName).toBe('SPAN'); + expect(wrapper).toHaveAttribute('tabindex', '0'); + expect(wrapper).toHaveAttribute('data-disabled-reason'); + expect(wrapper).toHaveAttribute('aria-describedby', reason.id); + } + + await user.hover(screen.getByTestId('home-build-app').parentElement!); + expect(await screen.findByRole('tooltip')).toHaveTextContent('«home.build.noCapability»'); + + // Withheld means inert: a click reaches no route. + await user.click(screen.getByTestId('home-build-app')); + expect(navigateMock).not.toHaveBeenCalled(); + }); + + it('on a runtime with no marketplace only the template chip is withheld, with its own reason', async () => { + const user = userEvent.setup(); + state.marketplaceEnabled = false; + render(); + + expect(screen.getByTestId('home-build-app')).toBeEnabled(); + const template = screen.getByTestId('home-start-template'); + expect(template).toBeDisabled(); + const reason = screen.getByTestId('home-marketplace-disabled-reason'); + expect(reason).toHaveTextContent('«home.template.marketplaceDisabled»'); + expect(template).toHaveAttribute('aria-describedby', reason.id); + expect(template.parentElement).toHaveAttribute('tabindex', '0'); + expect(screen.queryByTestId('home-authoring-gate-reason')).toBeNull(); + + template.parentElement!.focus(); + expect(await screen.findByRole('tooltip')).toHaveTextContent('«home.template.marketplaceDisabled»'); + await user.click(template); + expect(navigateMock).not.toHaveBeenCalled(); + }); +}); + +describe('the apps strip (objectui#12601)', () => { + it('Setup is not among the tiles: it is the heading’s System Settings link, on the /setup alias’s route', () => { + render(); + expect(screen.getByTestId('app-tile-crm')).toBeInTheDocument(); + expect(screen.queryByTestId('app-tile-setup')).toBeNull(); + + const link = screen.getByTestId('home-system-settings-link'); + expect(link).toHaveTextContent('«home.systemSettings»'); + expect(link).toHaveAttribute('href', '/apps/com.objectstack.setup'); + // The count pill counts the tiles, so Setup is not in it. + expect(screen.getByTestId('home-apps-count')).toHaveTextContent(/^1$/); + }); + + it('CONTROL — with no Setup app among the openable apps there is no settings link', () => { + state.apps = [CRM, { name: 'ops', label: 'Ops' }]; + render(); + expect(screen.queryByTestId('home-system-settings-link')).toBeNull(); + expect(screen.getByTestId('home-apps-count')).toHaveTextContent(/^2$/); + }); + + it('an admin gets a trailing Add app tile on the marketplace route', async () => { + const user = userEvent.setup(); + render(); + const add = screen.getByTestId('home-add-app'); + expect(add.tagName).toBe('BUTTON'); + expect(add).toHaveTextContent('«home.addApp»'); + // Trailing: after the last app tile, in the same row. + const row = screen.getByTestId('app-tile-crm').parentElement!; + expect(row.lastElementChild).toBe(add); + await user.click(add); + expect(navigateMock).toHaveBeenCalledWith('/apps/setup/system/marketplace'); + }); + + it.each([ + ['a non-admin', { isAdmin: false }], + ['an admin without the capability', { canAuthorMetadata: false }], + ['an admin on a runtime with no marketplace', { marketplaceEnabled: false }], + ] as const)('%s gets no Add app tile, as no marketplace shortcut', (_who, override) => { + Object.assign(state, override); + render(); + expect(screen.getByTestId('app-tile-crm')).toBeInTheDocument(); + expect(screen.queryByTestId('home-add-app')).toBeNull(); + expect(screen.queryByTestId('browse-marketplace-btn')).toBeNull(); + }); + + it('a tile shows when its app was last opened, read from the recent-items store', () => { + const hoursAgo = (h: number) => new Date(Date.now() - h * 3_600_000).toISOString(); + state.apps = [CRM, { name: 'ops', label: 'Ops' }, SETUP]; + state.recentItems = [ + // Newest first, as the store keeps them: CRM's newest entry decides. + { id: 'object:account', type: 'object', name: 'account', href: '/apps/crm/account', visitedAt: hoursAgo(2) }, + // Setup is not a tile, so its entry draws no line anywhere. + { id: 'object:sys_user', type: 'object', name: 'sys_user', href: '/apps/com.objectstack.setup/sys_user', visitedAt: hoursAgo(3) }, + { id: 'dashboard:pipeline', type: 'dashboard', name: 'pipeline', href: '/apps/crm/dashboard/pipeline', visitedAt: hoursAgo(5) }, + // An entry with no readable time names nothing: Ops draws no line. + { id: 'record:ticket:1', type: 'record', label: 'T-1', href: '/apps/ops/ticket/record/1', visitedAt: '' }, + ]; + render(); + + const opened = screen.getByTestId('app-tile-opened-crm'); + expect(screen.getByTestId('app-tile-crm')).toContainElement(opened); + // The tile's compact form (`TILE_OPENED_TIME_FORMAT`), not the long one. + expect(opened).toHaveTextContent('«home.appOpened»[time=2 hr. ago]'); + // CONTROL — an app with nothing left in the store draws no line. + expect(screen.getByTestId('app-tile-ops')).toBeInTheDocument(); + expect(screen.queryByTestId('app-tile-opened-ops')).toBeNull(); + expect(screen.queryByTestId('app-tile-opened-setup')).toBeNull(); + }); + + it('the heading names the section and its links sit at its right', () => { + render(); + const heading = screen.getByRole('heading', { level: 2, name: '«home.yourApps»' }); + const bar = heading.parentElement!; + expect(within(bar).getByTestId('browse-marketplace-btn')).toHaveTextContent('«home.browseMarketplace»'); + expect(within(bar).getByTestId('home-system-settings-link')).toBeInTheDocument(); + }); +}); diff --git a/packages/app-shell/src/console/home/__tests__/appLastOpened-12601.test.ts b/packages/app-shell/src/console/home/__tests__/appLastOpened-12601.test.ts new file mode 100644 index 0000000000..e75d855f14 --- /dev/null +++ b/packages/app-shell/src/console/home/__tests__/appLastOpened-12601.test.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#12601 — `lastOpenedByApp`, the reading behind a Home tile's + * "Opened 2 hours ago": the newest recent-items entry opened inside each app. + * + * The store records what was opened INSIDE an app at its `/apps//…` + * address, the segment being the route's own — the package id, or the name + * alias a URL may use (ADR-0048). So these cases pin: the newest entry decides, + * whatever order the list is in; both spellings of an app's segment resolve to + * it; an entry outside every app (`/studio/…`) or under an app the list does + * not hold names nothing; an unreadable time is no time. + */ +import { describe, it, expect } from 'vitest'; +import { lastOpenedByApp } from '../appLastOpened'; + +const CRM = { name: 'crm', _packageId: 'com.acme.crm' }; +const OPS = { name: 'ops' }; + +const visit = (href: string, visitedAt: string) => ({ href, visitedAt }); + +describe('lastOpenedByApp (objectui#12601)', () => { + it('takes the newest entry opened inside each app, keyed by app name', () => { + const opened = lastOpenedByApp( + [CRM, OPS], + [ + visit('/apps/com.acme.crm/account', '2026-10-16T08:00:00.000Z'), + visit('/apps/ops/dashboard/sla', '2026-10-15T09:00:00.000Z'), + // Older than the CRM entry above, though listed after it. + visit('/apps/com.acme.crm/opportunity/record/7', '2026-10-14T10:00:00.000Z'), + ], + ); + expect(opened).toEqual( + new Map([ + ['crm', '2026-10-16T08:00:00.000Z'], + ['ops', '2026-10-15T09:00:00.000Z'], + ]), + ); + }); + + it('resolves the name alias a URL may use to the same app as its package id', () => { + const opened = lastOpenedByApp( + [CRM], + [ + visit('/apps/com.acme.crm/account', '2026-10-14T10:00:00.000Z'), + visit('/apps/crm/lead?view=all', '2026-10-16T08:00:00.000Z'), + ], + ); + expect(opened.get('crm')).toBe('2026-10-16T08:00:00.000Z'); + }); + + it('names nothing for an entry outside every app, under an unknown app, or with no readable time', () => { + const opened = lastOpenedByApp( + [CRM, OPS], + [ + visit('/studio/com.acme.crm/data', '2026-10-16T08:00:00.000Z'), + visit('/apps/finance/invoice', '2026-10-16T08:00:00.000Z'), + visit('/apps/ops/dashboard/sla', ''), + visit('/home', '2026-10-16T08:00:00.000Z'), + ], + ); + expect(opened.size).toBe(0); + }); + + it('CONTROL — the same entries under a known app do name it', () => { + const opened = lastOpenedByApp([CRM, OPS], [visit('/apps/ops/dashboard/sla', '2026-10-16T08:00:00.000Z')]); + expect(opened.get('ops')).toBe('2026-10-16T08:00:00.000Z'); + }); +}); diff --git a/packages/app-shell/src/console/home/__tests__/appTileOpenedLine-12601.test.ts b/packages/app-shell/src/console/home/__tests__/appTileOpenedLine-12601.test.ts new file mode 100644 index 0000000000..24dcea3890 --- /dev/null +++ b/packages/app-shell/src/console/home/__tests__/appTileOpenedLine-12601.test.ts @@ -0,0 +1,135 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#12601 — the line under a Home app tile ("2 hr. ago", zh + * "2小时前打开") fits its tile in every pack, so it is never cut with an + * ellipsis. + * + * Measured live at 1280 px, en: the long form "Opened 6 seconds ago" read + * "Opened 6 secon…" in the tile. The line is now drawn in + * `TILE_OPENED_TIME_FORMAT` (Intl's `short` style, `numeric: 'always'`) through + * each pack's `home.appOpened`, and this file holds every pack's line, for the + * realistic range (seconds to a year), to a width budget. + * + * ## The budget, derived + * + * - The tile is `w-28` (112 px) with `px-2` (8 px each side), and the line is + * `w-full` inside it: 112 − 2 × 8 = 96 px of text, at `text-[11px]`. + * - The test DOM has no layout, so a line's width is ESTIMATED from its + * characters, in em of the 11 px font: a Han / kana / Hangul / full-width + * character 1.0, a space 0.32, an Arabic letter 0.53, a combining mark 0, any + * other character (Latin, Cyrillic, digit, punctuation) 0.62. + * - Those weights were calibrated against real rendering: Chromium, 11 px, + * Inter (the UI font `@object-ui/components` declares) and DejaVu Sans (the + * widest fallback in the container), over every pack's line for these cases. + * The estimate fell short of a measured width by at most 2.4 px, so 3 px are + * held in reserve: the budget is 96 − 3 = 93 px. (A one-time reading, taken + * on the pull request for this card; nothing here re-measures it.) + * + * Exact strings are pinned for en and zh only, at the four points the card + * names; every pack is held to the budget. The strings come from the ICU data + * of the Node that runs the suite, which a browser's ICU can word differently, + * so the budget — not the spelling — is what the other eight packs answer to. + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { builtInLocales } from '@object-ui/i18n/locales'; +import { timeAgo } from '../../../utils/relativeTime'; +import { TILE_OPENED_TIME_FORMAT } from '../appLastOpened'; + +const TILE_PX = 112; +const TILE_PADDING_PX = 8; +const FONT_PX = 11; +const RESERVE_PX = 3; +const BUDGET_PX = TILE_PX - 2 * TILE_PADDING_PX - RESERVE_PX; + +const WIDE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}\u3000-\u303f\uff00-\uffef]/u; +const MARK = /\p{M}/u; +const ARABIC = /\p{Script=Arabic}/u; + +/** The estimated rendered width of `text` at 11 px — see the module doc. */ +function widthPx(text: string): number { + let em = 0; + for (const ch of text) { + if (MARK.test(ch)) continue; + em += WIDE.test(ch) ? 1 : ch === ' ' ? 0.32 : ARABIC.test(ch) ? 0.53 : 0.62; + } + return em * FONT_PX; +} + +const NOW = new Date('2026-10-16T12:00:00Z'); +const SECOND = 1000; +const MINUTE = 60 * SECOND; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +/** Seconds to a year ago, both ends of every unit the formatter switches through. */ +const CASES: Array<[string, number]> = [ + ['6 s', 6 * SECOND], + ['45 s', 45 * SECOND], + ['5 min', 5 * MINUTE], + ['59 min', 59 * MINUTE], + ['3 h', 3 * HOUR], + ['23 h', 23 * HOUR], + ['1 d', 1 * DAY], + ['2 d', 2 * DAY], + ['6 d', 6 * DAY], + ['29 d', 29 * DAY], + ['1 mo', 31 * DAY], + ['11 mo', 330 * DAY], + ['1 y', 365 * DAY], + ['12 y', 12 * 365 * DAY], +]; + +/** The line a tile draws: the pack's template around the tile's relative time. */ +function tileLine(template: string, language: string, ago: number): string { + const iso = new Date(NOW.getTime() - ago).toISOString(); + return template.replace('{{time}}', timeAgo(iso, language, TILE_OPENED_TIME_FORMAT)); +} + +const PACKS = Object.entries(builtInLocales).map(([language, pack]) => ({ + language, + template: (pack as unknown as { home: { appOpened: string } }).home.appOpened, +})); + +beforeAll(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(NOW); +}); +afterAll(() => { + vi.useRealTimers(); +}); + +describe('a Home tile’s last-opened line fits its tile (objectui#12601)', () => { + it(`every pack's line, seconds to a year, is within ${BUDGET_PX} px at ${FONT_PX} px`, () => { + expect(PACKS).toHaveLength(10); + const over: string[] = []; + for (const { language, template } of PACKS) { + expect(template, language).toContain('{{time}}'); + for (const [label, ago] of CASES) { + const line = tileLine(template, language, ago); + if (widthPx(line) > BUDGET_PX) over.push(`${language} ${label}: "${line}" ≈ ${widthPx(line).toFixed(1)} px`); + } + } + expect(over).toEqual([]); + }); + + it('en and zh read as the card asks, at 6 s, 5 min, 3 h and 2 d', () => { + const en = PACKS.find((p) => p.language === 'en')!.template; + const zh = PACKS.find((p) => p.language === 'zh')!.template; + const points = [6 * SECOND, 5 * MINUTE, 3 * HOUR, 2 * DAY]; + expect(points.map((ago) => tileLine(en, 'en', ago))).toEqual([ + '6 sec. ago', + '5 min. ago', + '3 hr. ago', + '2 days ago', + ]); + expect(points.map((ago) => tileLine(zh, 'zh', ago))).toEqual(['6秒前打开', '5分钟前打开', '3小时前打开', '2天前打开']); + }); + + it('CONTROL — the long form the tile used to draw is over the budget', () => { + const iso = new Date(NOW.getTime() - 6 * SECOND).toISOString(); + const before = 'Opened {{time}}'.replace('{{time}}', timeAgo(iso, 'en')); + expect(before).toBe('Opened 6 seconds ago'); + expect(widthPx(before)).toBeGreaterThan(BUDGET_PX); + }); +}); diff --git a/packages/app-shell/src/console/home/appLastOpened.ts b/packages/app-shell/src/console/home/appLastOpened.ts new file mode 100644 index 0000000000..54e981a5d7 --- /dev/null +++ b/packages/app-shell/src/console/home/appLastOpened.ts @@ -0,0 +1,70 @@ +/** + * When the user last had each app open, read from the recent-items store + * (objectui#12601): the time under a Home app tile ("Opened 2 hours ago"). + * + * The store records no app visits of its own. It records what was opened + * INSIDE an app (an object, a dashboard, a page, a report, a record, a Studio + * metadata item), each with the `href` it was opened at and its `visitedAt`. + * Every one of those addresses is `/apps//…`, and the segment is the + * app the user was in: the route's own app segment (`useTrackRouteAsRecent`, + * `RecordDetailView`). So an app was last open when the newest entry under its + * segment was visited, and the segment is resolved to its app the way the + * router resolves it, `matchAppBySegment` (the package id, else the name alias + * a URL may use). + * + * An app with no entry left in the store (it keeps a handful, newest first, and + * a Studio package entry lives under `/studio`, outside every app) has no time: + * it is absent from the map and its tile draws no line, rather than a guess. + * + * @module + */ +import { matchAppBySegment } from '../../utils/index.js'; +import type { RecentItem } from '../../hooks/useRecentItems.js'; + +/** + * The relative-time form a tile's line is drawn in (objectui#12601): + * `Intl.RelativeTimeFormat`'s `short` style, `numeric: 'always'` — "3 hr. ago", + * "1 day ago", "3小时前". The line is 11 px in a 112 px tile and is never cut + * with an ellipsis, so the long form does not fit ("Opened 6 seconds ago" + * was measured truncating), and `numeric: 'auto'`'s phrases ("le mois + * dernier", "l'année dernière") are the longest any pack produces. Each pack's + * `home.appOpened` keeps its verb only where verb and time still fit. The + * width budget every pack's line is held to, and its derivation, are pinned in + * `appTileOpenedLine-12601.test.ts`. + */ +export const TILE_OPENED_TIME_FORMAT = { style: 'short', numeric: 'always' } as const; + +/** The app segment of an `/apps//…` address, or `undefined` for any other. */ +function appSegmentOf(href: string): string | undefined { + const parts = href.split(/[?#]/, 1)[0].split('/').filter(Boolean); + if (parts[0] !== 'apps' || !parts[1]) return undefined; + try { + return decodeURIComponent(parts[1]); + } catch { + return parts[1]; + } +} + +/** + * Each app's last-opened time, by app `name`: the `visitedAt` of the newest + * recent entry opened inside it. Apps with no such entry are not in the map. + */ +export function lastOpenedByApp( + // The app list exactly as the router's resolver takes it — no second app shape. + apps: Parameters[0], + // The two members of a store entry this reads. + recentItems: ReadonlyArray>, +): Map { + const opened = new Map(); + for (const item of recentItems) { + const segment = appSegmentOf(item.href); + if (!segment) continue; + const ms = Date.parse(item.visitedAt); + if (Number.isNaN(ms)) continue; + const app = matchAppBySegment(apps, segment); + if (!app || typeof app.name !== 'string') continue; + const known = opened.get(app.name); + if (!known || ms > known.ms) opened.set(app.name, { iso: item.visitedAt, ms }); + } + return new Map([...opened].map(([name, { iso }]) => [name, iso])); +} diff --git a/packages/app-shell/src/hooks/__tests__/useHomeInbox.approvalsStatus-12601.test.tsx b/packages/app-shell/src/hooks/__tests__/useHomeInbox.approvalsStatus-12601.test.tsx new file mode 100644 index 0000000000..4cb003fd54 --- /dev/null +++ b/packages/app-shell/src/hooks/__tests__/useHomeInbox.approvalsStatus-12601.test.tsx @@ -0,0 +1,102 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#12601 — `useHomeInbox` hands Home the approvals count WITH its + * status, so the line under the greeting can tell an answer from a read that + * has not answered. + * + * The line says "Nothing is waiting for you today" at zero. Said over a read + * that is still in flight or has failed, that is a denial arriving dressed as + * good news (the #4235 class), so Home states it only on `ready`. That rests on + * this hook passing the approvals feed's own status through, which is what + * these cases pin, over the real shared store and a stubbed `fetch`: + * + * - while the approvals request is in flight: `loading`, not `ready`; + * - a 200 answer: `ready`, with the count; + * - a failed read (500): `error`, not `ready`; + * - a 404 (no approvals plugin on this deployment): `ready` with 0 — the + * store's ruled reading of a missing resource, an answer (#4235). + * + * The inbox and activity feeds need an adapter; none is given here, so they + * stay `idle` and only the approvals request goes out. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', email: 'u1@example.com' } }), +})); +vi.mock('../../providers/AdapterProvider', () => ({ useAdapter: () => null })); +vi.mock('../../utils/authToken', () => ({ bearerAuthHeaders: () => ({}) })); + +import { useHomeInbox } from '../useHomeInbox'; +import { __resetSharedUserFeeds } from '../sharedUserFeeds'; + +/** How the approvals endpoint answers this case; `pending` never settles. */ +let answer: 'pending' | { status: number; ids?: string[] } = 'pending'; +let requested: string[] = []; + +const settle = async () => { + await act(async () => { + for (let i = 0; i < 5; i++) await Promise.resolve(); + }); +}; + +beforeEach(() => { + requested = []; + answer = 'pending'; + __resetSharedUserFeeds(); + vi.stubGlobal( + 'fetch', + vi.fn((url: string) => { + requested.push(String(url)); + if (answer === 'pending') return new Promise(() => {}); + const { status, ids = [] } = answer; + return Promise.resolve( + new Response(JSON.stringify({ data: ids.map((id) => ({ id })) }), { + status, + headers: { 'content-type': 'application/json' }, + }), + ); + }), + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + __resetSharedUserFeeds(); +}); + +describe('useHomeInbox passes the approvals status through (objectui#12601)', () => { + it('is `loading`, not `ready`, while the approvals read is in flight', async () => { + const { result } = renderHook(() => useHomeInbox()); + await settle(); + expect(requested.some((u) => u.includes('/api/v1/approvals/requests'))).toBe(true); + expect(result.current.pendingApprovalsStatus).toBe('loading'); + expect(result.current.pendingApprovalsCount).toBe(0); + }); + + it('is `ready` with the count once the read answers', async () => { + answer = { status: 200, ids: ['a1', 'a2'] }; + const { result } = renderHook(() => useHomeInbox()); + await settle(); + expect(result.current.pendingApprovalsStatus).toBe('ready'); + expect(result.current.pendingApprovalsCount).toBe(2); + }); + + it('is `error`, not `ready`, when the read fails', async () => { + answer = { status: 500 }; + const { result } = renderHook(() => useHomeInbox()); + await settle(); + expect(result.current.pendingApprovalsStatus).toBe('error'); + }); + + it('a 404 — no approvals plugin here — is the store’s answer: `ready` with 0', async () => { + answer = { status: 404 }; + const { result } = renderHook(() => useHomeInbox()); + await settle(); + expect(result.current.pendingApprovalsStatus).toBe('ready'); + expect(result.current.pendingApprovalsCount).toBe(0); + }); +}); diff --git a/packages/app-shell/src/hooks/sharedUserFeeds.ts b/packages/app-shell/src/hooks/sharedUserFeeds.ts index c9fb210675..8e60f0b0bd 100644 --- a/packages/app-shell/src/hooks/sharedUserFeeds.ts +++ b/packages/app-shell/src/hooks/sharedUserFeeds.ts @@ -571,8 +571,30 @@ function approverIdentities(user: unknown): string[] { * Feeds the bell's badge (second addend of `unread + approvals`) and its * Approvals tab, and Home's To-do card — from one polled request. Degrades to * 0 on 404 (approvals plugin not installed) and retires the poll. + * + * The count alone: whether it is an answer is {@link useSharedPendingApprovals}. */ export function useSharedPendingApprovalsCount(): number { + return useSharedPendingApprovals().value; +} + +/** + * The pending-approvals feed with its status — the count AND whether it is an + * answer (objectui#12601), the store's `SharedFeedSnapshot` that + * {@link useSharedPendingApprovalsCount} reads `.value` off. Same feed, same one + * request, same vocabulary as every other feed here (#4225): `ready` is an + * answer (a missing approvals plugin included — that is `markUnavailable`, and + * nothing is waiting there), `idle` / `loading` / `error` are not, and the + * value under `error` is the last answer, kept but no longer vouched for. + * + * Read where a surface states the count as a fact — `useHomeInbox` passes the + * status on, and Home's line under the greeting says "Nothing is waiting for + * you today" only on `ready`, so an in-flight or failed read is not reported + * as good news. + * + * Package-internal: not exported from `./index.ts` or the package entry. + */ +export function useSharedPendingApprovals(): SharedFeedSnapshot { const { user } = useAuth(); const identities = approverIdentities(user); // `user?.id` is the sign-in gate; identities is what the query needs. @@ -598,7 +620,7 @@ export function useSharedPendingApprovalsCount(): number { const seen = new Set(); for (const row of (payload?.data || []) as { id: string }[]) seen.add(row.id); return seen.size; - }).value; + }); } // ── Recent activity ────────────────────────────────────────────────────────── diff --git a/packages/app-shell/src/hooks/useHomeInbox.ts b/packages/app-shell/src/hooks/useHomeInbox.ts index 390e544a1e..886fb5e7e2 100644 --- a/packages/app-shell/src/hooks/useHomeInbox.ts +++ b/packages/app-shell/src/hooks/useHomeInbox.ts @@ -61,8 +61,9 @@ import { useMemo } from 'react'; import { useHumanActivityFeed, useSharedInboxFeed, - useSharedPendingApprovalsCount, + useSharedPendingApprovals, type ActivityFeedReading, + type SharedFeedStatus, } from './sharedUserFeeds.js'; import { groupNotifications } from '../layout/inboxGrouping.js'; @@ -94,6 +95,13 @@ export type HomeInboxStatus = 'idle' | 'loading' | 'ready' | 'error'; export interface HomeInboxData { pendingApprovalsCount: number; + /** + * Whether `pendingApprovalsCount` is an answer (objectui#12601) — the + * approvals feed's own status, passed through as `notificationsStatus` is + * for the inbox. Only `ready` is one; a surface that states the count as a + * fact ("Nothing is waiting for you today") states it only then. + */ + pendingApprovalsStatus: SharedFeedStatus; notifications: HomeNotification[]; /** * How many distinct unread TOPICS are waiting — the whole unread set, not the @@ -130,7 +138,7 @@ export function useHomeInbox(limit = 5): HomeInboxData { // activity rows: real human actions only, dropping the sys_*/ai_* churn // (actor "System"). `useSharedInboxFeed` is the bell's already-joined inbox // superset, cut here to what is actually waiting on the user. - const pendingApprovalsCount = useSharedPendingApprovalsCount(); + const { value: pendingApprovalsCount, status: pendingApprovalsStatus } = useSharedPendingApprovals(); const activity = useHumanActivityFeed(limit); const { value: messages, status: notificationsStatus } = useSharedInboxFeed(); @@ -169,6 +177,7 @@ export function useHomeInbox(limit = 5): HomeInboxData { return { pendingApprovalsCount, + pendingApprovalsStatus, notifications, unreadTopicCount, notificationsStatus, diff --git a/packages/app-shell/src/layout/AppHeader.tsx b/packages/app-shell/src/layout/AppHeader.tsx index a9f24b57b2..0cdcaa99cc 100644 --- a/packages/app-shell/src/layout/AppHeader.tsx +++ b/packages/app-shell/src/layout/AppHeader.tsx @@ -22,7 +22,9 @@ * the app, `system/*` included (objectui#12472). * - `home` : no breadcrumb; displays the product wordmark (from * `getProductName()`, default "ObjectOS") next to the brand - * logo. Used by `/home`. + * logo. Used by `/home`, whose frame mounts a palette, so from + * `lg` up its search field is the bar's middle zone, wider than + * an app's (objectui#12601). * - `orgs` : no breadcrumb; logo + "Organizations" title. Used by the * `/organizations` landing page. * - `studio`: no breadcrumb; logo + a fixed "Studio" crumb, as `orgs` draws @@ -484,12 +486,20 @@ export function AppHeader({ // center whatever either side holds. The left zone keeps `min-w-0`, and the // app pill truncates inside it; the cluster never shrinks below its // content (`lg:min-w-max`), so when the utilities need more than half the - // bar the search yields to the left instead of overlapping them. App - // context only, on every route inside the app, `system/*` included: the - // `home` / `orgs` / `studio` variants keep the search at the cluster's left - // edge, and below `lg` the search is the icon button in the cluster - // everywhere. - const centersSearch = isApp && hasCommandPalette; + // bar the search yields to the left instead of overlapping them. Inside an + // app it is every route, `system/*` included; below `lg` the search is the + // icon button in the cluster everywhere. + // + // objectui#12601 — the `home` variant centers it too, wherever a palette is + // mounted (`HomeLayout` mounts one): Home's bar is the product mark, the + // search field and the utilities, so the field is the bar's middle, wider + // than inside an app and named for what the palette finds. The `orgs` and + // `studio` variants keep the search at the cluster's left edge. + const isHomeSearch = resolvedVariant === 'home' && hasCommandPalette; + const centersSearch = (isApp && hasCommandPalette) || isHomeSearch; + const searchLabel = isHomeSearch + ? t('home.search', { defaultValue: 'Search apps, records, pages…' }) + : t('console.search', { defaultValue: 'Search…' }); // Search — desktop. One element, drawn in the middle zone or in the // cluster's search group (see `centersSearch`). @@ -497,14 +507,17 @@ export function AppHeader({ diff --git a/packages/app-shell/src/utils/relativeTime.ts b/packages/app-shell/src/utils/relativeTime.ts index ec46eda394..0176547488 100644 --- a/packages/app-shell/src/utils/relativeTime.ts +++ b/packages/app-shell/src/utils/relativeTime.ts @@ -15,11 +15,17 @@ import { formatRelativeTime } from '@object-ui/i18n'; /** * Format an ISO timestamp as a locale-aware "3 days ago" / "3天前". * Returns an empty string for a missing or unparseable value so callers can - * drop the meta column without a null check. + * drop the meta column without a null check. `options` are + * `Intl.RelativeTimeFormat`'s `style` / `numeric`, for a surface that needs + * the compact form (see `formatRelativeTime`). */ -export function timeAgo(iso: string | undefined, locale: string): string { +export function timeAgo( + iso: string | undefined, + locale: string, + options?: Parameters[2], +): string { if (!iso) return ''; const ms = new Date(iso).getTime(); if (Number.isNaN(ms)) return ''; - return formatRelativeTime(ms, locale); + return formatRelativeTime(ms, locale, options); } diff --git a/packages/i18n/src/__tests__/untranslated-identity-4376.test.ts b/packages/i18n/src/__tests__/untranslated-identity-4376.test.ts index c419fa2dfe..b7af78b8d7 100644 --- a/packages/i18n/src/__tests__/untranslated-identity-4376.test.ts +++ b/packages/i18n/src/__tests__/untranslated-identity-4376.test.ts @@ -117,6 +117,12 @@ const LEGITIMATE_IDENTITIES: Record = { // fr the French space-before-colon. ja/ko/ru/ar keep `: ` because that IS // their punctuation here, which is a translation decision, not an omission. 'grid.summary.pattern': 'Aggregate label/value join — a separator and two holes, no prose.', + // Pure format string as well (objectui#12601): the line under a Home app + // tile is the time hole plus a verb only where both fit the 112 px tile at + // 11 px. zh/ja/ko/ar add theirs; ru, like en, has no room for one, so its + // value is the hole alone — a fit decision held by app-shell's + // `appTileOpenedLine-12601.test.ts`, not an omission. + 'home.appOpened': 'Tile last-opened line — the bare `{{time}}` hole where no verb fits the tile.', // Loanwords these packs have adopted as their OWN vocabulary (checked against // their neighbours: zh writes `Logo 链接` / `Logo 已上传…`, ru writes `Email подтверждён`). diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 55f5ce8fbf..b23b8cc268 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -2818,12 +2818,10 @@ const ar = { home: { build: { title: "إنشاء تطبيق", - subtitle: "ابدأ من الصفر — صمّم الكائنات والنماذج والأتمتة والواجهات.", noCapability: "إنشاء التطبيقات غير متاح في مساحة العمل هذه — فهو يتطلب إذن «إدارة البيانات الوصفية» الذي لا يملكه حسابك.", }, template: { title: "ابدأ من قالب", - subtitle: "ثبّت تطبيق قالب من السوق وخصّصه.", marketplaceDisabled: "لا يوجد سوق تطبيقات مُهيّأ لوقت التشغيل هذا، لذا لا توجد قوالب لتثبيتها هنا.", }, welcomeAdminDescription: "صف نشاطك التجاري في جملة واحدة — وسيولّد الذكاء الاصطناعي الكائنات والشاشات وواجهات البرمجة وأدوات الوكيل. أو أعدّها بنفسك من القائمة على اليسار.", @@ -2893,7 +2891,19 @@ const ar = { greetingNight: "وردية الليل", greetingSeparator: "، ", greetingEnd: ".", - heroTagline: "استمر من حيث توقفت أو اكتشف شيئاً جديداً.", + search: "ابحث في التطبيقات والسجلات والصفحات…", + summary: { + approvals: "لديك اليوم {{count}} من طلبات الموافقة المعلّقة", + approvals_zero: "لديك اليوم {{count}} من طلبات الموافقة المعلّقة", + approvals_one: "لديك اليوم {{count}} من طلبات الموافقة المعلّقة", + approvals_two: "لديك اليوم {{count}} من طلبات الموافقة المعلّقة", + approvals_few: "لديك اليوم {{count}} من طلبات الموافقة المعلّقة", + approvals_many: "لديك اليوم {{count}} من طلبات الموافقة المعلّقة", + approvals_other: "لديك اليوم {{count}} من طلبات الموافقة المعلّقة", + none: "لا شيء بانتظارك اليوم", + }, + addApp: "إضافة تطبيق", + appOpened: "فُتح {{time}}", open: "فتح", recentApps: { title: "فُتح مؤخراً", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index f2bb7ba8dc..16f270700b 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -2606,12 +2606,10 @@ const de = { home: { build: { title: "App erstellen", - subtitle: "Von Grund auf beginnen — Objekte, Formulare, Automatisierungen und Oberflächen gestalten.", noCapability: "Das Erstellen von Apps ist in diesem Arbeitsbereich nicht verfügbar — es erfordert die Berechtigung „Metadaten verwalten“, die Ihr Konto nicht besitzt.", }, template: { title: "Mit einer Vorlage starten", - subtitle: "Installieren Sie eine Vorlagen-App aus dem Marktplatz und passen Sie sie an.", marketplaceDisabled: "Für diese Laufzeitumgebung ist kein App-Marktplatz konfiguriert, daher gibt es hier keine Vorlagen zum Installieren.", }, welcomeAdminDescription: "Beschreiben Sie Ihr Geschäft in einem Satz — die KI erzeugt die Objekte, Bildschirme, APIs und Agent-Tools. Oder richten Sie alles selbst über das Menü links ein.", @@ -2677,7 +2675,15 @@ const de = { greetingNight: "Nachtschicht", greetingSeparator: ", ", greetingEnd: ".", - heroTagline: "Machen Sie dort weiter, wo Sie aufgehört haben, oder entdecken Sie etwas Neues.", + search: "Apps, Datensätze, Seiten durchsuchen…", + summary: { + approvals: "Heute warten {{count}} Genehmigungen auf Sie", + approvals_one: "Heute wartet {{count}} Genehmigung auf Sie", + approvals_other: "Heute warten {{count}} Genehmigungen auf Sie", + none: "Heute wartet nichts auf Sie", + }, + addApp: "App hinzufügen", + appOpened: "{{time}}", open: "Öffnen", recentApps: { title: "Zuletzt geöffnet", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 1482cfaf36..aafbe0f681 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -3140,15 +3140,31 @@ const en = { // full-width marks, ar its own comma. greetingSeparator: ', ', greetingEnd: '.', - heroTagline: 'Pick up where you left off, or explore something new.', + // objectui#12601 — the field in Home's top bar that opens the command palette. + search: 'Search apps, records, pages…', + // objectui#12601 — the line under the greeting that sums up what is + // waiting: the approvals count, or `none` at zero. + summary: { + approvals: 'You have {{count}} approval(s) waiting today', + approvals_one: 'You have {{count}} approval waiting today', + approvals_other: 'You have {{count}} approvals waiting today', + none: 'Nothing is waiting for you today', + }, + // objectui#12601 — an admin's trailing tile in the apps strip; opens the marketplace. + addApp: 'Add app', + // objectui#12601 — the line under a Home app tile: when the user last had + // the app open, `time` a compact relative time ("2 hr. ago"). The line is + // 11 px in a 112 px tile and never cut, so a pack adds a verb only where + // verb and time still fit (zh "{{time}}打开"); English does not have the + // room for "Opened". Held to its width budget in every pack by + // `appTileOpenedLine-12601.test.ts` (app-shell). + appOpened: '{{time}}', build: { title: 'Build an app', - subtitle: 'Start from scratch — design objects, forms, automations and interfaces.', noCapability: 'Building apps isn\'t available in this workspace — it requires the “Manage Metadata” permission, which your account doesn\'t have.', }, template: { title: 'Start with a template', - subtitle: 'Install a template app from the marketplace and customize it.', marketplaceDisabled: 'This runtime has no app marketplace configured, so there are no templates to install here.', }, open: 'Open', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 1e841b6523..c2e30c996b 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -2658,12 +2658,10 @@ const es = { home: { build: { title: "Crear una aplicación", - subtitle: "Empiece desde cero — diseñe objetos, formularios, automatizaciones e interfaces.", noCapability: "La creación de aplicaciones no está disponible en este espacio de trabajo: requiere el permiso «Gestionar metadatos», que su cuenta no tiene.", }, template: { title: "Empezar con una plantilla", - subtitle: "Instale una aplicación de plantilla desde el mercado y personalícela.", marketplaceDisabled: "Este runtime no tiene un marketplace de aplicaciones configurado, por lo que no hay plantillas que instalar aquí.", }, welcomeAdminDescription: "Describa su negocio en una frase — la IA genera los objetos, las pantallas, las API y las herramientas del agente. O configúrelo usted mismo desde el menú de la izquierda.", @@ -2730,7 +2728,16 @@ const es = { greetingNight: "Turno de noche", greetingSeparator: ", ", greetingEnd: ".", - heroTagline: "Continúe donde lo dejó o descubra algo nuevo.", + search: "Buscar aplicaciones, registros, páginas…", + summary: { + approvals: "Hoy tiene {{count}} aprobaciones pendientes", + approvals_one: "Hoy tiene {{count}} aprobación pendiente", + approvals_many: "Hoy tiene {{count}} aprobaciones pendientes", + approvals_other: "Hoy tiene {{count}} aprobaciones pendientes", + none: "Hoy no tiene nada pendiente", + }, + addApp: "Agregar aplicación", + appOpened: "{{time}}", open: "Abrir", recentApps: { title: "Abiertos recientemente", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index ef44af9963..6d656c3ea9 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -2655,12 +2655,10 @@ const fr = { home: { build: { title: "Créer une application", - subtitle: "Partir de zéro — concevez les objets, formulaires, automatisations et interfaces.", noCapability: "La création d'applications n'est pas disponible dans cet espace de travail : elle requiert l'autorisation « Gérer les métadonnées », que votre compte ne possède pas.", }, template: { title: "Partir d'un modèle", - subtitle: "Installez une application modèle depuis la place de marché et personnalisez-la.", marketplaceDisabled: "Aucune place de marché d'applications n'est configurée pour ce runtime : il n'y a donc aucun modèle à installer ici.", }, welcomeAdminDescription: "Décrivez votre activité en une phrase — l'IA génère les objets, les écrans, les API et les outils d'agent. Ou configurez tout vous-même depuis le menu de gauche.", @@ -2727,7 +2725,16 @@ const fr = { greetingNight: "Travail de nuit", greetingSeparator: ", ", greetingEnd: ".", - heroTagline: "Reprenez là où vous vous êtes arrêté ou découvrez quelque chose de nouveau.", + search: "Rechercher des applications, enregistrements, pages…", + summary: { + approvals: "{{count}} approbations vous attendent aujourd'hui", + approvals_one: "{{count}} approbation vous attend aujourd'hui", + approvals_many: "{{count}} approbations vous attendent aujourd'hui", + approvals_other: "{{count}} approbations vous attendent aujourd'hui", + none: "Rien ne vous attend aujourd'hui", + }, + addApp: "Ajouter une application", + appOpened: "{{time}}", open: "Ouvrir", recentApps: { title: "Récemment ouverts", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 69abf7954d..5ac4d4c00f 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -2608,12 +2608,10 @@ const ja = { home: { build: { title: "アプリを作る", - subtitle: "ゼロから始める — オブジェクト、フォーム、自動化、画面を設計します。", noCapability: "このワークスペースではアプリの作成はできません — 「メタデータ管理」権限が必要ですが、お使いのアカウントにはありません。", }, template: { title: "テンプレートから始める", - subtitle: "マーケットプレイスからテンプレートアプリをインストールしてカスタマイズします。", marketplaceDisabled: "このランタイムにはアプリマーケットプレイスが設定されていないため、ここからインストールできるテンプレートはありません。", }, welcomeAdminDescription: "ビジネスを一文で説明してください — AI がオブジェクト、画面、API、エージェントツールを生成します。左のメニューから自分で設定することもできます。", @@ -2679,7 +2677,15 @@ const ja = { greetingNight: "夜遅くまで作業中", greetingSeparator: "、", greetingEnd: "。", - heroTagline: "前回の続きを始めるか、新しいものを探索してください。", + search: "アプリ、レコード、ページを検索…", + summary: { + approvals: "今日は承認待ちが {{count}} 件あります", + approvals_one: "今日は承認待ちが {{count}} 件あります", + approvals_other: "今日は承認待ちが {{count}} 件あります", + none: "今日の対応事項はありません", + }, + addApp: "アプリを追加", + appOpened: "{{time}}に起動", open: "開く", recentApps: { title: "最近アクセスしたもの", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index ffb3e07e6d..26b334f406 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -2606,12 +2606,10 @@ const ko = { home: { build: { title: "앱 만들기", - subtitle: "처음부터 시작 — 객체, 폼, 자동화, 인터페이스를 설계합니다.", noCapability: "이 워크스페이스에서는 앱 만들기를 사용할 수 없습니다 — ‘메타데이터 관리’ 권한이 필요하지만 계정에 해당 권한이 없습니다.", }, template: { title: "템플릿으로 시작", - subtitle: "마켓플레이스에서 템플릿 앱을 설치하고 원하는 대로 바꾸세요.", marketplaceDisabled: "이 런타임에는 앱 마켓플레이스가 구성되어 있지 않아 여기에서 설치할 템플릿이 없습니다.", }, welcomeAdminDescription: "비즈니스를 한 문장으로 설명해 주세요 — AI가 객체, 화면, API, 에이전트 도구를 생성합니다. 왼쪽 메뉴에서 직접 설정할 수도 있습니다.", @@ -2676,7 +2674,15 @@ const ko = { greetingNight: "야간 작업 중", greetingSeparator: ", ", greetingEnd: ".", - heroTagline: "마지막으로 중단한 곳에서 이어서 하거나 새로운 것을 발견해 보세요.", + search: "앱, 레코드, 페이지 검색…", + summary: { + approvals: "오늘 승인 대기 중인 항목이 {{count}}건 있습니다", + approvals_one: "오늘 승인 대기 중인 항목이 {{count}}건 있습니다", + approvals_other: "오늘 승인 대기 중인 항목이 {{count}}건 있습니다", + none: "오늘은 처리할 일이 없습니다", + }, + addApp: "앱 추가", + appOpened: "{{time}}에 열람", open: "열기", recentApps: { title: "최근 열린 항목", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 6bb16a01ce..b0c2f7508a 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -2653,12 +2653,10 @@ const pt = { home: { build: { title: "Criar um aplicativo", - subtitle: "Comece do zero — projete objetos, formulários, automações e interfaces.", noCapability: "A criação de aplicativos não está disponível neste espaço de trabalho — ela exige a permissão “Gerenciar metadados”, que sua conta não possui.", }, template: { title: "Comece com um modelo", - subtitle: "Instale um aplicativo modelo do marketplace e personalize-o.", marketplaceDisabled: "Este runtime não tem um marketplace de aplicativos configurado, portanto não há modelos para instalar aqui.", }, welcomeAdminDescription: "Descreva seu negócio em uma frase — a IA gera os objetos, as telas, as APIs e as ferramentas do agente. Ou configure você mesmo pelo menu à esquerda.", @@ -2725,7 +2723,16 @@ const pt = { greetingNight: "Turno da noite", greetingSeparator: ", ", greetingEnd: ".", - heroTagline: "Continue de onde parou ou descubra algo novo.", + search: "Pesquisar aplicativos, registros, páginas…", + summary: { + approvals: "Hoje você tem {{count}} aprovações pendentes", + approvals_one: "Hoje você tem {{count}} aprovação pendente", + approvals_many: "Hoje você tem {{count}} aprovações pendentes", + approvals_other: "Hoje você tem {{count}} aprovações pendentes", + none: "Nada aguardando você hoje", + }, + addApp: "Adicionar aplicativo", + appOpened: "{{time}}", open: "Abrir", recentApps: { title: "Abertos recentemente", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index e41129f24d..072cc4a618 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -2722,12 +2722,10 @@ const ru = { home: { build: { title: "Создать приложение", - subtitle: "Начните с нуля — спроектируйте объекты, формы, автоматизации и интерфейсы.", noCapability: "Создание приложений недоступно в этом рабочем пространстве — для этого нужно разрешение «Управление метаданными», которого нет у вашей учётной записи.", }, template: { title: "Начать с шаблона", - subtitle: "Установите шаблонное приложение из маркетплейса и настройте его под себя.", marketplaceDisabled: "Для этого рантайма не настроен маркетплейс приложений, поэтому здесь нет шаблонов для установки.", }, welcomeAdminDescription: "Опишите свой бизнес одним предложением — ИИ создаст объекты, экраны, API и инструменты агента. Либо настройте всё сами через меню слева.", @@ -2796,7 +2794,17 @@ const ru = { greetingNight: "Ночная смена", greetingSeparator: ", ", greetingEnd: ".", - heroTagline: "Продолжите с того места, где остановились, или откройте что-то новое.", + search: "Поиск приложений, записей, страниц…", + summary: { + approvals: "Сегодня {{count}} запросов ждут вашего согласования", + approvals_one: "Сегодня {{count}} запрос ждёт вашего согласования", + approvals_few: "Сегодня {{count}} запроса ждут вашего согласования", + approvals_many: "Сегодня {{count}} запросов ждут вашего согласования", + approvals_other: "Сегодня {{count}} запроса ждут вашего согласования", + none: "Сегодня ничего не ждёт вашего внимания", + }, + addApp: "Добавить приложение", + appOpened: "{{time}}", open: "Открыть", recentApps: { title: "Недавно открытые", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index d8e71db5e5..68c6e936f2 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -2701,15 +2701,21 @@ const zh = { greetingNight: '夜深了', greetingSeparator: ',', greetingEnd: '。', - heroTagline: '从上次离开的地方继续,或者探索新的内容。', + search: '搜索应用、记录、页面…', + summary: { + approvals: '今天有 {{count}} 项待审批', + approvals_one: '今天有 {{count}} 项待审批', + approvals_other: '今天有 {{count}} 项待审批', + none: '今天没有待办', + }, + addApp: '添加应用', + appOpened: '{{time}}打开', build: { title: '构建应用', - subtitle: '从零开始 — 设计对象、表单、自动化与界面。', noCapability: '此工作区不提供构建应用功能 — 它需要「管理元数据」权限,而你的账号没有该权限。', }, template: { title: '从模板开始', - subtitle: '从应用市场安装模板应用并按需定制。', marketplaceDisabled: '本运行时未配置应用市场,因此这里没有可安装的模板。', }, open: '打开', diff --git a/packages/i18n/src/utils/formatting.ts b/packages/i18n/src/utils/formatting.ts index a290aed228..5595062b38 100644 --- a/packages/i18n/src/utils/formatting.ts +++ b/packages/i18n/src/utils/formatting.ts @@ -99,10 +99,17 @@ export function formatDateTime( * midnight), the same distance in every zone at the same wall-clock time. * An unparsable value comes back as its string, as it does from * {@link formatDate}; `Intl.RelativeTimeFormat` would throw on it. + * + * `options` are `Intl.RelativeTimeFormat`'s own `style` and `numeric`, passed + * through unchanged; omitted, the result is what it always was (`long`, + * `auto`: "yesterday", "2 days ago"). A surface with little room asks for a + * compact form, e.g. `{ style: 'short', numeric: 'always' }` ("1 day ago", + * "3 hr. ago"). */ export function formatRelativeTime( date: Date | string | number, locale = 'en', + options: Pick = {}, ): string { const d = toDisplayDate(date); if (isNaN(d.getTime())) { @@ -115,7 +122,10 @@ export function formatRelativeTime( const diffHour = Math.round(diffMin / 60); const diffDay = Math.round(diffHour / 24); - const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }); + const rtf = new Intl.RelativeTimeFormat(locale, { + numeric: options.numeric ?? 'auto', + style: options.style ?? 'long', + }); if (Math.abs(diffSec) < 60) return rtf.format(diffSec, 'second'); if (Math.abs(diffMin) < 60) return rtf.format(diffMin, 'minute');