Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions dist/assets/index-BeVS7Wr2.css

Large diffs are not rendered by default.

328 changes: 328 additions & 0 deletions dist/assets/index-Czz-ZZvO.js

Large diffs are not rendered by default.

328 changes: 0 additions & 328 deletions dist/assets/index-D2LZqiXQ.js

This file was deleted.

1 change: 0 additions & 1 deletion dist/assets/index-DbG5mQ4q.css

This file was deleted.

55 changes: 8 additions & 47 deletions dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2857,55 +2857,18 @@
color: transparent;
}
.composer .ghost .typed { color: transparent; }
.composer .ghost .suggest {
color: oklch(80% 0.012 260);
opacity: 0;
transition: opacity var(--t-fast) var(--ease);
}
.composer.has-suggest .ghost .suggest { opacity: 1; }
/* Demo caret (prototype): a real text cursor — hard blink on a 1.05s cadence
with a soft brand afterglow. Shown while a chip fills the prompt. */
with a soft brand afterglow. Shown while the hero demo or a chip writes
the prompt; held solid while characters land (is-demo-keying), so it
only blinks while the field is idle, like a real cursor. */
@keyframes caret-blink { 0%,45% { opacity:1; } 46%,100% { opacity:0; } }
.composer.is-demo-typing .ghost .typed::after {
content:""; display:inline-block; width:2px; height:1.05em; margin-right:-2px; vertical-align:-.15em;
background:var(--accent); border-radius:1px;
box-shadow:0 0 7px 1px oklch(from var(--accent) l c h / .5);
animation:caret-blink 1.05s step-end infinite;
}
.composer .tab-hint {
position: absolute;
right: 64px;
bottom: 22px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 6px;
border: 0;
background: transparent;
border-radius: 6px;
cursor: pointer;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.02em;
color: var(--muted);
opacity: 0;
transform: translateY(2px);
transition: opacity var(--t-fast) var(--ease), transform var(--t-fast) var(--ease);
pointer-events: none;
}
.composer .tab-hint kbd {
font: inherit;
padding: 1px 6px;
border: 1px solid var(--border);
border-bottom-width: 2px;
border-radius: 5px;
background: var(--surface);
color: var(--fg);
}
.composer.has-suggest .tab-hint { opacity: 1; transform: none; pointer-events: auto; }
.composer .tab-hint[hidden] { display: none; }
.composer .tab-hint:hover { color: var(--fg); background: oklch(96% 0.01 260); }
.composer .tab-hint:hover kbd { border-color: oklch(80% 0.02 260); }
.composer.is-demo-keying .ghost .typed::after { animation: none; }
.composer:focus-within {
border-color: var(--accent);
box-shadow:
Expand Down Expand Up @@ -3310,8 +3273,8 @@
.send.is-busy .send-spin { animation: none; opacity: 0.6; }
}
</style>
<script type="module" crossorigin src="/assets/index-D2LZqiXQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DbG5mQ4q.css">
<script type="module" crossorigin src="/assets/index-Czz-ZZvO.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BeVS7Wr2.css">
</head>
<body>
<div class="shell-frame"><div class="shell-stage" id="shell-stage">
Expand Down Expand Up @@ -3350,10 +3313,8 @@ <h1 class="headline reveal">FlutterFlow custom code.<br><span class="headline-su

<div class="composer" id="composer">
<label class="sr-only" for="pipeline-input">Describe the widget or action you need</label>
<div class="ghost" aria-hidden="true"><span class="typed"></span><span class="suggest"></span></div>
<textarea id="pipeline-input" class="field" rows="3" spellcheck="false" aria-describedby="suggest-status" placeholder="Describe the widget or action you need" data-demo-prompt="A circular progress gauge with a gradient stroke"></textarea>
<span id="suggest-status" class="sr-only" aria-live="polite"></span>
<button class="tab-hint" type="button" id="tab-hint" tabindex="-1" hidden=""><kbd aria-hidden="true">Tab</kbd><span class="tab-hint-label">to accept</span></button>
<div class="ghost" aria-hidden="true"><span class="typed"></span></div>
<textarea id="pipeline-input" class="field" rows="3" spellcheck="false" placeholder="Describe the widget or action you need" data-demo-prompt="A circular progress gauge with a gradient stroke"></textarea>
<div class="tools">
<div id="prompt-image-upload" class="prompt-image-upload">
<button class="icon-btn" type="button" id="btn-add-images" aria-label="Attach reference image" onclick="document.getElementById('prompt-image-input').click()">
Expand Down
183 changes: 85 additions & 98 deletions e2e/stu376-composer.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,15 @@ import {
} from "./fixtures/apiFixtures.js";

/**
* STU-376: the wired prompt composer — ghost suggestions from the 13 local
* prototype patterns, Tab/Escape state machine, chip fill, whitespace and
* duplicate-submission guards, and the existing attachment/settings paths.
* STU-376: the wired prompt composer — no autocomplete (out until its own
* milestone), chip fill, whitespace and duplicate-submission guards, and the
* existing attachment/settings paths.
* Every external endpoint is intercepted with deterministic fixtures, so a
* stray completion-provider request fails the console assertion loudly.
*/

const COMPOSER_DEFAULT_PROMPT = "A circular progress gauge with a gradient stroke";
const COMPOSER_PLACEHOLDER = "Describe the widget or action you need";
const GAUGE_COMPLETION = " and an animated percentage label";
// Catching a demo window races the demo's real-time clock: the hold alone is
// ~5s, and page init on a loaded worker adds seconds before the first poll.
// The catch budget must cover both, not just the window being observed.
Expand Down Expand Up @@ -99,105 +98,58 @@ for (const { name, width, height } of viewports) {
await page.setViewportSize({ width, height });
});

test("rests empty on its hint without an active suggestion", async ({ page }) => {
test("rests empty on its hint", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/");

const field = page.locator("#pipeline-input");
// Reduced motion skips the hero demo and lands its end state directly:
// the empty field on its hint, nothing to submit, nothing suggested.
// the empty field on its hint, nothing to submit.
await expect(page.locator("#composer")).not.toHaveClass(/is-demo-typing/, {
timeout: 15000,
});
await expect(field).toHaveValue("");
await expect(field).toHaveAttribute("placeholder", COMPOSER_PLACEHOLDER);
await expect(page.locator(".ghost .suggest")).toHaveText("");
await expect(page.locator("#tab-hint")).toBeHidden();
await expect(page.locator("#composer")).not.toHaveClass(/has-suggest/);
await expect(page.locator("#hero-send")).toBeDisabled();
});

test("typing surfaces the local suggestion with the tab hint and live announcement", async ({ page }) => {
test("typing makes no suggestion", async ({ page }) => {
const requestedUrls = [];
page.on("request", (request) => requestedUrls.push(request.url()));

await page.goto("/");

await page.locator("#pipeline-input").fill(COMPOSER_DEFAULT_PROMPT);

const composer = page.locator("#composer");
await expect(composer).toHaveClass(/has-suggest/);
await expect(page.locator(".ghost .suggest")).toHaveText(GAUGE_COMPLETION);
// This prompt used to trigger a ghost completion. Autocomplete is out
// until its own milestone: no ghost text, Tab hint or announcement
// exists, and waiting past the old 220ms debounce brings none.
const field = page.locator("#pipeline-input");
await field.fill(COMPOSER_DEFAULT_PROMPT);
await page.waitForTimeout(400);
await expect(field).toHaveValue(COMPOSER_DEFAULT_PROMPT);
await expect(page.locator(".ghost .typed")).toHaveText(COMPOSER_DEFAULT_PROMPT);
await expect(page.locator("#tab-hint")).toBeVisible();
await expect(page.locator("#suggest-status")).toContainText(
"Suggestion: " + GAUGE_COMPLETION + ". Press Tab to accept.",
);
await expect(page.locator("#tab-hint")).toHaveAttribute(
"aria-label",
"Accept suggestion: and an animated percentage label",
);
await expect(page.locator(".ghost .suggest")).toHaveCount(0);
await expect(page.locator("#tab-hint")).toHaveCount(0);
await expect(page.locator("#suggest-status")).toHaveCount(0);
await expect(field).not.toHaveAttribute("aria-describedby", /.+/);
await expect(page.locator("#composer")).not.toHaveClass(/has-suggest/);

// Suggestions are purely local: no completion provider may be contacted.
// No completion provider is contacted either.
const completionRequests = requestedUrls.filter(
(url) => /groq|\/chat\/completions/i.test(url),
);
expect(completionRequests).toEqual([]);
});

test("Tab accepts the active suggestion", async ({ page }) => {
test("Tab moves focus to the next control and never changes the prompt", async ({ page }) => {
await page.goto("/");

const field = page.locator("#pipeline-input");
await field.fill(COMPOSER_DEFAULT_PROMPT);
await expect(page.locator("#composer")).toHaveClass(/has-suggest/);

await page.keyboard.press("Tab");

await expect(field).toHaveValue(COMPOSER_DEFAULT_PROMPT + GAUGE_COMPLETION);
await expect(page.locator(".ghost .suggest")).toHaveText("");
await expect(page.locator("#composer")).not.toHaveClass(/has-suggest/);
await expect(page.locator("#tab-hint")).toBeHidden();
await expect(page.locator("#suggest-status")).toHaveText("");
await expect(page.locator("#hero-send")).toBeEnabled();

// Acceptance must not re-trigger a suggestion on the accepted text.
await page.waitForTimeout(400);
await expect(page.locator(".ghost .suggest")).toHaveText("");
});

test("Escape dismisses the suggestion without changing the prompt", async ({ page }) => {
await page.goto("/");

const field = page.locator("#pipeline-input");
await field.fill(COMPOSER_DEFAULT_PROMPT);
await expect(page.locator("#composer")).toHaveClass(/has-suggest/);

await page.keyboard.press("Escape");

await expect(field).toHaveValue(COMPOSER_DEFAULT_PROMPT);
await expect(page.locator(".ghost .suggest")).toHaveText("");
await expect(page.locator("#composer")).not.toHaveClass(/has-suggest/);
await expect(page.locator("#tab-hint")).toBeHidden();
await expect(page.locator("#suggest-status")).toHaveText("");

// With the suggestion dismissed, Tab falls through and moves focus.
await page.keyboard.press("Tab");
await expect(field).not.toBeFocused();
});

test("Tab without an active suggestion moves focus to the next control", async ({ page }) => {
await page.goto("/");

const field = page.locator("#pipeline-input");
await field.fill("Generate some code for me please");
// Wait out the suggestion debounce so a late match cannot sneak in.
await page.waitForTimeout(400);
await expect(page.locator(".ghost .suggest")).toHaveText("");
await expect(page.locator("#composer")).not.toHaveClass(/has-suggest/);

await page.keyboard.press("Tab");

await expect(field).toHaveValue(COMPOSER_DEFAULT_PROMPT);
await expect(field).not.toBeFocused();
await expect(
page.locator('.tools [aria-label="Attach reference image"]'),
Expand Down Expand Up @@ -361,7 +313,7 @@ test.describe("submission wiring at desktop viewport", () => {
test.describe("touch controls at mobile viewport", () => {
test.use({ hasTouch: true, viewport: { width: 360, height: 800 } });

test("tapping a chip, the accept hint and send all work by touch", async ({ page }) => {
test("tapping a chip, typing and send all work by touch", async ({ page }) => {
const counts = await routeCountingPipeline(page);
await page.goto("/");

Expand All @@ -371,13 +323,12 @@ test.describe("touch controls at mobile viewport", () => {
await page.locator("#example-chips .chip", { hasText: "Signature pad" }).tap();
await expect(field).toHaveValue("A signature pad that exports a transparent PNG");

// Typing after a touch focus surfaces the suggestion; tapping the hint accepts it.
// Typing after a touch focus edits the prompt as typed — nothing is
// suggested or appended.
await field.tap();
await field.fill("");
await page.keyboard.type("A signature pad");
await expect(page.locator("#composer")).toHaveClass(/has-suggest/);
await page.locator("#tab-hint").tap();
await expect(field).toHaveValue("A signature pad that exports a transparent PNG");
await expect(field).toHaveValue("A signature pad");

// The touch targets meet the 44px minimum from the responsive contract.
const sendBox = await page.locator("#hero-send").boundingBox();
Expand Down Expand Up @@ -411,10 +362,8 @@ test.describe("STU-445 hero composer behaviour", () => {
// before the first sample.
await page.goto("/", { waitUntil: "commit" });

const run = await page.evaluate(async (expected) => {
const seen = [];
let caretAtFull = "";
let suggested = false;
const seen = await page.evaluate(async () => {
const samples = [];
let started = false;
// Canonical HTML: 5200ms hold and 73ms per character, then a 1600ms
// rest and a 35ms-per-character erase.
Expand All @@ -423,50 +372,61 @@ test.describe("STU-445 hero composer behaviour", () => {
const composer = document.getElementById("composer");
const typed = document.querySelector(".ghost .typed");
const typing = composer?.classList.contains("is-demo-typing") ?? false;
if (composer?.classList.contains("has-suggest")) suggested = true;
if (typed && typing) {
started = true;
const text = typed.textContent || "";
if (seen[seen.length - 1] !== text) seen.push(text);
if (text === expected && !caretAtFull) {
caretAtFull = getComputedStyle(typed, "::after").animationName;
// Record each text the first time it shows, with the caret's live
// computed state at that moment.
if (samples[samples.length - 1]?.text !== text) {
const caret = getComputedStyle(typed, "::after");
samples.push({ text, animation: caret.animationName, opacity: caret.opacity });
}
} else if (started) {
break; // the caret class retired: the demo is over
}
await new Promise((resolve) => setTimeout(resolve, 15));
}
return { seen, caretAtFull, suggested };
}, COMPOSER_DEFAULT_PROMPT);
return samples;
});

// The visible text CHANGES over time, both ways: strictly longer prefixes
// up to the full example — real typing — then strictly shorter ones back
// down — real backspacing, not a field that is cleared in one step.
const { seen } = run;
const peak = seen.indexOf(COMPOSER_DEFAULT_PROMPT);
const texts = seen.map((sample) => sample.text);
const peak = texts.indexOf(COMPOSER_DEFAULT_PROMPT);
expect(peak).toBeGreaterThan(1);
for (let index = 1; index <= peak; index += 1) {
expect(seen[index].length).toBeGreaterThan(seen[index - 1].length);
expect(seen[index].startsWith(seen[index - 1])).toBe(true);
expect(texts[index].length).toBeGreaterThan(texts[index - 1].length);
expect(texts[index].startsWith(texts[index - 1])).toBe(true);
}
expect(seen.length - peak).toBeGreaterThan(2);
for (let index = peak + 1; index < seen.length; index += 1) {
expect(seen[index].length).toBeLessThan(seen[index - 1].length);
expect(seen[index - 1].startsWith(seen[index])).toBe(true);
expect(texts.length - peak).toBeGreaterThan(2);
for (let index = peak + 1; index < texts.length; index += 1) {
expect(texts[index].length).toBeLessThan(texts[index - 1].length);
expect(texts[index - 1].startsWith(texts[index])).toBe(true);
}

// The orange caret behaves like a real cursor: it blinks while the field
// is idle — the empty hold and the rest on the full example — and holds
// solid, fully visible, on every character typed or erased.
expect(texts[0]).toBe("");
expect(seen[0].animation).toBe("caret-blink");
expect(seen[peak].animation).toBe("caret-blink");
const keyed = seen.filter((sample, index) => index !== peak && sample.text !== "");
expect(keyed.length).toBeGreaterThan(10);
for (const sample of keyed) {
expect(sample, `caret while "${sample.text}" shows`).toMatchObject({
animation: "none",
opacity: "1",
});
}
// The orange caret blinks while the full example rests before the erase.
expect(run.caretAtFull).toBe("caret-blink");
// The demo makes no suggestion at any point.
expect(run.suggested).toBe(false);

// The demo retires cleanly: caret gone, field empty on its grey hint,
// nothing to submit.
const field = page.locator("#pipeline-input");
await expect(page.locator("#composer")).not.toHaveClass(/is-demo-typing/);
await expect(page.locator("#composer")).not.toHaveClass(/is-demo-keying/);
await expect(field).toHaveValue("");
await expect(field).toHaveAttribute("placeholder", COMPOSER_PLACEHOLDER);
await expect(page.locator(".ghost .suggest")).toHaveText("");
await expect(page.locator("#tab-hint")).toBeHidden();
await expect(page.locator("#hero-send")).toBeDisabled();
const caretAfter = await page.evaluate(
() =>
Expand All @@ -476,6 +436,33 @@ test.describe("STU-445 hero composer behaviour", () => {
expect(caretAfter).toBe("none");
});

test("a chip types its prompt behind a solid caret", async ({ page }) => {
await page.goto("/");
const chip = page.locator("#example-chips .chip", { hasText: "Signature pad" });
const prompt = await chip.getAttribute("data-prompt");
await chip.click();

// Characters land the whole time a chip fills, so the caret never blinks
// off mid-fill: every sample sees it solid and fully visible.
const samples = await page.evaluate(async () => {
const seen = [];
const composer = document.getElementById("composer");
const typed = document.querySelector(".ghost .typed");
while (composer.classList.contains("is-demo-typing")) {
const caret = getComputedStyle(typed, "::after");
seen.push({ animation: caret.animationName, opacity: caret.opacity });
await new Promise((resolve) => setTimeout(resolve, 10));
}
return seen;
});
expect(samples.length).toBeGreaterThan(3);
for (const sample of samples) {
expect(sample).toEqual({ animation: "none", opacity: "1" });
}
await expect(page.locator("#pipeline-input")).toHaveValue(prompt);
await expect(page.locator("#composer")).not.toHaveClass(/is-demo-keying/);
});

test("a mid-demo click lands the empty field — the example never becomes the prompt", async ({
page,
}) => {
Expand Down
4 changes: 3 additions & 1 deletion e2e/stu389-hero-mark-field.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,9 @@ test.describe("frame measurements before and after disabling the effect", () =>

// A genuine frame metric: count requestAnimationFrame callbacks the field
// drives over a fixed window. A module that never renders yields 0 here.
const framesOn = await countRafFrames(page, 500);
// The window is long enough that a loaded machine's dropped frames can't
// push a field that is running under the bar.
const framesOn = await countRafFrames(page, 1500);
expect(framesOn, "with the effect enabled the field should animate, not sit idle").toBeGreaterThan(5);

await page.emulateMedia({ reducedMotion: "reduce" });
Expand Down
Loading
Loading