mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* 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>
185 lines
9.0 KiB
TypeScript
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');
|
|
});
|
|
});
|