Files
open-design/packages/contracts/tests/od-next-device-frame.test.ts
f1a73f0d89 feat(od-next): structure-only layout primitives for the prototype task profile (#7327)
* feat(od-next): ship structure-only layout primitives with the prototype task profile

Blind evals and the pet-grooming runs showed the same layout defects over and
over — stacked spans that never became blocks, chrome height constants reused
as padding, authored copy crammed until it wrapped — while the prose rules that
forbid them sat unchanged in the prompt. The rules are now backed by something
the Build can copy instead of re-derive:

- `assets/task-profiles/prototype/layout.css`: a ~50-line, structure-only
  stylesheet (`@layer od-layout`, `:where()`, `od-` prefix) for stacked
  information, truncation/clamp of data text, media ratio, rails, and an
  optional screen skeleton. Declared as a prototype resource, staged into
  `.od-frames/` under the existing ownership manifest, and quoted into the
  stable request context as the `layout-primitives` fact on every prototype
  run.
- Prototype rule card 2.2.0: a "Variable-length text and stacked information"
  section that separates authored copy (rewritten to a budget, never
  truncated) from data text (truncate/clamp in lists, wrap in full on
  confirmation and detail screens), keyed to the real class names; the quality
  floor's layout bullet now points at the primitives.
- Core 2.1.0: the variable-length-text paragraph rewritten as actions
  (three fates before the text is written) instead of prohibitions.
- Run-finish observation `od_next_layout_primitives`
  (verbatim / modified / linked / absent) so the rollout can tell whether a
  model tier reproduces the block faithfully enough for inlining.

* fix(od-next): let aspect-ratio win on .od-media and pin two rule-card gaps

The first verification runs exposed one primitive bug and two recurring model
slips: `.od-media` lacked `height: auto`, so an `<img height=…>` attribute
kept its pixel height and the ratio never applied (tall cropped thumbnails);
cards built on `<a>` shipped with browser-default link styling; and product
tokens declared on an inner wrapper left sheet/dialog overlays mounted in the
shell's screen without a surface. The stylesheet and the rule card now say so.

* fix(od-next): keep .od-media at its intrinsic ratio; make cropping an explicit opt-in

Review: the fallback aspect-ratio of 1 plus an unconditional object-fit:
cover turned every unset .od-media into a square crop — contradicting the
core image-geometry rule (content-bearing images render their full frame;
cover is reserved for deliberate crops) and the primitive's own comment,
exactly on the media the rule card points at product shots. The primitive
now defaults to aspect-ratio: var(--od-ratio, auto) with no object-fit;
a uniform tile crop sets --od-ratio and adds .od-media-cover together,
and the commerce-card row of the rule card says so.

* fix(od-next): carry finish-time observations on run_finished, not the frozen baseProps

Review: od_next_device_platform*/od_next_layout_primitives were spread into
baseProps, which is built at run creation — before any deliverable exists —
and run_finished only spreads that frozen object. The daemon log printed the
observations while the analytics event never carried them, which defeats the
rollout signal these fields exist for. They now join finishedProperties,
read live from the run after the physical run resolves. (The device-shell
fields shipped with the same trap in the shells PR; fixed here on the same
lines.)

---------

Co-authored-by: NJUHua <113895241+NJUHua@users.noreply.github.com>
2026-08-25 03:58:46 +00:00

185 lines
9.0 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
OD_NEXT_DEVICE_FRAME_ROOT,
OD_NEXT_MANAGED_RESOURCE_FILES,
detectOdNextDevicePlatformFromText,
detectOdNextLayoutPrimitives,
odNextManagedResourceName,
selectOdNextLayoutPrimitivesCss,
hasOdNextDeviceShell,
odNextDeviceFramePath,
resolveOdNextDevicePlatform,
selectOdNextDeviceFrameContextV2,
} from '../src/prompts/od-next-device-frame.js';
// The handheld shell is resolved from the user's own words and the project's
// platform metadata only. These specs pin the vocabulary boundary that the
// rule card's "no shell" row depends on: an explicit platform always wins, a
// platform-less phone app maps to the neutral shell, and a responsive site or
// a company name never counts as a phone.
describe('detectOdNextDevicePlatformFromText', () => {
it.each([
['做一个 iPhone 记账 app', 'ios'],
['Build an iOS onboarding flow in SwiftUI', 'ios'],
['苹果手机上的打卡应用', 'ios'],
['iphone15 上跑的外卖 app', 'ios'],
['做一个安卓端的阅读器', 'android'],
['Material Design 3 settings screen for Android', 'android'],
['鸿蒙系统的天气应用', 'android'],
['Pixel 8 camera app redesign', 'android'],
['宠物美容店线上预约 App,手机端', 'mobile-neutral'],
['做一个移动端的记账应用', 'mobile-neutral'],
['A mobile app for booking grooming appointments', 'mobile-neutral'],
['native app for field technicians', 'mobile-neutral'],
])('classifies %s as %s', (text, expected) => {
expect(detectOdNextDevicePlatformFromText(text)).toBe(expected);
});
it.each([
'做一个响应式官网,移动端也要好看',
'Responsive marketing site with a mobile app download section',
'桌面端的数据看板,移动端自适应',
'苹果公司历史介绍的落地页',
'pixel-perfect landing page clone',
'A SaaS admin dashboard',
'做一个 web app 管理后台',
'',
])('does not classify %s as a phone app', (text) => {
expect(detectOdNextDevicePlatformFromText(text)).toBeNull();
});
it('lets an explicit platform win over a responsive mention', () => {
expect(detectOdNextDevicePlatformFromText('iOS app,同时要有响应式官网')).toBe('ios');
expect(detectOdNextDevicePlatformFromText(null, undefined, 'Android app + desktop site')).toBe('android');
});
});
describe('resolveOdNextDevicePlatform', () => {
it('prefers the user\'s explicit platform over project metadata', () => {
expect(resolveOdNextDevicePlatform({
metadata: { platform: 'auto', platformTargets: ['mobile-android'] },
textPlatform: 'ios',
})).toEqual({ platform: 'ios', resolvedFrom: 'request-text' });
});
it('reads single-platform metadata as that platform and the Mobile chip as neutral', () => {
expect(resolveOdNextDevicePlatform({
metadata: { platform: 'mobile-ios' },
textPlatform: null,
})).toEqual({ platform: 'ios', resolvedFrom: 'project-metadata' });
expect(resolveOdNextDevicePlatform({
metadata: { platformTargets: ['mobile-android'] },
})).toEqual({ platform: 'android', resolvedFrom: 'project-metadata' });
// Home "Mobile app" chip: both targets, no platform named.
expect(resolveOdNextDevicePlatform({
metadata: { platform: 'auto', platformTargets: ['mobile-ios', 'mobile-android'] },
textPlatform: 'mobile-neutral',
})).toEqual({ platform: 'mobile-neutral', resolvedFrom: 'project-metadata' });
});
it('maps a platform-less phone brief to neutral unless the project targets a non-phone surface', () => {
expect(resolveOdNextDevicePlatform({
metadata: { platform: 'auto' },
textPlatform: 'mobile-neutral',
})).toEqual({ platform: 'mobile-neutral', resolvedFrom: 'request-text' });
expect(resolveOdNextDevicePlatform({
metadata: undefined,
textPlatform: 'mobile-neutral',
})).toEqual({ platform: 'mobile-neutral', resolvedFrom: 'request-text' });
for (const platform of ['responsive', 'web-desktop', 'tablet', 'desktop-app']) {
expect(resolveOdNextDevicePlatform({
metadata: { platform },
textPlatform: 'mobile-neutral',
})).toBeNull();
}
});
it('resolves nothing for a web prototype with no phone signal', () => {
expect(resolveOdNextDevicePlatform({ metadata: { kind: 'prototype' } as never })).toBeNull();
expect(resolveOdNextDevicePlatform({ metadata: { platform: 'responsive' }, textPlatform: null })).toBeNull();
});
});
describe('selectOdNextDeviceFrameContextV2', () => {
const resources = [
{ path: './assets/task-profiles/prototype/device-frames/iphone.html', text: '<div data-phone-shell data-platform="iphone"></div>' },
{ path: './assets/task-profiles/prototype/device-frames/android.html', text: '<div data-phone-shell data-platform="android"></div>' },
{ path: './assets/task-profiles/prototype/device-frames/neutral.html', text: '<div data-phone-shell data-platform="neutral"></div>' },
{ path: './assets/task-profiles/prototype/other.md', text: 'not a shell' },
];
it('quotes the selected shell and lists every staged shell by project-relative path', () => {
const context = selectOdNextDeviceFrameContextV2({
resolution: { platform: 'android', resolvedFrom: 'request-text' },
taskResources: resources,
});
expect(context).toEqual({
platform: 'android',
resolvedFrom: 'request-text',
shell: `${OD_NEXT_DEVICE_FRAME_ROOT}/android.html`,
availableShells: [
`${OD_NEXT_DEVICE_FRAME_ROOT}/android.html`,
`${OD_NEXT_DEVICE_FRAME_ROOT}/iphone.html`,
`${OD_NEXT_DEVICE_FRAME_ROOT}/neutral.html`,
],
shellHtml: '<div data-phone-shell data-platform="android"></div>',
});
expect(odNextDeviceFramePath('ios')).toBe('.od-frames/iphone.html');
});
it('omits the fact when nothing was resolved or the package ships no shell for the platform', () => {
expect(selectOdNextDeviceFrameContextV2({ resolution: null, taskResources: resources })).toBeNull();
expect(selectOdNextDeviceFrameContextV2({
resolution: { platform: 'ios', resolvedFrom: 'request-text' },
taskResources: resources.filter((resource) => !resource.path.endsWith('iphone.html')),
})).toBeNull();
expect(selectOdNextDeviceFrameContextV2({
resolution: { platform: 'ios', resolvedFrom: 'request-text' },
taskResources: undefined,
})).toBeNull();
});
});
describe('hasOdNextDeviceShell', () => {
it('requires both the handset marker and the content slot', () => {
expect(hasOdNextDeviceShell(
'<div class="phone-frame" data-phone-shell data-platform="iphone"><main class="phone-content"></main></div>',
)).toBe(true);
expect(hasOdNextDeviceShell('<div class="phone-frame" data-phone-shell="true"><div class="x phone-content y"></div></div>')).toBe(true);
expect(hasOdNextDeviceShell('<div class="phone-frame" data-phone-shell></div>')).toBe(false);
expect(hasOdNextDeviceShell('<main class="phone-content"></main>')).toBe(false);
expect(hasOdNextDeviceShell('<div class="card" style="border-radius:24px"></div>')).toBe(false);
expect(hasOdNextDeviceShell('')).toBe(false);
expect(hasOdNextDeviceShell(null)).toBe(false);
});
});
describe('layout primitives resource', () => {
const css = '/* OD-LAYOUT-PRIMITIVES v1 — structure only */\n@layer od-layout {\n .od-stack { display: flex; }\n}\n/* /OD-LAYOUT-PRIMITIVES v1 */\n';
const resources = [
{ path: './assets/task-profiles/prototype/device-frames/iphone.html', text: '<div data-phone-shell></div>' },
{ path: './assets/task-profiles/prototype/layout.css', text: css },
{ path: './assets/task-profiles/prototype/notes.md', text: 'not managed' },
];
it('names the managed files and selects the stylesheet out of the resources', () => {
expect(OD_NEXT_MANAGED_RESOURCE_FILES).toEqual(['iphone.html', 'android.html', 'neutral.html', 'layout.css']);
expect(odNextManagedResourceName('./x/device-frames/android.html')).toBe('android.html');
expect(odNextManagedResourceName('./x/layout.css')).toBe('layout.css');
expect(odNextManagedResourceName('./x/notes.md')).toBeNull();
expect(selectOdNextLayoutPrimitivesCss(resources)).toBe(css);
expect(selectOdNextLayoutPrimitivesCss(resources.slice(0, 1))).toBeNull();
expect(selectOdNextLayoutPrimitivesCss(undefined)).toBeNull();
});
it('classifies how a document carries the primitives', () => {
expect(detectOdNextLayoutPrimitives(`<style>${css}</style>`, css)).toBe('verbatim');
expect(detectOdNextLayoutPrimitives(`<style>\n ${css.replace(/\n/g, '\n ')}</style>`, css)).toBe('verbatim');
expect(detectOdNextLayoutPrimitives('<style>/* OD-LAYOUT-PRIMITIVES v1 */ .od-stack{display:grid} /* /OD-LAYOUT-PRIMITIVES v1 */</style>', css)).toBe('modified');
expect(detectOdNextLayoutPrimitives('<link rel="stylesheet" href=".od-frames/layout.css">', css)).toBe('linked');
expect(detectOdNextLayoutPrimitives('<div class="od-stack"></div>', css)).toBe('absent');
expect(detectOdNextLayoutPrimitives('', css)).toBe('absent');
});
});