diff --git a/BUILD-NOTES.md b/BUILD-NOTES.md index 9ae6443..bfd8735 100644 --- a/BUILD-NOTES.md +++ b/BUILD-NOTES.md @@ -878,3 +878,15 @@ separates fresh final checks, applied-asset viewer proof and final sandbox check unchanged-source16main/48boundary/no-JS/growth/contrast/fallback evidence. Private fixture databases, authentication, logs and backup material are excluded. No analytics, customer/financial data, launch, billing, schema, infrastructure or unrelated runtime behavior changes. + +## Limited customer UI approval: artifact detail and template library + +The user rejected broad admin redesign adoption. This change adopts only the artifact-detail **arrangement**, keeping the current product shell, type, palette, red Sparkline and StatCard/Audience implementations. The document/sharing pair uses a scoped 2:1 layout at the existing 760px breakpoint and stacks in DOM order below it; native focusable anchors lead to Document, Sharing, Audience, History and Save as template. Real forms, confirmation dialogs, preview security and domain behavior are unchanged. + +Only the authenticated `/dashboard/templates` cards are simplified to image, linked title and tags. The separate public catalog renderer is unchanged. Stored/API/preview descriptions and all 20 canonical template/source/thumbnail options are untouched. The redundant Preview button is removed, not the preview action: the existing stretched title link opens it from the whole card. + +Library navigation uses native links, styled as tabs, with `library=mine|builtin` and `aria-current=page`; Tab/Enter, reload, back and no-JavaScript navigation work without a new client script. Built-in remains the default because it was the first group before this change, including for accounts with personal templates. Legacy preview URLs select the preview's library; explicit mismatched library/preview pairs show only the chosen library, not a confusing cross-library preview. Successful promotion's existing notice selects My templates. Preview/close preserve the library; switching library clears preview. There was no template-list filter or pagination to replace. + +Scoped integration red controls fail on the previous source for missing detail groups, missing library selection and visible card descriptions. Existing assertions move to the explicit personal destination / new query-preserving preview URLs. Browser regressions exercise the existing seven responsive projects, real sandboxed owner preview and JavaScript-disabled library navigation. The style guide documents these two targeted patterns using current primitives; no global redesign, dependency, schema, provider or launch-flag change is included. + +Homepage activation is separately authorized after this UI rollout is verified: existing `AA_COMING_SOON=false` selects the already-built HomePage. This PR does not merge/deploy or change that runtime flag. Existing production deployment and rollback safeguards remain required. diff --git a/src/routes/dashboard.ts b/src/routes/dashboard.ts index f5378c8..3714f71 100644 --- a/src/routes/dashboard.ts +++ b/src/routes/dashboard.ts @@ -672,6 +672,14 @@ export function registerHumanRoutes(app: HumanApp, context: HumanRoutesContext): DashboardTemplatesPage({ account: accountView(session.account), templates, + library: + routeContext.req.query('library') === 'mine' || + (!routeContext.req.query('library') && + routeContext.req.query('notice') === 'template_promoted') + ? 'mine' + : routeContext.req.query('library') === 'builtin' + ? 'builtin' + : undefined, previewTemplate: await getTemplatePreview( services, session.account.id, diff --git a/src/ui/assets/app.css b/src/ui/assets/app.css index faeeb57..8d90648 100644 --- a/src/ui/assets/app.css +++ b/src/ui/assets/app.css @@ -573,6 +573,26 @@ grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr)); } + /* Artifact detail only: the document leads, sharing sits beside it when there is room. + Audience keeps its existing widgets and chart below this pair, not a third narrow column. */ + .aa-artifact-detail__columns { + display: grid; + min-width: 0; + gap: var(--spacing-aa-4); + align-items: start; + grid-template-columns: minmax(0, 1fr); + } + + .aa-artifact-detail__columns > * { + min-width: 0; + } + + @media (min-width: 760px) { + .aa-artifact-detail__columns { + grid-template-columns: minmax(0, 2fr) minmax(16rem, 1fr); + } + } + .aa-grid--3 { grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr)); } @@ -3511,10 +3531,7 @@ height: 3rem; } - /* A column, so the actions row can be pushed to the bottom edge of the tallest card in the row. - Cards already stretch to a common height; without this the Preview buttons sat wherever each - description happened to end, and a one-line description next to a two-line one put two - identical buttons on two different baselines. */ + /* Listing cards show only their image, linked title and tags. Full copy stays in the preview. */ .aa-template-card__body { display: flex; min-width: 0; @@ -3539,8 +3556,7 @@ text-decoration: none; } - /* The whole card opens the preview; the Preview button is the same destination, named out loud - for a reader scanning for a control. The button sits above this overlay on its own z-index. */ + /* One link opens the preview from the whole card; no duplicate Preview control. */ .aa-template-card__link::after { position: absolute; inset: 0; @@ -3573,26 +3589,16 @@ gap: var(--spacing-aa-2); } - .aa-template-card__subline { - min-width: 0; - margin: 0; - color: var(--color-aa-muted); - font-size: var(--text-aa-sm); - overflow-wrap: anywhere; + /* Native page links, not script-dependent tabs: reload/back/no-JS retain the library. */ + .aa-template-tabs .aa-tab { + text-decoration: none; + white-space: normal; + text-align: center; } - .aa-template-card__actions { - position: relative; - z-index: 1; - display: flex; - min-width: 0; - max-width: 100%; - flex-wrap: wrap; - justify-content: flex-start; - /* The pin: whatever the description's length, the button sits on the card's bottom edge. */ - margin-top: auto; - padding-top: var(--spacing-aa-3); - gap: var(--spacing-aa-2); + .aa-template-tabs .aa-tab[aria-current="page"] { + color: var(--color-aa-accent); + border-bottom-color: var(--color-aa-accent); } /* The template preview frame. A framed document has no intrinsic size, so without this it falls diff --git a/src/ui/pages/dashboard.tsx b/src/ui/pages/dashboard.tsx index 37d8132..6f3434e 100644 --- a/src/ui/pages/dashboard.tsx +++ b/src/ui/pages/dashboard.tsx @@ -534,35 +534,67 @@ export function DashboardArtifactPage({ -
- - {artifact.type === 'markdown' && artifact.htmlPreview ? ( -
- ) : previewUrl ? ( - /* - * Absolute and cross-origin on cloud, absolute and same-origin self-hosted — the URL - * the route handed down, never one built here. A relative `src` is what broke this - * card: it resolves to the dashboard origin, and the dashboard's own CSP admits only - * the sandbox host to `frame-src`, so on cloud the browser refused the load and the - * "Rendered preview" was blank. `sandbox="allow-scripts"` stays: the attribute keeps - * the document in an opaque origin whichever host served it. - */ - + +
+
+ + {artifact.type === 'markdown' && artifact.htmlPreview ? ( +
+ ) : previewUrl ? ( + /* + * Absolute and cross-origin on cloud, absolute and same-origin self-hosted — the URL + * the route handed down, never one built here. A relative `src` is what broke this + * card: it resolves to the dashboard origin, and the dashboard's own CSP admits only + * the sandbox host to `frame-src`, so on cloud the browser refused the load and the + * "Rendered preview" was blank. `sandbox="allow-scripts"` stays: the attribute keeps + * the document in an opaque origin whichever host served it. + */ + + ) : null} + +
+
+ +
+
+ {stats ? ( +
+ +
+ ) : null} +
+
- - {diff ? : null} - +
+ +
); @@ -661,7 +693,10 @@ export function DashboardBotsPage({ ); } +export type TemplateLibrary = 'builtin' | 'mine'; + export interface DashboardTemplatesPageProps { + library?: TemplateLibrary | undefined; account: DashboardAccountView; templates: DashboardTemplateView[]; previewTemplate?: DashboardTemplatePreview | null | undefined; @@ -671,6 +706,7 @@ export interface DashboardTemplatesPageProps { export function DashboardTemplatesPage({ account, + library, templates, previewTemplate, extensionNavItems, @@ -679,6 +715,12 @@ export function DashboardTemplatesPage({ const starters = templates.filter((template) => template.builtIn); const personal = templates.filter((template) => !template.builtIn); + // Keep the old first group as the default. Legacy preview links open their own library; + // a successful promotion opens My templates so the saved result is not hidden on arrival. + const selected = library ?? (previewTemplate && !previewTemplate.builtIn ? 'mine' : 'builtin'); + const visiblePreview = + previewTemplate?.builtIn === (selected === 'builtin') ? previewTemplate : null; + return ( + {/* GROUPED BY THE JOB, not listed by name. The same six categories the public gallery and `GET /v1/templates?category=` use, so a person browsing here and an agent filtering there are looking at one taxonomy. A flat @@ -709,40 +767,49 @@ export function DashboardTemplatesPage({ has is "what do I start from for the thing I am doing". Empty categories are not rendered — a heading over nothing is a heading that has to be read to discover it says nothing. */} - {TEMPLATE_CATEGORY_ORDER.map((category) => { - const group = starters.filter((template) => template.category === category); - return group.length === 0 ? null : ( - - ); - })} - {starters.length === 0 ? ( + {selected === 'builtin' + ? TEMPLATE_CATEGORY_ORDER.map((category) => { + const group = starters.filter((template) => template.category === category); + return group.length === 0 ? null : ( + + ); + }) + : null} + {selected === 'builtin' && starters.length === 0 ? ( ) : null} - - Pick an artifact to promote → - - } - /> - {previewTemplate ? : null} + {selected === 'mine' ? ( + + Pick an artifact to promote → + + } + /> + ) : null} + {visiblePreview ? ( + + ) : null}
); @@ -2083,7 +2150,13 @@ function BotActions({ bot }: { bot: DashboardBotView }) { * Slots are listed only when there are any. "Slots: none" on the three HTML examples that declare * none was a field-form frame on something that is not a form. */ -function TemplatePreviewPanel({ template }: { template: DashboardTemplatePreview }) { +function TemplatePreviewPanel({ + template, + library, +}: { + template: DashboardTemplatePreview; + library: TemplateLibrary; +}) { const isHtml = template.type === 'html'; return (
@@ -2132,7 +2205,7 @@ function TemplatePreviewPanel({ template }: { template: DashboardTemplatePreview value={template.content} /> - @@ -2154,6 +2227,7 @@ function TemplateGroup({ id, title, templates, + library, emptyTitle, empty, emptyAction, @@ -2161,6 +2235,7 @@ function TemplateGroup({ id: string; title: string; templates: DashboardTemplateView[]; + library: TemplateLibrary; emptyTitle: string; empty: string; emptyAction?: Child | undefined; @@ -2179,7 +2254,7 @@ function TemplateGroup({ {templates.map((template) => ( - + ))}
@@ -2199,8 +2274,14 @@ function TemplateGroup({ * It is a template-specific card rather than `DashboardCard` because the cover image changes the * card's shape, not its trim, and Artifacts and Bots must not inherit that. */ -function TemplateCard({ template }: { template: DashboardTemplateView }) { - const href = `/dashboard/templates?preview=${template.id}#template-preview`; +function TemplateCard({ + template, + library, +}: { + template: DashboardTemplateView; + library: TemplateLibrary; +}) { + const href = `/dashboard/templates?library=${library}&preview=${template.id}#template-preview`; return (
  • {/* The cover is the card's own link target via the stretched pseudo-element below, so it @@ -2225,14 +2306,6 @@ function TemplateCard({ template }: { template: DashboardTemplateView }) { {template.type === 'markdown' ? 'md' : 'html'} -

    - {template.description ?? 'No description yet — open the preview to see the example.'} -

    -
    - -
  • ); diff --git a/src/ui/pages/style-guide.tsx b/src/ui/pages/style-guide.tsx index e7d85c8..e728c91 100644 --- a/src/ui/pages/style-guide.tsx +++ b/src/ui/pages/style-guide.tsx @@ -1092,57 +1092,87 @@ function dangerCardSection() { function cardTableSection() { return ( -
    - - - - - } - > -

    Use cards for grouped forms, setup steps, and dashboard side panels.

    -
    - - md, 'Shared', '{relative}', '142'], - ['Launch Notes', html, 'Private', 'Yesterday', '0'], - ['Incident Retro', md, 'Password protected', 'Aug 25', '38'], - ]} - /> -
    - The region is focusable, named from its caption, and carries an inline-end fade plus a - hint whenever scrollWidth > clientWidth — measured, never assumed, and - re-measured on resize. A table that fits shows neither. -
    - - {tableColumnPrioritySection()} +
    +
    + +

    Primary preview. Existing Audience widgets follow the pair.

    +
    + +

    Adjacent on desktop; follows the document on mobile.

    +
    +
    + +

    + Library tabs are native page links: Tab then Enter, with selection kept in the URL. Cards + contain image, title and tags; descriptions remain in previews. +

    +
    + + + + + } + > +

    + Use cards for grouped forms, setup steps, and dashboard side panels. +

    +
    + +
    md, 'Shared', '{relative}', '142'], + ['Launch Notes', html, 'Private', 'Yesterday', '0'], + [ + 'Incident Retro', + md, + 'Password protected', + 'Aug 25', + '38', + ], + ]} + /> +
    + The region is focusable, named from its caption, and carries an inline-end fade plus a + hint whenever scrollWidth > clientWidth — measured, never assumed, and + re-measured on resize. A table that fits shows neither. +
    + + {tableColumnPrioritySection()} + ); } diff --git a/tests/e2e/owner-preview.spec.ts b/tests/e2e/owner-preview.spec.ts index 2bfbe43..2802df7 100644 --- a/tests/e2e/owner-preview.spec.ts +++ b/tests/e2e/owner-preview.spec.ts @@ -280,3 +280,74 @@ async function readMagicLink(since: number): Promise { await new Promise((resolve) => setTimeout(resolve, 150)); } } + +test('detail grouping gives the document room without replacing owner controls', async ({ + page, +}) => { + await signIn(page); + await page.goto(`${CLOUD_BASE_URL}/dashboard/artifacts/${seed.artifactId}`); + const documentPane = page.locator('#document'); + const sharing = page.locator('#sharing'); + const d = await documentPane.boundingBox(); + const s = await sharing.boundingBox(); + expect(d).not.toBeNull(); + expect(s).not.toBeNull(); + if (!d || !s) throw new Error('Document or sharing layout missing'); + if ((page.viewportSize()?.width ?? 0) >= 760) { + expect(d.width).toBeGreaterThan(s.width); + expect(Math.abs(d.y - s.y)).toBeLessThan(1); + } else { + expect(s.y).toBeGreaterThan(d.y + d.height); + } + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + for (const id of ['document', 'sharing', 'audience', 'history', 'save-template']) { + await page.locator(`nav[aria-label="Artifact sections"] a[href="#${id}"]`).click(); + await expect(page.locator(`#${id}`)).toBeInViewport(); + await expect(page.locator(`#${id}`)).toBeFocused(); + } + await expect(page.locator('#document iframe')).toHaveAttribute('sandbox', 'allow-scripts'); + await expect(page.getByRole('button', { name: 'Save as template', exact: true })).toBeVisible(); +}); + +test('template library links and one-link cards retain URL selection without JavaScript', async ({ + page, + browser, +}) => { + await signIn(page); + const context = await browser.newContext({ + storageState: await page.context().storageState(), + javaScriptEnabled: false, + viewport: page.viewportSize() ?? { width: 1440, height: 1000 }, + }); + const plain = await context.newPage(); + try { + await plain.goto(`${CLOUD_BASE_URL}/dashboard/templates`); + const nav = plain.getByRole('navigation', { name: 'Template library', exact: true }); + await expect(nav.getByRole('link', { name: 'Built-in templates' })).toHaveAttribute( + 'aria-current', + 'page' + ); + await expect(plain.locator('.aa-template-card')).toHaveCount(20); + await expect(plain.locator('.aa-template-card p, .aa-template-card button')).toHaveCount(0); + await expect(plain.locator('.aa-template-card a')).toHaveCount(20); + await nav.getByRole('link', { name: 'My templates' }).focus(); + await plain.keyboard.press('Enter'); + await expect(plain).toHaveURL(/library=mine/); + await plain.reload(); + await expect(plain.getByRole('link', { name: 'My templates', exact: true })).toHaveAttribute( + 'aria-current', + 'page' + ); + await plain.getByRole('link', { name: 'Built-in templates', exact: true }).click(); + await plain.locator('.aa-template-card a').first().click(); + await expect(plain.locator('#template-preview')).toBeInViewport(); + await plain.getByRole('link', { name: 'Close preview' }).click(); + await expect(plain).toHaveURL(/library=builtin$/); + await expect(plain.locator('#template-preview')).toHaveCount(0); + expect(await plain.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe( + true + ); + } finally { + await context.close(); + } +}); diff --git a/tests/integration/dashboard-actions.test.ts b/tests/integration/dashboard-actions.test.ts index 2d859f7..003d40b 100644 --- a/tests/integration/dashboard-actions.test.ts +++ b/tests/integration/dashboard-actions.test.ts @@ -100,7 +100,7 @@ describe('M4 dashboard screens and actions', () => { } ); expect(promoted.status).toBe(303); - const templates = await ctx.app.request('/dashboard/templates', { + const templates = await ctx.app.request('/dashboard/templates?library=mine', { headers: { Cookie: cookie }, }); expect(templates.status).toBe(200); diff --git a/tests/integration/dashboard-detail-r4.test.ts b/tests/integration/dashboard-detail-r4.test.ts index d474d31..e9b33a9 100644 --- a/tests/integration/dashboard-detail-r4.test.ts +++ b/tests/integration/dashboard-detail-r4.test.ts @@ -159,3 +159,35 @@ describe('B-D4 · the risk ladder stops being inverted', () => { expect(refused.headers.get('location')).toContain('notice=restore_confirm_mismatch'); }); }); + +describe('limited detail arrangement preserves the real panels', () => { + it('orders document/sharing before intact Audience, history and promotion with focusable anchors', async () => { + const ctx = await makeContext(); + const { cookie, artifactId } = await seed(ctx, { versions: 3, share: true }); + const html = await ( + await ctx.app.request(`/dashboard/artifacts/${artifactId}`, { headers: { Cookie: cookie } }) + ).text(); + expect(html).toContain('class="aa-artifact-detail__columns"'); + let previous = -1; + for (const id of ['document', 'sharing', 'audience', 'history', 'save-template']) { + expect(html).toContain(`href="#${id}"`); + expect(html).toContain(`id="${id}" tabindex="-1"`); + const position = html.indexOf(`id="${id}"`); + expect(position).toBeGreaterThan(previous); + previous = position; + } + for (const action of [ + 'download', + 'share/password', + 'share/revoke', + 'restore', + 'promote-template', + 'delete', + ]) { + expect(html).toContain(`/artifacts/${artifactId}/${action}`); + } + expect(html).toContain('data-aa-dashboard-preview="markdown"'); + expect(html).toContain('data-aa-confirm-match="v1"'); + expect(html).toContain('id="aa-audience-title"'); + }); +}); diff --git a/tests/integration/dashboard-reveal-r4.test.ts b/tests/integration/dashboard-reveal-r4.test.ts index 14e9292..bcfbd57 100644 --- a/tests/integration/dashboard-reveal-r4.test.ts +++ b/tests/integration/dashboard-reveal-r4.test.ts @@ -83,7 +83,9 @@ describe('B-C5 · a revealed panel is somewhere the browser actually goes', () = const listing = await ( await ctx.app.request('/dashboard/templates', { headers: { Cookie: cookie } }) ).text(); - expect(listing).toMatch(/href="\/dashboard\/templates\?preview=[^"]*#template-preview"/); + expect(listing).toMatch( + /href="\/dashboard\/templates\?library=builtin&preview=[^"]*#template-preview"/ + ); const revealed = await ( await ctx.app.request(`/dashboard/templates?preview=${report.id}`, { @@ -126,6 +128,6 @@ describe('B-C6 · what can be opened can be closed', () => { const panel = html.split('
    { @@ -155,7 +156,7 @@ describe('B-M1 · an empty template table says its state, not its title twice', const { cookie } = await seedAll(ctx); const html = await ( - await ctx.app.request('/dashboard/templates', { headers: { Cookie: cookie } }) + await ctx.app.request('/dashboard/templates?library=mine', { headers: { Cookie: cookie } }) ).text(); expect(html.split('Your templates').length - 1).toBe(1); diff --git a/tests/integration/dashboard-template-gallery.test.ts b/tests/integration/dashboard-template-gallery.test.ts index f1c3bdc..8188ea0 100644 --- a/tests/integration/dashboard-template-gallery.test.ts +++ b/tests/integration/dashboard-template-gallery.test.ts @@ -112,7 +112,7 @@ describe('the templates listing shows the example instead of describing its fiel const { cookie, promotedId } = await seed(ctx); const html = await ( - await ctx.app.request('/dashboard/templates', { headers: { Cookie: cookie } }) + await ctx.app.request('/dashboard/templates?library=mine', { headers: { Cookie: cookie } }) ).text(); const card = cardFor(html, promotedId); @@ -234,3 +234,58 @@ describe('the template frame is gated the way the artifact frame is', () => { expect(panel).not.toContain(' { + it('keeps built-ins as the default and makes personal items an explicit native destination', async () => { + const ctx = await makeContext(); + const { cookie, digestId, promotedId } = await seed(ctx); + const read = async (query = '') => + ( + await ctx.app.request(`/dashboard/templates${query}`, { headers: { Cookie: cookie } }) + ).text(); + const builtin = await read(); + expect(builtin).toContain('href="/dashboard/templates?library=builtin" aria-current="page"'); + expect(builtin).toContain(`preview=${digestId}#template-preview`); + expect(builtin).not.toContain(`preview=${promotedId}#template-preview`); + const mine = await read('?library=mine'); + expect(mine).toContain('href="/dashboard/templates?library=mine" aria-current="page"'); + expect(mine).toContain(`library=mine&preview=${promotedId}#template-preview`); + expect(mine).not.toContain(`preview=${digestId}#template-preview`); + expect(await read('?notice=template_promoted')).toContain( + `preview=${promotedId}#template-preview` + ); + expect(await read('?library=invalid')).toContain( + 'href="/dashboard/templates?library=builtin" aria-current="page"' + ); + }); + + it('omits description and redundant actions from cards without deleting preview or stored copy', async () => { + const ctx = await makeContext(); + const { cookie, promotedId } = await seed(ctx); + const listing = await ( + await ctx.app.request('/dashboard/templates?library=mine', { headers: { Cookie: cookie } }) + ).text(); + const card = cardFor(listing, promotedId); + expect(card).not.toContain('Promoted from my own artifact.'); + expect(card).not.toMatch(/]/); + expect(card).not.toContain('My templates'); + const mismatched = await ( + await ctx.app.request(`/dashboard/templates?library=builtin&preview=${promotedId}`, { + headers: { Cookie: cookie }, + }) + ).text(); + expect(mismatched).not.toContain('id="template-preview"'); + }); +});