mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* fix(web): demote Plugins and Integrations to nav rail footer
Plugins and Integrations are platform-configuration surfaces, not
daily-use destinations. Moving them to the footer section of the
left nav rail — separated from primary items by a thin divider —
keeps them reachable while giving the primary four items
(Home, Projects, Automations, Design Systems) the visual weight
they deserve.
- EntryNavRail: remove Plugins/Integrations from the main __group
and place them in the __footer above the help launcher
- entry-layout.css: add __divider rule (1 px separator) to visually
mark the boundary between primary and secondary nav regions
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): remove settings dropdown, gear opens settings directly
The gear/cog button previously opened a dropdown that mixed three
unrelated concerns: community links (X, Discord), preference quick-
access (Language, Appearance), a feature shortcut (Use everywhere),
and a redundant Settings entry — creating two separate paths to the
same Settings dialog and duplicating Language/Appearance relative to
the Settings sidebar.
Changes:
- Gear button now directly opens the Settings dialog (no intermediate
dropdown layer)
- Follow @nexudotio on X and Join Discord moved to the Help menu at
the bottom of the nav rail, where community/external links belong
- Language and Appearance remain exclusively in the Settings dialog
- Use everywhere remains exclusively in the topbar chip
- Remove dead state (avatarMenuOpen, languageExpanded,
appearanceExpanded), ref, outside-click effect, and module-level
constants (APPEARANCE_THEMES, APPEARANCE_LABEL, describeModelChip)
that were introduced solely to support the dropdown
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(web): move output-type chips above chat input as tab bar
The home hero previously had two chip rows below the chat input that
mixed two unrelated dimensions: output type (Prototype, Slide deck,
Image…) and workflow source (Create plugin, From Figma, From folder,
From template). Users had no visual cue that these were different
categories.
This change separates the two dimensions clearly:
- Output type (create group) becomes a tab bar positioned above the
input card. Tabs share the same chip data and onPickChip dispatch,
so plugin selection, active state, and pending state are unchanged.
Active tab shows a colored underline; the bar border visually
connects to the input card below.
- Workflow source (migrate group) stays as the chip row below the
input card, now standing alone with unambiguous "how to start"
semantics.
- Subtitle updated from "Pick a plugin below" to "Pick a type"
to match the new placement.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): skip replace-prompt confirmation when switching output-type tabs
Output-type tab clicks (create group) are mode-selection gestures;
the user expects the prompt to update immediately without a dialog.
The confirmation is still shown for migrate-group chips (From Figma
etc.) where the replacement carries meaningful user-provided content.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): use brand logo as Home destination, drop redundant brand mentions
The entry nav rail previously rendered the brand logo and a separate
Home icon back-to-back; both invoked `onViewChange('home')`, so the
Home button was pure duplicate affordance. The hero pane also
displayed a third "Open Design" lockup that competed with the rail
logo and the rebranded title.
This collapses those affordances:
* `EntryNavRail` drops the dedicated Home `NavButton`. The brand
logo button now carries `aria-current="page"` and an `is-active`
visual state when the home view is showing, and its tooltip reads
"Open Design · Home" off-home so the navigation behavior stays
discoverable for new users.
* `entry-layout.css` adds the matching `.entry-nav-rail__logo.is-active`
accent treatment so the "you are here" cue reads at parity with
primary rail buttons.
* `HomeHero` removes the inline `home-hero__brand` lockup and the
associated CSS, then retunes the title/subtitle/type-tab spacing
so the headline group still pairs tightly with the type tabs and
input card below.
* `entry-chrome-flows` is updated to assert the logo carries the
active page treatment and that no `entry-nav-home` test id
resurfaces by mistake.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): auto-grow home chat input, disable internal scroll and drag-resize
The home hero textarea was capped at a fixed `min-height: 84px` with
`resize: vertical`, so users had to either drag the bottom-right
corner to enlarge it or scroll the textarea internally to read
longer prompts. That hid context (loaded plugin templates routinely
overflow three lines) and exposed a manual grip whose state was easy
to leave in an awkward height.
This change makes the chat box grow with its content:
* `HomeHero` adds a `useLayoutEffect` that, on every prompt change,
resets the textarea height to `auto` so the browser can measure a
smaller content, then writes back `scrollHeight` in pixels. The
effect uses layout phase (not effect phase) to avoid a one-frame
flash at the previous height when a plugin loads a long example
prompt.
* `home-hero.css` swaps `resize: vertical` for `resize: none` and
adds `overflow: hidden`, so the manual drag grip disappears and
the textarea never scrolls internally. `min-height: 84px` is kept
so the empty input still reads as a chunky chat box. The outer
page handles overflow when the prompt is genuinely very long.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): make output-type tabs read as folder-tabs attached to chat box
The previous tab bar used a small icon + label per tab and signaled
the active tab with a 2px accent underline sitting on a horizontal
divider line. That divider visually broke the relationship between
the tabs and the input card below — the active tab looked like a
free-floating header, not a flap of the chat surface, so users had
to do extra work to mentally connect "I picked Prototype" with the
prompt area immediately underneath.
This switches the tab bar to a folder-tab pattern:
* `HomeHero` drops the per-tab `Icon` element. The seven labels
(Prototype, Live artifact, Slide deck, Image, Video, HyperFrames,
Audio) already disambiguate at the type sizes used here, and the
icons were primarily decorative.
* `home-hero.css` rewrites the tab styling:
- The bar no longer paints its own baseline border; the input
card's top edge serves as the baseline.
- Each tab is a rounded-top container with a 1px transparent
border and a `-1px` bottom margin, so its bottom edge overlaps
the card's top border by exactly one pixel.
- The active tab borrows the card's panel background and border
color, and overrides its bottom border with the panel color
so it visually erases the card's top border for the tab's
width. The result reads as one continuous "Prototype is this
chat box" surface.
- The card's prior `margin-top: 8px` is removed so the tab bar
bottom and card top sit at the same y coordinate, which is the
geometric precondition for the 1px overlap to land cleanly.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): enlarge home chat attach and submit buttons for legibility
The attach (paper-clip) and submit (arrow-up) controls on the home
chat input rendered at 32px diameter with 14px and 18px glyphs
respectively. After stroke antialiasing the icons read at roughly
11–12px on typical displays — small enough that users reported the
glyphs were illegible and had to be discovered by trial-and-error.
This bumps both controls to 38px circles and grows their glyphs
(attach 14 → 18, submit 18 → 22). The primary call-to-action now
has clearly more visual weight than the surrounding muted hint
text, and the paper-clip is recognisable at a glance.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): paint a chevron on inline select slots in the home prompt
Inline select slots in the home prompt overlay (e.g. the
`high-fidelity` / `wireframe` fidelity picker on the Live artifact
template) rendered as plain pill highlights, visually identical to
free-text and read-only text slots. The slot's `appearance: none`
strips the browser's default chevron, so users had no affordance
hinting the value was switchable.
This wraps select-type slots in an `inline-flex` span and overlays
an explicit chevron-down `Icon` against the slot's trailing padding
(bumped from 18px to 22px to make room). The chevron inherits the
accent color via the wrap's `color` property so it themes correctly
in light and dark modes. Click semantics are unchanged because the
chevron is `pointer-events: none`.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): size inline number slots to their value, not the whole row
Number-type inline slots in the home prompt (e.g. the slide-count
spinner on the Slide deck template) inherited the generic slot
`min-width: 8ch` and then stretched to the browser's default
`<input type=number>` width, so a two-digit value like "10" ate
the entire remaining line and pushed the native spinner buttons
to the far right edge — far away from the value they control.
This sizes the number input to its actual content plus four
characters of trailing room for the spinner buttons (clamped to
6–14ch), and adds a `home-hero__prompt-slot-input--number`
modifier that overrides the slot `min-width` to 4ch. The
spinner now sits flush against the value and the slot reads as
a compact inline pill matching the surrounding text/select
slots.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): widen prompt line-height so slot pills do not collide vertically
Each interactive slot and mention in the home prompt paints a 2px
outline ring via `box-shadow`. At the previous `line-height: 1.55`
on a 15px font, two lines were ~23px apart while a single pill
occupied ~20px of vertical space (text box + ring on both sides),
leaving roughly 2px of clear space between rows. When the prompt
wrapped onto multiple lines — common for the Image template's
"Generate a {kind} of {subject}. Style: {style}. Aspect: {ratio}."
example — the rings from line N and line N+1 visually merged into
a single bar, making it ambiguous which pill the user was about to
click or edit.
This bumps the line-height of both the highlight overlay and the
underlying editable textarea to 1.85 (~28px per line), restoring
~8px of clear space between pill rows. The two values must stay
identical so the overlay glyphs continue to track the textarea
caret positions exactly; a brief comment in each rule documents
this coupling for future edits.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): paint dropdown chevron on select element with neutral color
The previous chevron treatment wrapped each select slot in an
inline-flex span and laid an accent-orange `<Icon>` over the
trailing padding. At the prompt's normal display size the orange
chevron blended into the orange pill ring corners, so users
perceived "a bunch of dropdown arrows" across every highlighted
slot, even though only the actual select rendered one.
Move the chevron onto the `<select>` itself as a small neutral-
gray `background-image` SVG. The grey contrasts clearly with the
pill's accent ring and the chevron lives inside the select's own
padding, so it can never visually overlap the value text and can
never appear on non-select slots.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): shrink select-slot dropdown caret so it stops reading as a check-mark
The 8×6 stroked chevron looked like a check-mark in zoomed views
because the stroke width was a large fraction of the glyph. Swap
for a small (9×5) filled triangle drawn as a `background-image`,
with `background-size` pinned so browsers can't scale it to its
intrinsic SVG box. The caret is now unambiguously a dropdown
indicator without crowding the value text.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): make read-only prompt slots visually distinct from editable pills
Multi-word context values are rendered as plain <span>s with
pointer-events: none, but they inherited the same orange pill +
ring treatment as the truly editable <input>/<select> slots.
Users couldn't tell which pills they could click into.
Strip the pill background, the ring, the radius, and the padding
from `.home-hero__prompt-slot-text` and replace them with a
subtle dashed bottom border. The orange foreground keeps the
slot family link, but read-only highlights now clearly read as
"context value spliced into the prompt" rather than as
"interactive control".
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): scale select-slot dropdown caret up to 12px wide for legibility
The 9×5 caret was too quiet at the prompt's font size to read as
a dropdown affordance. Bump to a 12×6 filled triangle and pad the
select's trailing space (24px) so the value text still has clear
breathing room from the caret.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): collapse plugins search and filter strips into one bar
The plugins-home gallery laid out search, count, mode (Featured),
total-in-catalog, clear-filters, the main category row, and the
sub-category row as four floating clusters. Search lived up in
the section header, far from the chips it actually scopes; the
mode strip wedged "Featured + 386 in catalog + Clear filters"
between the header and the category row with no obvious
ownership; and the two clear-filter affordances duplicated each
other (chip strip + empty-state).
Fold the mode strip into the category row: Featured is now the
leading chip on the same line as the category pills, and the
search field, the result count, and a compact Clear link sit as
a right-aligned tools cluster on that same row. The header
shrinks back to just the title, subtitle, and Browse registry
link. The sub-category row stays as the contextual second line
when a category is active.
Tests: keeps the existing data-testids (plugins-home-chip-featured,
plugins-home-row-category, plugins-home-clear, plugins-home-count,
plugins-home-search) so the existing 11-case section spec passes
without modification.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): hide Recent projects rail on first-run home when empty
A brand-new user landing on Home saw an empty dashed box with the
copy "No projects yet — type a prompt to start one." sitting
above the plugin gallery. The hero already prompts the user to
type, so the empty rail just adds vertical noise without telling
them anything new.
Return null from RecentProjectsStrip when the recent list is
empty (loading or not) so the rail appears only when there is
actually something to show. Update the entry-chrome e2e to
assert toHaveCount(0) on first run — codifying the new contract
that the rail is conditional, not always-mounted.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): hide structured inputs form when every field is in the prompt
The PluginInputsForm below the chat textarea surfaced every plugin
input — even the ones the prompt template already substitutes
inline as highlighted slot pills. For a plugin like Prototype
that's five identical labelled inputs duplicating the five slot
pills above them, making the chat box look like it has grown a
second composer.
Compute the set of placeholder keys actually referenced in the
prompt template (via INPUT_PLACEHOLDER_PATTERN) and skip those
when deciding what the structured form needs to render. The form
still appears for plugins that expose inputs not referenced in
the prompt text (e.g. a "Run in background" toggle), but
template-only plugins collapse the redundant second editor.
Update the picker spec accordingly: when every field is in the
template the form is now expected to be absent rather than to
render a duplicate of the inline slot.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): drop redundant filtered-count and Clear link beside the search
The combined plugins filter bar trailed the search field with
"59 / 386" and an inline "Clear" link, but both repeat what the
chip strip already shows: every chip carries its own count
(Slides 59, All 386, …) and clicking the All chip already resets
the filters. Users had no way to know what the bare "59 / 386"
fraction or "Clear" referred to without inference.
Strip the trailing tools cluster down to just the search input.
The empty-results message at the bottom of the gallery still
exposes a contextual "Clear filters" button when a stacked
filter yields no matches, so the affordance isn't lost — just
removed from a position where it didn't read as actionable.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): drop default subtitle copy from the Official starters strip
The Home gallery rendered a two-line explanatory subtitle under
"Official starters" — "Ready-to-use Open Design workflows bundled
with this runtime. Pick one to load a starter prompt, or browse
the registry for more." — every visit. Returning users skip it,
new users get the same message from the section title plus the
Browse registry link plus the visual card grid itself; the prose
read as filler chrome above the chip strip.
Default the subtitle prop to undefined and only render the <p>
when a caller passes an explicit string. Other surfaces that
mount PluginsHomeSection with their own copy keep their
subtitle; the bare Home gallery loses one row of vertical noise.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fade out empty workflow lanes in Plugins home filter
Empty top-level lanes (Deploy 0, Refine 0, etc.) and empty
sub-categories (Vercel 0 under Deploy, Figma 0 under Import, etc.)
used to look identical to populated ones, so users couldn't tell at
a glance which chips were real catalog buckets and which were
"contribute a plugin" invites. The strip kept all lanes visible by
design — the workflow shape (Import / Create / Export / Share /
Deploy / Refine / Extend) is part of what we want users to see —
so we keep them clickable, but tag count-zero chips with
data-empty='true' and give them a faded, dashed-border treatment.
"All" pills stay solid since their count reflects the parent lane,
not their own emptiness.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): update home nav test expectations
* fix(e2e): align critical smoke with entry chrome
---------
Co-authored-by: chaoxiaoche <chaoxiaoche@chaoxiaochedeMacBook-Pro.local>
Co-authored-by: Cursor <cursoragent@cursor.com>
748 lines
24 KiB
TypeScript
748 lines
24 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { HomeView } from '../../src/components/HomeView';
|
|
import {
|
|
createPluginAuthoringHandoff,
|
|
createPluginUseHandoff,
|
|
PLUGIN_AUTHORING_DEFAULT_GOAL,
|
|
PLUGIN_AUTHORING_PROMPT,
|
|
} from '../../src/components/home-hero/plugin-authoring';
|
|
|
|
const AUTHORING_PLUGIN = {
|
|
id: 'od-plugin-authoring',
|
|
title: 'Plugin authoring',
|
|
version: '0.1.0',
|
|
trust: 'bundled' as const,
|
|
sourceKind: 'bundled' as const,
|
|
source: '/tmp/plugin-authoring',
|
|
capabilitiesGranted: ['prompt:inject'],
|
|
fsPath: '/tmp/plugin-authoring',
|
|
installedAt: 0,
|
|
updatedAt: 0,
|
|
manifest: {
|
|
name: 'od-plugin-authoring',
|
|
title: 'Plugin authoring',
|
|
version: '0.1.0',
|
|
description: 'Create plugins',
|
|
od: {
|
|
kind: 'scenario',
|
|
taskKind: 'new-generation',
|
|
useCase: { query: 'Create an Open Design plugin for {{pluginGoal}}.' },
|
|
inputs: [
|
|
{
|
|
name: 'pluginGoal',
|
|
type: 'string',
|
|
required: false,
|
|
default: PLUGIN_AUTHORING_DEFAULT_GOAL,
|
|
label: 'Plugin goal',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
};
|
|
|
|
const DEFAULT_PLUGIN = {
|
|
...AUTHORING_PLUGIN,
|
|
id: 'od-new-generation',
|
|
title: 'New generation',
|
|
source: '/tmp/new-generation',
|
|
fsPath: '/tmp/new-generation',
|
|
manifest: {
|
|
...AUTHORING_PLUGIN.manifest,
|
|
name: 'od-new-generation',
|
|
title: 'New generation',
|
|
description: 'Create new design artifacts',
|
|
od: {
|
|
kind: 'scenario',
|
|
taskKind: 'new-generation',
|
|
useCase: { query: 'Create a plugin.' },
|
|
},
|
|
},
|
|
};
|
|
|
|
const HIDDEN_DEFAULT_PLUGIN = {
|
|
...DEFAULT_PLUGIN,
|
|
id: 'od-default',
|
|
title: 'Default design router',
|
|
source: '/tmp/default-router',
|
|
fsPath: '/tmp/default-router',
|
|
manifest: {
|
|
...DEFAULT_PLUGIN.manifest,
|
|
name: 'od-default',
|
|
title: 'Default design router',
|
|
od: {
|
|
...DEFAULT_PLUGIN.manifest.od,
|
|
hidden: true,
|
|
},
|
|
},
|
|
};
|
|
|
|
// The Prototype / Live-artifact chips now bind to the bundled
|
|
// `example-web-prototype` plugin (which ships its own seed +
|
|
// layouts + checklist) instead of the generic od-new-generation
|
|
// router. Mirror that here so the chip-applies test can find a
|
|
// matching plugin record and the apply call resolves to the new id.
|
|
const WEB_PROTOTYPE_PLUGIN = {
|
|
...DEFAULT_PLUGIN,
|
|
id: 'example-web-prototype',
|
|
title: 'Web Prototype',
|
|
source: '/tmp/web-prototype',
|
|
fsPath: '/tmp/web-prototype',
|
|
manifest: {
|
|
...DEFAULT_PLUGIN.manifest,
|
|
name: 'example-web-prototype',
|
|
title: 'Web Prototype',
|
|
description: 'General-purpose desktop web prototype.',
|
|
od: {
|
|
kind: 'scenario',
|
|
taskKind: 'new-generation',
|
|
useCase: {
|
|
query: 'Build a {{fidelity}} {{artifactKind}} for {{audience}} using {{designSystem}} from {{template}}.',
|
|
},
|
|
inputs: [
|
|
{
|
|
name: 'artifactKind',
|
|
type: 'string',
|
|
required: true,
|
|
default: 'web prototype',
|
|
label: 'Artifact kind',
|
|
},
|
|
{
|
|
name: 'fidelity',
|
|
type: 'select',
|
|
required: true,
|
|
options: ['wireframe', 'high-fidelity'],
|
|
default: 'high-fidelity',
|
|
label: 'Fidelity',
|
|
},
|
|
{
|
|
name: 'audience',
|
|
type: 'string',
|
|
required: true,
|
|
default: 'product evaluators',
|
|
label: 'Audience',
|
|
},
|
|
{
|
|
name: 'designSystem',
|
|
type: 'string',
|
|
default: 'the active project design system',
|
|
label: 'Design system',
|
|
},
|
|
{
|
|
name: 'template',
|
|
type: 'string',
|
|
default: 'the bundled web prototype seed',
|
|
label: 'Template',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
};
|
|
|
|
const AUTHORING_DEFAULT_SCENARIO_INPUTS = {
|
|
artifactKind: 'Open Design plugin',
|
|
audience: 'Open Design plugin authors',
|
|
topic: 'packaging a reusable workflow as an Open Design plugin',
|
|
};
|
|
|
|
const AUTHORING_APPLY_RESULT = {
|
|
query: 'Create a plugin.',
|
|
contextItems: [],
|
|
inputs: AUTHORING_PLUGIN.manifest.od.inputs,
|
|
assets: [],
|
|
mcpServers: [],
|
|
trust: 'trusted',
|
|
capabilitiesGranted: ['prompt:inject'],
|
|
capabilitiesRequired: ['prompt:inject'],
|
|
appliedPlugin: {
|
|
snapshotId: 'snap-authoring',
|
|
pluginId: 'od-plugin-authoring',
|
|
pluginVersion: '0.1.0',
|
|
manifestSourceDigest: 'a'.repeat(64),
|
|
inputs: { pluginGoal: PLUGIN_AUTHORING_DEFAULT_GOAL },
|
|
resolvedContext: { items: [] },
|
|
capabilitiesGranted: ['prompt:inject'],
|
|
capabilitiesRequired: ['prompt:inject'],
|
|
assetsStaged: [],
|
|
taskKind: 'new-generation',
|
|
appliedAt: 0,
|
|
connectorsRequired: [],
|
|
connectorsResolved: [],
|
|
mcpServers: [],
|
|
status: 'fresh',
|
|
},
|
|
projectMetadata: {},
|
|
};
|
|
|
|
const DEFAULT_APPLY_RESULT = {
|
|
...AUTHORING_APPLY_RESULT,
|
|
inputs: [],
|
|
appliedPlugin: {
|
|
...AUTHORING_APPLY_RESULT.appliedPlugin,
|
|
snapshotId: 'snap-default',
|
|
pluginId: 'od-new-generation',
|
|
inputs: AUTHORING_DEFAULT_SCENARIO_INPUTS,
|
|
},
|
|
};
|
|
|
|
const WEB_PROTOTYPE_APPLY_RESULT = {
|
|
...AUTHORING_APPLY_RESULT,
|
|
query: WEB_PROTOTYPE_PLUGIN.manifest.od.useCase.query,
|
|
inputs: WEB_PROTOTYPE_PLUGIN.manifest.od.inputs,
|
|
appliedPlugin: {
|
|
...AUTHORING_APPLY_RESULT.appliedPlugin,
|
|
snapshotId: 'snap-web-prototype',
|
|
pluginId: 'example-web-prototype',
|
|
inputs: {
|
|
artifactKind: 'web prototype',
|
|
fidelity: 'high-fidelity',
|
|
audience: 'product evaluators',
|
|
designSystem: 'the active project design system',
|
|
template: 'the bundled web prototype seed',
|
|
},
|
|
},
|
|
};
|
|
|
|
describe('HomeView prompt handoff', () => {
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
cleanup();
|
|
});
|
|
|
|
it('consumes a plugin authoring handoff once and focuses the textarea', async () => {
|
|
vi.stubGlobal('fetch', vi.fn(async () => (
|
|
new Response(JSON.stringify({ plugins: [] }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
})
|
|
)));
|
|
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
|
cb(0);
|
|
return 0;
|
|
});
|
|
|
|
const { rerender } = render(
|
|
<HomeView
|
|
projects={[]}
|
|
onSubmit={() => undefined}
|
|
onOpenProject={() => undefined}
|
|
onViewAllProjects={() => undefined}
|
|
promptHandoff={createPluginAuthoringHandoff(1)}
|
|
/>,
|
|
);
|
|
|
|
const input = await screen.findByTestId('home-hero-input');
|
|
await waitFor(() => {
|
|
expect((input as HTMLTextAreaElement).value).toBe(PLUGIN_AUTHORING_PROMPT);
|
|
expect(document.activeElement).toBe(input);
|
|
});
|
|
|
|
fireEvent.change(input, { target: { value: 'User edited prompt' } });
|
|
|
|
rerender(
|
|
<HomeView
|
|
projects={[]}
|
|
onSubmit={() => undefined}
|
|
onOpenProject={() => undefined}
|
|
onViewAllProjects={() => undefined}
|
|
promptHandoff={createPluginAuthoringHandoff(1)}
|
|
/>,
|
|
);
|
|
|
|
expect((input as HTMLTextAreaElement).value).toBe('User edited prompt');
|
|
});
|
|
|
|
it('uses the same authoring prompt from the Home rail chip', async () => {
|
|
vi.stubGlobal('fetch', vi.fn(async () => (
|
|
new Response(JSON.stringify({ plugins: [] }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
})
|
|
)));
|
|
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
|
cb(0);
|
|
return 0;
|
|
});
|
|
|
|
render(
|
|
<HomeView
|
|
projects={[]}
|
|
onSubmit={() => undefined}
|
|
onOpenProject={() => undefined}
|
|
onViewAllProjects={() => undefined}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(await screen.findByTestId('home-hero-rail-create-plugin'));
|
|
|
|
const input = await screen.findByTestId('home-hero-input');
|
|
await waitFor(() => {
|
|
expect((input as HTMLTextAreaElement).value).toBe(PLUGIN_AUTHORING_PROMPT);
|
|
expect(document.activeElement).toBe(input);
|
|
});
|
|
expect(screen.queryByRole('alert')).toBeNull();
|
|
});
|
|
|
|
it('adds a plugin-use handoff from the Plugins page as context', async () => {
|
|
const fetchMock = vi.fn<typeof fetch>(async (url) => {
|
|
if (typeof url === 'string' && url === '/api/plugins') {
|
|
return new Response(JSON.stringify({ plugins: [WEB_PROTOTYPE_PLUGIN] }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
throw new Error(`unexpected fetch ${url}`);
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
|
cb(0);
|
|
return 0;
|
|
});
|
|
|
|
render(
|
|
<HomeView
|
|
projects={[]}
|
|
onSubmit={() => undefined}
|
|
onOpenProject={() => undefined}
|
|
onViewAllProjects={() => undefined}
|
|
promptHandoff={createPluginUseHandoff(1, 'example-web-prototype')}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('home-hero-context-plugin-example-web-prototype')).toBeTruthy();
|
|
});
|
|
expect((await screen.findByTestId('home-hero-input') as HTMLTextAreaElement).value)
|
|
.toBe('');
|
|
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/apply'))).toBe(false);
|
|
});
|
|
|
|
it('routes free-form submits through the hidden default plugin without applying a visible chip', async () => {
|
|
const fetchMock = vi.fn<typeof fetch>(async (url) => {
|
|
if (typeof url === 'string' && url === '/api/plugins') {
|
|
return new Response(JSON.stringify({ plugins: [HIDDEN_DEFAULT_PLUGIN, DEFAULT_PLUGIN] }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
throw new Error(`unexpected fetch ${url}`);
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const onSubmit = vi.fn();
|
|
|
|
render(
|
|
<HomeView
|
|
projects={[]}
|
|
onSubmit={onSubmit}
|
|
onOpenProject={() => undefined}
|
|
onViewAllProjects={() => undefined}
|
|
/>,
|
|
);
|
|
|
|
const input = await screen.findByTestId('home-hero-input');
|
|
fireEvent.change(input, { target: { value: 'Make a launch page for a robotics studio' } });
|
|
fireEvent.click(screen.getByTestId('home-hero-submit'));
|
|
|
|
expect(screen.queryByTestId('home-hero-active-plugin')).toBeNull();
|
|
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
|
|
prompt: 'Make a launch page for a robotics studio',
|
|
pluginId: 'od-default',
|
|
appliedPluginSnapshotId: null,
|
|
pluginInputs: { prompt: 'Make a launch page for a robotics studio' },
|
|
projectKind: 'other',
|
|
}));
|
|
});
|
|
|
|
it('falls back to od-new-generation when od-plugin-authoring is not registered yet', async () => {
|
|
const fetchMock = vi.fn<typeof fetch>(async (url) => {
|
|
if (typeof url === 'string' && url === '/api/plugins') {
|
|
return new Response(JSON.stringify({ plugins: [DEFAULT_PLUGIN] }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
if (typeof url === 'string' && url.includes('/apply')) {
|
|
return new Response(JSON.stringify(DEFAULT_APPLY_RESULT), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
throw new Error(`unexpected fetch ${url}`);
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
|
cb(0);
|
|
return 0;
|
|
});
|
|
const onSubmit = vi.fn();
|
|
|
|
render(
|
|
<HomeView
|
|
projects={[]}
|
|
onSubmit={onSubmit}
|
|
onOpenProject={() => undefined}
|
|
onViewAllProjects={() => undefined}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(await screen.findByTestId('home-hero-rail-create-plugin'));
|
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
|
|
'/api/plugins/od-new-generation/apply',
|
|
expect.anything(),
|
|
));
|
|
const applyCall = fetchMock.mock.calls.find(([url]) => (
|
|
typeof url === 'string' && url.includes('/api/plugins/od-new-generation/apply')
|
|
));
|
|
expect(JSON.parse(String((applyCall?.[1] as RequestInit).body))).toMatchObject({
|
|
inputs: {
|
|
artifactKind: 'Open Design plugin',
|
|
audience: 'Open Design plugin authors',
|
|
topic: 'packaging a reusable workflow as an Open Design plugin',
|
|
},
|
|
});
|
|
await waitFor(() => {
|
|
expect((screen.getByTestId('home-hero-input') as HTMLTextAreaElement).value)
|
|
.toBe(PLUGIN_AUTHORING_PROMPT);
|
|
expect((screen.getByTestId('home-hero-submit') as HTMLButtonElement).disabled).toBe(false);
|
|
});
|
|
fireEvent.click(screen.getByTestId('home-hero-submit'));
|
|
|
|
expect(screen.queryByRole('alert')).toBeNull();
|
|
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
|
|
prompt: PLUGIN_AUTHORING_PROMPT,
|
|
pluginId: 'od-new-generation',
|
|
appliedPluginSnapshotId: 'snap-default',
|
|
pluginInputs: {
|
|
artifactKind: 'Open Design plugin',
|
|
audience: 'Open Design plugin authors',
|
|
topic: 'packaging a reusable workflow as an Open Design plugin',
|
|
},
|
|
projectKind: 'other',
|
|
}));
|
|
});
|
|
|
|
it('applies Home rail Prototype chip against the bundled web-prototype scenario plugin', async () => {
|
|
const fetchMock = vi.fn<typeof fetch>(async (url) => {
|
|
if (typeof url === 'string' && url === '/api/plugins') {
|
|
return new Response(JSON.stringify({ plugins: [WEB_PROTOTYPE_PLUGIN] }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
if (typeof url === 'string' && url.includes('/apply')) {
|
|
return new Response(JSON.stringify(WEB_PROTOTYPE_APPLY_RESULT), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
throw new Error(`unexpected fetch ${url}`);
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
|
cb(0);
|
|
return 0;
|
|
});
|
|
|
|
render(
|
|
<HomeView
|
|
projects={[]}
|
|
onSubmit={() => undefined}
|
|
onOpenProject={() => undefined}
|
|
onViewAllProjects={() => undefined}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(await screen.findByTestId('home-hero-rail-prototype'));
|
|
|
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
|
|
'/api/plugins/example-web-prototype/apply',
|
|
expect.anything(),
|
|
));
|
|
const applyCall = fetchMock.mock.calls.find(([url]) => (
|
|
typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply')
|
|
));
|
|
expect(JSON.parse(String((applyCall?.[1] as RequestInit).body))).toMatchObject({
|
|
inputs: {
|
|
artifactKind: 'web prototype',
|
|
fidelity: 'high-fidelity',
|
|
audience: 'product evaluators',
|
|
designSystem: 'the active project design system',
|
|
template: 'the bundled web prototype seed',
|
|
},
|
|
});
|
|
expect(screen.getByTestId('home-hero-prompt-slot-fidelity')).toBeTruthy();
|
|
expect(screen.getByTestId('home-hero-prompt-slot-artifactKind')).toBeTruthy();
|
|
expect(screen.getByTestId('home-hero-prompt-slot-designSystem')).toBeTruthy();
|
|
expect(screen.getByTestId('home-hero-prompt-slot-template')).toBeTruthy();
|
|
// Template-backed inputs are represented inline in the prompt, so
|
|
// the structured form below should not duplicate the same fields.
|
|
expect(screen.queryByTestId('plugin-inputs-form')).toBeNull();
|
|
expect(screen.queryByRole('alert')).toBeNull();
|
|
});
|
|
|
|
it('applies output-type chips immediately when replacing an existing prompt', async () => {
|
|
const fetchMock = vi.fn<typeof fetch>(async (url) => {
|
|
if (typeof url === 'string' && url === '/api/plugins') {
|
|
return new Response(JSON.stringify({ plugins: [WEB_PROTOTYPE_PLUGIN] }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
if (typeof url === 'string' && url.includes('/apply')) {
|
|
return new Response(JSON.stringify(DEFAULT_APPLY_RESULT), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
throw new Error(`unexpected fetch ${url}`);
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
|
cb(0);
|
|
return 0;
|
|
});
|
|
|
|
render(
|
|
<HomeView
|
|
projects={[]}
|
|
onSubmit={() => undefined}
|
|
onOpenProject={() => undefined}
|
|
onViewAllProjects={() => undefined}
|
|
/>,
|
|
);
|
|
|
|
const input = await screen.findByTestId('home-hero-input');
|
|
fireEvent.change(input, { target: { value: 'Keep my current brief' } });
|
|
fireEvent.click(await screen.findByTestId('home-hero-rail-prototype'));
|
|
|
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
|
|
'/api/plugins/example-web-prototype/apply',
|
|
expect.anything(),
|
|
));
|
|
expect(screen.queryByRole('dialog', { name: /replace current prompt/i })).toBeNull();
|
|
});
|
|
|
|
it('appends a plugin-use query handoff without replacing an existing prompt', async () => {
|
|
const fetchMock = vi.fn<typeof fetch>(async (url) => {
|
|
if (typeof url === 'string' && url === '/api/plugins') {
|
|
return new Response(JSON.stringify({ plugins: [WEB_PROTOTYPE_PLUGIN] }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
throw new Error(`unexpected fetch ${url}`);
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
|
cb(0);
|
|
return 0;
|
|
});
|
|
|
|
const { rerender } = render(
|
|
<HomeView
|
|
projects={[]}
|
|
onSubmit={() => undefined}
|
|
onOpenProject={() => undefined}
|
|
onViewAllProjects={() => undefined}
|
|
/>,
|
|
);
|
|
|
|
const input = await screen.findByTestId('home-hero-input');
|
|
fireEvent.change(input, { target: { value: 'Keep my current brief' } });
|
|
|
|
rerender(
|
|
<HomeView
|
|
projects={[]}
|
|
onSubmit={() => undefined}
|
|
onOpenProject={() => undefined}
|
|
onViewAllProjects={() => undefined}
|
|
promptHandoff={createPluginUseHandoff(2, 'example-web-prototype', {
|
|
action: 'use-with-query',
|
|
})}
|
|
/>,
|
|
);
|
|
|
|
const expectedPrompt = [
|
|
'Keep my current brief',
|
|
'',
|
|
'Build a high-fidelity web prototype for product evaluators using the active project design system from the bundled web prototype seed.',
|
|
].join('\n');
|
|
await waitFor(() => {
|
|
expect((input as HTMLTextAreaElement).value).toBe(expectedPrompt);
|
|
expect((input as HTMLTextAreaElement).selectionStart).toBe(expectedPrompt.length);
|
|
expect((input as HTMLTextAreaElement).selectionEnd).toBe(expectedPrompt.length);
|
|
});
|
|
expect(screen.queryByRole('dialog', { name: /replace current prompt/i })).toBeNull();
|
|
expect(screen.getByTestId('home-hero-context-plugin-example-web-prototype')).toBeTruthy();
|
|
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/apply'))).toBe(false);
|
|
});
|
|
|
|
it('binds od-plugin-authoring before submitting the rail create-plugin prompt', async () => {
|
|
const fetchMock = vi.fn<typeof fetch>(async (url) => {
|
|
if (typeof url === 'string' && url === '/api/plugins') {
|
|
return new Response(JSON.stringify({ plugins: [AUTHORING_PLUGIN] }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
if (typeof url === 'string' && url.includes('/apply')) {
|
|
return new Response(JSON.stringify(AUTHORING_APPLY_RESULT), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
throw new Error(`unexpected fetch ${url}`);
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
|
cb(0);
|
|
return 0;
|
|
});
|
|
const onSubmit = vi.fn();
|
|
|
|
render(
|
|
<HomeView
|
|
projects={[]}
|
|
onSubmit={onSubmit}
|
|
onOpenProject={() => undefined}
|
|
onViewAllProjects={() => undefined}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(await screen.findByTestId('home-hero-rail-create-plugin'));
|
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
|
|
'/api/plugins/od-plugin-authoring/apply',
|
|
expect.anything(),
|
|
));
|
|
await waitFor(() => {
|
|
const badge = screen.getByTestId('home-hero-active-plugin');
|
|
expect(badge.textContent).toContain('Create plugin');
|
|
expect(badge.textContent).not.toContain('Plugin authoring');
|
|
});
|
|
fireEvent.click(await screen.findByTestId('home-hero-submit'));
|
|
|
|
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
|
|
prompt: PLUGIN_AUTHORING_PROMPT,
|
|
pluginId: 'od-plugin-authoring',
|
|
appliedPluginSnapshotId: 'snap-authoring',
|
|
pluginInputs: { pluginGoal: PLUGIN_AUTHORING_DEFAULT_GOAL },
|
|
projectKind: 'other',
|
|
}));
|
|
});
|
|
|
|
it('keeps the authoring goal input linked to the prompt and submit payload', async () => {
|
|
const fetchMock = vi.fn<typeof fetch>(async (url) => {
|
|
if (typeof url === 'string' && url === '/api/plugins') {
|
|
return new Response(JSON.stringify({ plugins: [AUTHORING_PLUGIN] }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
if (typeof url === 'string' && url.includes('/apply')) {
|
|
return new Response(JSON.stringify(AUTHORING_APPLY_RESULT), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
throw new Error(`unexpected fetch ${url}`);
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
|
cb(0);
|
|
return 0;
|
|
});
|
|
const onSubmit = vi.fn();
|
|
|
|
render(
|
|
<HomeView
|
|
projects={[]}
|
|
onSubmit={onSubmit}
|
|
onOpenProject={() => undefined}
|
|
onViewAllProjects={() => undefined}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(await screen.findByTestId('home-hero-rail-create-plugin'));
|
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
|
|
'/api/plugins/od-plugin-authoring/apply',
|
|
expect.anything(),
|
|
));
|
|
|
|
const rewrittenGoal = 'catalog internal research notes into a reusable knowledge workflow';
|
|
const input = screen.getByTestId('home-hero-input') as HTMLTextAreaElement;
|
|
fireEvent.change(input, {
|
|
target: {
|
|
value: input.value.replace(
|
|
PLUGIN_AUTHORING_DEFAULT_GOAL,
|
|
rewrittenGoal,
|
|
),
|
|
},
|
|
});
|
|
await waitFor(() => {
|
|
expect(input.value).toContain(rewrittenGoal);
|
|
});
|
|
fireEvent.click(screen.getByTestId('home-hero-submit'));
|
|
|
|
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
|
|
prompt: expect.stringContaining(rewrittenGoal),
|
|
pluginId: 'od-plugin-authoring',
|
|
pluginInputs: {
|
|
pluginGoal: rewrittenGoal,
|
|
},
|
|
})));
|
|
});
|
|
|
|
it('does not submit the create-plugin prompt before the authoring scenario is applied', async () => {
|
|
let resolveApply: (response: Response) => void = () => undefined;
|
|
const applyResponse = new Promise<Response>((resolve) => {
|
|
resolveApply = resolve;
|
|
});
|
|
const fetchMock = vi.fn<typeof fetch>(async (url) => {
|
|
if (typeof url === 'string' && url === '/api/plugins') {
|
|
return new Response(JSON.stringify({ plugins: [AUTHORING_PLUGIN] }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
if (typeof url === 'string' && url.includes('/apply')) {
|
|
return applyResponse;
|
|
}
|
|
throw new Error(`unexpected fetch ${url}`);
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
|
cb(0);
|
|
return 0;
|
|
});
|
|
const onSubmit = vi.fn();
|
|
|
|
render(
|
|
<HomeView
|
|
projects={[]}
|
|
onSubmit={onSubmit}
|
|
onOpenProject={() => undefined}
|
|
onViewAllProjects={() => undefined}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(await screen.findByTestId('home-hero-rail-create-plugin'));
|
|
fireEvent.click(await screen.findByTestId('home-hero-submit'));
|
|
expect(onSubmit).not.toHaveBeenCalled();
|
|
|
|
resolveApply(new Response(JSON.stringify(AUTHORING_APPLY_RESULT), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
}));
|
|
await waitFor(() => {
|
|
expect((screen.getByTestId('home-hero-submit') as HTMLButtonElement).disabled).toBe(false);
|
|
});
|
|
fireEvent.click(screen.getByTestId('home-hero-submit'));
|
|
|
|
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
|
|
pluginId: 'od-plugin-authoring',
|
|
appliedPluginSnapshotId: 'snap-authoring',
|
|
}));
|
|
});
|
|
});
|