@ggui-ai/ui-gen 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/generation-dispatch.d.ts +6 -3
- package/dist/adapters/generation-dispatch.js +4944 -4239
- package/dist/adapters/generation-dispatch.js.map +1 -1
- package/dist/adapters/index.js +8 -5
- package/dist/adapters/index.js.map +1 -1
- package/dist/advanced/index.d.ts +12 -8
- package/dist/advanced/index.js +4462 -3726
- package/dist/advanced/index.js.map +1 -1
- package/dist/blueprint-validator.js +8 -12
- package/dist/blueprint-validator.js.map +1 -1
- package/dist/boilerplate.d.ts +5 -2
- package/dist/boilerplate.js +95 -12
- package/dist/boilerplate.js.map +1 -1
- package/dist/check/index.d.ts +2 -2
- package/dist/check/index.js +2 -0
- package/dist/check/index.js.map +1 -1
- package/dist/compose.js +1 -1
- package/dist/compose.js.map +1 -1
- package/dist/{create-ui-generator-CCIeQaEe.d.ts → create-ui-generator-C8Ki9Wz1.d.ts} +18 -2
- package/dist/{design-mode-BHEE7flj.d.ts → design-mode-DLufhrNp.d.ts} +0 -6
- package/dist/{dispatch-BA5dJcg-.d.ts → dispatch-D2htzi9h.d.ts} +2 -2
- package/dist/evaluation/axis-checks/index.d.ts +4 -4
- package/dist/evaluation/axis-checks/index.js +297 -13
- package/dist/evaluation/axis-checks/index.js.map +1 -1
- package/dist/evaluation/axis-checks/registry.d.ts +2 -2
- package/dist/evaluation/axis-checks/registry.js +252 -1
- package/dist/evaluation/axis-checks/registry.js.map +1 -1
- package/dist/evaluation/index.d.ts +160 -8
- package/dist/evaluation/index.js +702 -81
- package/dist/evaluation/index.js.map +1 -1
- package/dist/fragments/index.js +1 -1
- package/dist/fragments/index.js.map +1 -1
- package/dist/{generate-8L_bc2iP.d.ts → generate-9IeNT4ZY.d.ts} +1 -1
- package/dist/harness/check/runtime-render/index.d.ts +3 -2
- package/dist/harness/check/runtime-render/index.js +8 -12
- package/dist/harness/check/runtime-render/index.js.map +1 -1
- package/dist/harness/index.d.ts +3 -2
- package/dist/harness/index.js +385 -254
- package/dist/harness/index.js.map +1 -1
- package/dist/harness/prompts.d.ts +2 -2
- package/dist/harness/prompts.js +3 -3
- package/dist/harness/prompts.js.map +1 -1
- package/dist/harness/result-types.d.ts +3 -3
- package/dist/harness/runtime.d.ts +13 -4
- package/dist/harness/runtime.js +106 -19
- package/dist/harness/runtime.js.map +1 -1
- package/dist/harness/types-public.d.ts +13 -2
- package/dist/harness/types-public.js +385 -254
- package/dist/harness/types-public.js.map +1 -1
- package/dist/harness/validator-trace-sink.d.ts +2 -2
- package/dist/index.d.ts +29 -7
- package/dist/index.js +4465 -3727
- package/dist/index.js.map +1 -1
- package/dist/patch.d.ts +3 -2
- package/dist/{prompts-k6m6reqe.d.ts → prompts-YU9piOgs.d.ts} +12 -46
- package/dist/styling-profile-CxfvV48g.d.ts +35 -0
- package/dist/tools/get-primitives-ts.d.ts +1 -1
- package/dist/tools/get-primitives-ts.js +1 -1
- package/dist/tools/get-primitives-ts.js.map +1 -1
- package/dist/{types-public-D24JLJgl.d.ts → types-public-DVSssdc-.d.ts} +39 -3
- package/dist/validation/index.d.ts +1 -1
- package/dist/validation/index.js +1 -1
- package/dist/validation/index.js.map +1 -1
- package/dist/workflows.d.ts +3 -2
- package/package.json +12 -12
- package/src/adapters/generation-dispatch.ts +6 -1
- package/src/boilerplate/free-design-prompt.ts +7 -0
- package/src/boilerplate/hard-sections.ts +16 -1
- package/src/boilerplate/styling-profile.ts +166 -0
- package/src/boilerplate/system-prompt.ts +20 -8
- package/src/check/run-tier0.ts +2 -0
- package/src/contract-context.ts +15 -14
- package/src/create-harness.ts +4 -0
- package/src/create-ui-generator.ts +41 -2
- package/src/design-mode.ts +11 -1
- package/src/design-system-docs.ts +7 -4
- package/src/effort.ts +48 -0
- package/src/evaluation/axis-checks/checks/grid.ts +148 -0
- package/src/evaluation/axis-checks/checks/universal.ts +244 -0
- package/src/evaluation/axis-checks/dispatch.ts +78 -13
- package/src/evaluation/axis-checks/helpers.ts +12 -0
- package/src/evaluation/axis-checks/registry.ts +2 -0
- package/src/evaluation/design-identity.ts +96 -0
- package/src/evaluation/index.ts +17 -0
- package/src/evaluation/llm-evaluator.ts +13 -1
- package/src/evaluation/theme-css.ts +14 -0
- package/src/evaluation/types-public.ts +35 -1
- package/src/evaluation/visual-evaluator.ts +478 -45
- package/src/fragments/render.ts +1 -1
- package/src/harness/check/runtime-render/prepare-mockup.ts +18 -11
- package/src/harness/coding/init-session.ts +1 -0
- package/src/harness/coding/run-coding-turn.ts +6 -14
- package/src/harness/coding/run-eval-round.ts +31 -3
- package/src/harness/runtime.ts +10 -0
- package/src/harness/types-public.ts +11 -0
- package/src/index.ts +2 -0
- package/src/pin-fixtures.ts +98 -0
- package/src/run-check.ts +10 -10
- package/src/tools/get-primitives-ts.ts +1 -1
- package/src/tools.ts +2 -1
- package/src/validation/primitives.ts +1 -1
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
* Callers who want a lightweight single-shot path can build it
|
|
32
32
|
* themselves.
|
|
33
33
|
*/
|
|
34
|
+
import type { SingleComponentParams } from './harness/runtime.js';
|
|
34
35
|
import type {
|
|
35
36
|
GadgetDescriptor,
|
|
36
37
|
GenerationError,
|
|
@@ -55,6 +56,8 @@ import {
|
|
|
55
56
|
} from '@ggui-ai/mcp-server-core';
|
|
56
57
|
import { createGeneratorTools } from './adapters/index.js';
|
|
57
58
|
import { dispatchGeneration } from './adapters/generation-dispatch.js';
|
|
59
|
+
import { hasProfile } from './boilerplate/styling-profile.js';
|
|
60
|
+
import { effortDials } from './effort.js';
|
|
58
61
|
import type { ProviderName } from './adapters/types.js';
|
|
59
62
|
import type { GenerationResult } from './harness/result-types.js';
|
|
60
63
|
import {
|
|
@@ -67,6 +70,7 @@ import type { RenderingContext } from './contract-context.js';
|
|
|
67
70
|
import type { DesignMode } from './design-mode.js';
|
|
68
71
|
import { resolveRoute, applyRouteToEnv } from './adapters/provider-router.js';
|
|
69
72
|
import type { QualityConfig } from './evaluation/types-public.js';
|
|
73
|
+
import type { EvaluationConfig } from './evaluation/types.js';
|
|
70
74
|
import type { AgentConfig } from './harness/llm-router.js';
|
|
71
75
|
|
|
72
76
|
/** The slug for the OSS default seed generator. */
|
|
@@ -89,6 +93,20 @@ export interface CreateUiGeneratorOptions {
|
|
|
89
93
|
readonly maxAttempts?: number;
|
|
90
94
|
/** Maximum evaluation rounds. */
|
|
91
95
|
readonly maxEvalRounds?: number;
|
|
96
|
+
/**
|
|
97
|
+
* In-loop LLM evaluation config (ggui#1001). When set, every generation
|
|
98
|
+
* runs the evaluator tiers after the coding leg (`maxRounds` defaults to
|
|
99
|
+
* `maxEvalRounds`) and the harness result carries `evalResult` — the
|
|
100
|
+
* probe verdict a downstream judge reads. Default: none, as today —
|
|
101
|
+
* a serving deployment leaves it unset; offline minting jobs set a round.
|
|
102
|
+
*/
|
|
103
|
+
readonly evaluation?: EvaluationConfig;
|
|
104
|
+
/**
|
|
105
|
+
* In-loop visual round config (per-canvas judging, a sample, the
|
|
106
|
+
* stylesheet to render under). Forwarded verbatim; never part of the
|
|
107
|
+
* prompt or the identity. Default: none — the round runs as today.
|
|
108
|
+
*/
|
|
109
|
+
readonly visualEvaluation?: SingleComponentParams['visualEvaluation'];
|
|
92
110
|
/** Quality config controlling eval tiers + improvement behavior. */
|
|
93
111
|
readonly qualityConfig?: QualityConfig;
|
|
94
112
|
/**
|
|
@@ -208,6 +226,8 @@ export function createUiGenerator(
|
|
|
208
226
|
enableRuntimeRender = false,
|
|
209
227
|
maxAttempts,
|
|
210
228
|
maxEvalRounds,
|
|
229
|
+
evaluation,
|
|
230
|
+
visualEvaluation,
|
|
211
231
|
qualityConfig,
|
|
212
232
|
gadgetCatalog,
|
|
213
233
|
disableEnvMutation = false,
|
|
@@ -313,6 +333,7 @@ export function createUiGenerator(
|
|
|
313
333
|
const promptWithVariance = injectVariance(
|
|
314
334
|
promptWithRendering,
|
|
315
335
|
input.variance,
|
|
336
|
+
{ profileDeclared: hasProfile(input.profile) },
|
|
316
337
|
);
|
|
317
338
|
|
|
318
339
|
// Resolve appGadgets by precedence:
|
|
@@ -335,6 +356,19 @@ export function createUiGenerator(
|
|
|
335
356
|
resolvedAppGadgets,
|
|
336
357
|
);
|
|
337
358
|
|
|
359
|
+
// `effort` (ggui#1059): a named level's dials override the
|
|
360
|
+
// deployment's options for THIS generation; absent ⇒ untouched.
|
|
361
|
+
// Prompt bytes never move with it (dials only).
|
|
362
|
+
const dials = effortDials(input.profile?.effort);
|
|
363
|
+
const effectiveMaxAttempts = dials?.maxAttempts ?? maxAttempts;
|
|
364
|
+
const effectiveMaxEvalRounds = dials?.maxEvalRounds ?? maxEvalRounds;
|
|
365
|
+
const effectiveEvaluation =
|
|
366
|
+
dials !== undefined && evaluation !== undefined ? { ...evaluation, maxRounds: dials.maxEvalRounds } : evaluation;
|
|
367
|
+
const effectiveVisualEvaluation =
|
|
368
|
+
dials !== undefined && visualEvaluation !== undefined
|
|
369
|
+
? { ...visualEvaluation, passThreshold: dials.selfEvalPassThreshold }
|
|
370
|
+
: visualEvaluation;
|
|
371
|
+
|
|
338
372
|
const result = await dispatchGeneration({
|
|
339
373
|
provider,
|
|
340
374
|
userPrompt,
|
|
@@ -342,8 +376,10 @@ export function createUiGenerator(
|
|
|
342
376
|
tools,
|
|
343
377
|
...(input.contract ? { contract: input.contract } : {}),
|
|
344
378
|
originalPrompt: input.request.prompt,
|
|
345
|
-
...(
|
|
346
|
-
...(
|
|
379
|
+
...(effectiveMaxAttempts !== undefined ? { maxAttempts: effectiveMaxAttempts } : {}),
|
|
380
|
+
...(effectiveMaxEvalRounds !== undefined ? { maxEvalRounds: effectiveMaxEvalRounds } : {}),
|
|
381
|
+
...(effectiveEvaluation !== undefined ? { evaluation: effectiveEvaluation } : {}),
|
|
382
|
+
...(effectiveVisualEvaluation !== undefined ? { visualEvaluation: effectiveVisualEvaluation } : {}),
|
|
347
383
|
...(qualityConfig !== undefined ? { qualityConfig } : {}),
|
|
348
384
|
...(resolvedAppGadgets !== undefined
|
|
349
385
|
? { appGadgets: resolvedAppGadgets }
|
|
@@ -360,6 +396,9 @@ export function createUiGenerator(
|
|
|
360
396
|
...(onRetry !== undefined ? { onRetry } : {}),
|
|
361
397
|
...(designMode !== undefined ? { designMode } : {}),
|
|
362
398
|
...(canvas !== undefined ? { canvas } : {}),
|
|
399
|
+
...(input.profile !== undefined ? { profile: input.profile } : {}),
|
|
400
|
+
// Fixture props reach the in-loop probe + visual round only (never the prompt, never identity).
|
|
401
|
+
...(input.fixtureProps !== undefined ? { fixtureProps: input.fixtureProps } : {}),
|
|
363
402
|
});
|
|
364
403
|
|
|
365
404
|
onGenerated?.(result);
|
package/src/design-mode.ts
CHANGED
|
@@ -77,6 +77,16 @@ export type CanvasDisplayMode = "inline" | "fullscreen";
|
|
|
77
77
|
* canvas display mode. Only `chat` and `inline` are the inline card;
|
|
78
78
|
* everything else is width-governed.
|
|
79
79
|
*/
|
|
80
|
+
/**
|
|
81
|
+
* The display mode a JUDGE canvas stands for (ggui#1100): the inline chat
|
|
82
|
+
* card is `xs-chat-card`; every other canvas is a surface the component
|
|
83
|
+
* owns edge to edge, which the served runtime composes with `fit: 'fill'`
|
|
84
|
+
* — so the judge must too, or it scores a picture the user never sees.
|
|
85
|
+
*/
|
|
86
|
+
export function displayModeForCanvas(canvas: CanvasClass): CanvasDisplayMode {
|
|
87
|
+
return canvas === "xs-chat-card" ? "inline" : "fullscreen";
|
|
88
|
+
}
|
|
89
|
+
|
|
80
90
|
export function canvasDisplayModeForShell(shell: string | undefined): CanvasDisplayMode {
|
|
81
91
|
return shell === "chat" || shell === "inline" ? "inline" : "fullscreen";
|
|
82
92
|
}
|
|
@@ -177,6 +187,6 @@ export function describeCanvas(canvas: CanvasClass): string {
|
|
|
177
187
|
return (
|
|
178
188
|
`This component renders on the \`${canvas}\` canvas (${d.label}): ` +
|
|
179
189
|
`${d.minWidthPx}–${d.maxWidthPx}px wide, ${d.host}. ` +
|
|
180
|
-
`Design for the whole range — the host may size the iframe anywhere inside it.`
|
|
190
|
+
`Design for the whole range — the host may size the iframe anywhere inside it, and the host owns the height: size to your content, never to the viewport.`
|
|
181
191
|
);
|
|
182
192
|
}
|
|
@@ -26,7 +26,7 @@ export const DEFAULT_DESIGN_SYSTEM_DOCS = `# Design System - Default Light Theme
|
|
|
26
26
|
- var(--ggui-color-primary-300) - #7dd3fc
|
|
27
27
|
- var(--ggui-color-primary-400) - #38bdf8
|
|
28
28
|
- var(--ggui-color-primary-500) - #0ea5e9
|
|
29
|
-
- var(--ggui-color-primary-600) - #0284c7 (main action
|
|
29
|
+
- var(--ggui-color-primary-600) - #0284c7 (main action FILL — buttons, borders, icon fills; accent TEXT takes the link role below)
|
|
30
30
|
- var(--ggui-color-primary-700) - #0369a1
|
|
31
31
|
- var(--ggui-color-primary-800) - #075985
|
|
32
32
|
- var(--ggui-color-primary-900) - #0c4a6e
|
|
@@ -42,10 +42,11 @@ These tokens adapt automatically to any theme (light, dark, branded). **ALWAYS u
|
|
|
42
42
|
| onContainer | var(--ggui-color-onContainer) | Body text on a card |
|
|
43
43
|
| elevated | var(--ggui-color-elevated) | Menus, popovers, modals, toasts — floats above a card |
|
|
44
44
|
| onElevated | var(--ggui-color-onElevated) | Text on an elevated surface |
|
|
45
|
+
| heroGround | var(--ggui-color-heroGround) | A hero / greeting panel's ground — brand-tinted on a light host, the ink pair on a dark one (Card surface="hero") |
|
|
46
|
+
| onHeroGround | var(--ggui-color-onHeroGround) | Text on the hero ground |
|
|
45
47
|
| sunken | var(--ggui-color-sunken) | Wells, inputs at rest, code blocks — recessed inside a card |
|
|
46
48
|
| onSunken | var(--ggui-color-onSunken) | Muted / secondary text |
|
|
47
|
-
|
|
|
48
|
-
| onContainer | var(--ggui-color-onContainer) | Text on branded containers |
|
|
49
|
+
| link | var(--ggui-color-link) | Accent TEXT — eyebrows, taglines, links, labels, inline arrows: the brand's readable ink, derived per mode to clear 4.5:1 on the container and the ground |
|
|
49
50
|
| outline | var(--ggui-color-outline) | Borders, dividers |
|
|
50
51
|
| outlineVariant | var(--ggui-color-outlineVariant) | Subtle borders |
|
|
51
52
|
|
|
@@ -54,6 +55,7 @@ These tokens adapt automatically to any theme (light, dark, branded). **ALWAYS u
|
|
|
54
55
|
- Body text → \`var(--ggui-color-onContainer)\`
|
|
55
56
|
- Card/panel background → \`var(--ggui-color-container)\`; recessed wells / inputs → \`var(--ggui-color-sunken)\`
|
|
56
57
|
- Secondary/muted text → \`var(--ggui-color-onSunken)\`
|
|
58
|
+
- Accent / branded text (eyebrow, tagline, link, label, inline arrow) → \`var(--ggui-color-link)\` or \`<Text tone="emphasized">\` — never a bare \`primary-<stop>\` as a text color
|
|
57
59
|
- Branded section/header → \`var(--ggui-color-container)\` bg + \`var(--ggui-color-onContainer)\` text
|
|
58
60
|
- Borders/dividers → \`var(--ggui-color-outline)\` or \`var(--ggui-color-outlineVariant)\`
|
|
59
61
|
|
|
@@ -172,7 +174,8 @@ Use \`var(--ggui-spacing-N)\` for all padding, gap, and margin values. **Never u
|
|
|
172
174
|
- **Layering roles** (ground, container, elevated, sunken + their on* inks; outline): one kind of AREA each — use them for every background/text/border decision; they adapt to any theme
|
|
173
175
|
- **primary-50/100**: Section backgrounds, highlight strips, card headers
|
|
174
176
|
- **primary-200/300**: Borders, dividers, focus rings, input outlines
|
|
175
|
-
- **primary-500/600**:
|
|
177
|
+
- **primary-500/600**: Filled buttons, CTAs, badges, icon fills, active indicators — fills and borders, NOT a text color
|
|
178
|
+
- **link**: Accent TEXT — eyebrows, taglines, links, labels, inline arrows; the brand's readable ink per mode (a bare \`primary-300…600\` as a text color fails tier-0: \`universal.accent_text_ink\`)
|
|
176
179
|
- **primary-700/800/900**: Headings and text on light primary backgrounds
|
|
177
180
|
- **neutral-***: Only when you need a specific shade that semantic tokens don't cover
|
|
178
181
|
|
package/src/effort.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// The `effort` reader table (ggui#1059, A2). The wire carries ONLY the level
|
|
2
|
+
// name (`generation.profile.effort`, protocol #1058); this table is the
|
|
3
|
+
// cold-gen READER that maps a name onto the harness's dials, versioned here
|
|
4
|
+
// so a level's meaning (and its price line) can move without a wire change.
|
|
5
|
+
//
|
|
6
|
+
// Boundaries, stated once:
|
|
7
|
+
// - `effort` renders NO prompt text — the prompt is byte-identical under any
|
|
8
|
+
// level (`profile-cross.pin.test.ts`). Dials only.
|
|
9
|
+
// - Absent ⇒ the deployment's own options, untouched. The table is applied
|
|
10
|
+
// only when a level is named.
|
|
11
|
+
// - `selfEvalPassThreshold` is the GENERATOR's in-loop visual
|
|
12
|
+
// self-evaluation threshold (`visualEvaluation.passThreshold`). A serving
|
|
13
|
+
// layer's own acceptance bar is a different number and does not move with
|
|
14
|
+
// `effort`.
|
|
15
|
+
// - `modelTier` / `judgeTier` are REQUESTS to the deployment's router
|
|
16
|
+
// (`fast | balanced | premium`), never a model id: a deployment with one
|
|
17
|
+
// key is never promoted by a profile. The harness does not act on them;
|
|
18
|
+
// the caller that owns model selection reads them from this table.
|
|
19
|
+
import type { AppGenerationProfileEffort, ModelTier } from "@ggui-ai/protocol";
|
|
20
|
+
|
|
21
|
+
/** Bump when a level's meaning changes; the price line beside it follows. */
|
|
22
|
+
export const EFFORT_TABLE_VERSION = 1;
|
|
23
|
+
|
|
24
|
+
export interface EffortDials {
|
|
25
|
+
/** Coding-loop turn cap (`maxAttempts`). */
|
|
26
|
+
readonly maxAttempts: number;
|
|
27
|
+
/** Evaluation rounds the harness may spend (`maxEvalRounds`). */
|
|
28
|
+
readonly maxEvalRounds: number;
|
|
29
|
+
/** The generator's in-loop visual self-evaluation threshold, 0–100 — not a serving layer's acceptance bar. */
|
|
30
|
+
readonly selfEvalPassThreshold: number;
|
|
31
|
+
/** Requested coding-model tier — a request to the deployment's router, never a model id. */
|
|
32
|
+
readonly modelTier: ModelTier;
|
|
33
|
+
/** Requested judge-model tier — same nature. */
|
|
34
|
+
readonly judgeTier: ModelTier;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const EFFORT_DIALS: Readonly<Record<AppGenerationProfileEffort, EffortDials>> = {
|
|
38
|
+
low: { maxAttempts: 5, maxEvalRounds: 1, selfEvalPassThreshold: 65, modelTier: "fast", judgeTier: "fast" },
|
|
39
|
+
medium: { maxAttempts: 8, maxEvalRounds: 2, selfEvalPassThreshold: 70, modelTier: "fast", judgeTier: "balanced" },
|
|
40
|
+
high: { maxAttempts: 8, maxEvalRounds: 3, selfEvalPassThreshold: 75, modelTier: "balanced", judgeTier: "balanced" },
|
|
41
|
+
xhigh: { maxAttempts: 10, maxEvalRounds: 3, selfEvalPassThreshold: 80, modelTier: "premium", judgeTier: "balanced" },
|
|
42
|
+
ultra: { maxAttempts: 12, maxEvalRounds: 4, selfEvalPassThreshold: 85, modelTier: "premium", judgeTier: "premium" },
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** The dials for a named level; `undefined` when no level is named (the deployment's options stand). */
|
|
46
|
+
export function effortDials(effort: AppGenerationProfileEffort | undefined): EffortDials | undefined {
|
|
47
|
+
return effort === undefined ? undefined : EFFORT_DIALS[effort];
|
|
48
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// packages/ui-gen/src/evaluation/axis-checks/checks/grid.ts
|
|
2
|
+
//
|
|
3
|
+
// Checks gated on `render=grid`.
|
|
4
|
+
//
|
|
5
|
+
// ── grid.board_columns_side_by_side (ggui#1046) ─────────────────────────
|
|
6
|
+
// A kanban classifies as `render=grid` (the classifier's GRID_RX carries
|
|
7
|
+
// `kanban`), and three of four constrained mints on the candidate-15 image
|
|
8
|
+
// composed the board's columns as a vertical <Stack> of sections — no <Grid>
|
|
9
|
+
// anywhere — then read below the judge's bar; the tier-2 evaluator saw the
|
|
10
|
+
// stacking only as a `warn`, which never costs a fix turn. This check reads
|
|
11
|
+
// the source the way the fix turn must: find the JSX map over the board's
|
|
12
|
+
// columns array and name the layout primitive that ENCLOSES it. <Grid> or
|
|
13
|
+
// <Row> = side by side; <Stack> / <Card> / <Box> / a bare block / nothing =
|
|
14
|
+
// stacked. Stands down in `free` design mode, where the primitive is optional.
|
|
15
|
+
|
|
16
|
+
import type { EvalIssue } from "../../types-public.js";
|
|
17
|
+
import type { AxisCheck, AxisCheckInput } from "../types.js";
|
|
18
|
+
import { axisCheckTraceEnabled, mkIssue } from "../helpers.js";
|
|
19
|
+
|
|
20
|
+
/** Prop names a board's column array goes by. */
|
|
21
|
+
const BOARD_PROP_RX = /^(columns?|lanes?|stages?|lists?|buckets?|swimlanes?)$/i;
|
|
22
|
+
const BOARD_PROMPT_RX = /\b(kanban|board)\b/i;
|
|
23
|
+
/** The names a board's column array goes by, for the prompt and source paths. */
|
|
24
|
+
const BOARD_NAMES = ["columns", "lanes", "stages", "lists", "buckets", "swimlanes"] as const;
|
|
25
|
+
/** Layout primitives whose nesting decides side-by-side vs stacked. */
|
|
26
|
+
const LAYOUT_TAG_RX = /<(\/?)(Grid|Row|Stack|Card|Box|Container|ScrollArea|div|section)\b([^<>]*?)>/g;
|
|
27
|
+
const SIDE_BY_SIDE = new Set(["Grid", "Row"]);
|
|
28
|
+
|
|
29
|
+
interface PropShapeLike {
|
|
30
|
+
type?: string;
|
|
31
|
+
schema?: { type?: string };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The board prop names the contract declares as arrays. */
|
|
35
|
+
export function boardPropNames(contract: AxisCheckInput["contract"]): string[] {
|
|
36
|
+
const propsField = contract?.propsSpec as { properties?: Record<string, PropShapeLike> } | undefined;
|
|
37
|
+
const properties = propsField?.properties ?? {};
|
|
38
|
+
return Object.entries(properties)
|
|
39
|
+
.filter(([name, p]) => BOARD_PROP_RX.test(name) && (p?.type === "array" || p?.schema?.type === "array"))
|
|
40
|
+
.map(([name]) => name);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Every JSX map over `name`, by index — an expression-body arrow (`name.map((col) => (<…`)
|
|
45
|
+
* or a block-body one (`name.map((col) => { … return (<…`); the serving deployment's candidate-18 kanban
|
|
46
|
+
* mints composed two of three stacked boards through the block form (ggui#1046), which the
|
|
47
|
+
* first reader never matched.
|
|
48
|
+
*/
|
|
49
|
+
function jsxMapPositions(sourceCode: string, name: string): number[] {
|
|
50
|
+
const rx = new RegExp(
|
|
51
|
+
`\\b${name}(?:\\?\\.)?\\.map\\(\\s*\\(?\\s*\\w+(?:\\s*,\\s*\\w+)?\\s*\\)?\\s*=>\\s*(?:\\(?\\s*<|\\{[^{}]{0,600}?\\breturn\\s*\\(?\\s*<)`,
|
|
52
|
+
"g",
|
|
53
|
+
);
|
|
54
|
+
return [...sourceCode.matchAll(rx)].map((m) => m.index ?? 0);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The layout primitive enclosing `pos`, walked as a tag stack over the source before it. */
|
|
58
|
+
export function enclosingLayoutTag(sourceCode: string, pos: number): string | undefined {
|
|
59
|
+
const stack: string[] = [];
|
|
60
|
+
for (const m of sourceCode.slice(0, pos).matchAll(LAYOUT_TAG_RX)) {
|
|
61
|
+
const closing = m[1] === "/";
|
|
62
|
+
const name = m[2] ?? "";
|
|
63
|
+
const attrs = m[3] ?? "";
|
|
64
|
+
if (closing) {
|
|
65
|
+
if (stack[stack.length - 1] === name) stack.pop();
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (attrs.trimEnd().endsWith("/")) continue; // self-closing
|
|
69
|
+
stack.push(name);
|
|
70
|
+
}
|
|
71
|
+
return stack[stack.length - 1];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** What the check read (ggui#1046): the board names it walked and, per name, every JSX map's enclosing layout tag. */
|
|
75
|
+
export interface BoardRead {
|
|
76
|
+
readonly declared: readonly string[];
|
|
77
|
+
readonly names: readonly string[];
|
|
78
|
+
readonly perName: ReadonlyArray<{ readonly name: string; readonly maps: number; readonly enclosing: readonly string[] }>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The board check's own trace line (ggui#1046). */
|
|
82
|
+
export interface BoardColumnsTrace extends BoardRead {
|
|
83
|
+
readonly designMode: string;
|
|
84
|
+
readonly standDown: boolean;
|
|
85
|
+
readonly result: "pass" | "fail" | "stand-down";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function describeBoardRead(input: AxisCheckInput): BoardRead {
|
|
89
|
+
// Which array the board's columns are: the contract names it; else the prompt says
|
|
90
|
+
// "board" / "kanban"; else the SOURCE itself does — a JSX map over a board-shaped name
|
|
91
|
+
// (ggui#1046: on candidate 20 the check flagged every stacked source locally and none on
|
|
92
|
+
// the serving deployment, where the contract and prompt reach it in another shape; the
|
|
93
|
+
// source is the one input every path hands over verbatim).
|
|
94
|
+
const declared = boardPropNames(input.contract);
|
|
95
|
+
const names = declared.length > 0
|
|
96
|
+
? declared
|
|
97
|
+
: BOARD_PROMPT_RX.test(input.originalPrompt)
|
|
98
|
+
? [...BOARD_NAMES]
|
|
99
|
+
: BOARD_NAMES.filter((n) => jsxMapPositions(input.sourceCode, n).length > 0);
|
|
100
|
+
const perName = names.map((name) => {
|
|
101
|
+
const positions = jsxMapPositions(input.sourceCode, name);
|
|
102
|
+
return { name, maps: positions.length, enclosing: positions.map((pos) => enclosingLayoutTag(input.sourceCode, pos) ?? "nothing") };
|
|
103
|
+
});
|
|
104
|
+
return { declared, names, perName };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Board maps composed under something other than <Grid> / <Row>: `[propName, enclosing tag or "nothing"]`. */
|
|
108
|
+
export function findStackedBoardColumns(input: AxisCheckInput): Array<readonly [string, string]> {
|
|
109
|
+
const stacked: Array<readonly [string, string]> = [];
|
|
110
|
+
for (const { name, enclosing } of describeBoardRead(input).perName) {
|
|
111
|
+
for (const tag of enclosing) if (!SIDE_BY_SIDE.has(tag)) stacked.push([name, tag]);
|
|
112
|
+
}
|
|
113
|
+
return stacked;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function runBoardColumnsSideBySide(input: AxisCheckInput): EvalIssue[] {
|
|
117
|
+
if (input.compiledCode === null) return [];
|
|
118
|
+
const standDown = input.designMode === "free";
|
|
119
|
+
const stacked = standDown ? [] : findStackedBoardColumns(input);
|
|
120
|
+
if (axisCheckTraceEnabled()) {
|
|
121
|
+
// The check's own line beside the dispatcher's (ggui#1046): what it read, PASS and stand-down included.
|
|
122
|
+
const line: BoardColumnsTrace = {
|
|
123
|
+
...describeBoardRead(input),
|
|
124
|
+
designMode: input.designMode ?? "constrained",
|
|
125
|
+
standDown,
|
|
126
|
+
result: standDown ? "stand-down" : stacked.length > 0 ? "fail" : "pass",
|
|
127
|
+
};
|
|
128
|
+
console.log(JSON.stringify({ boardColumnsTrace: line }));
|
|
129
|
+
}
|
|
130
|
+
if (stacked.length === 0) return [];
|
|
131
|
+
const [name, tag] = stacked[0]!;
|
|
132
|
+
return [
|
|
133
|
+
mkIssue(
|
|
134
|
+
"grid.board_columns_side_by_side",
|
|
135
|
+
`The board's \`${name}\` are composed under <${tag}> — a vertical stack of column sections; at md and wider the columns must sit side by side.`,
|
|
136
|
+
`Wrap the ${name} map in <Grid columns={{ base: 1, md: ${name}.length }} gap="md"> (each column its own <Stack> of cards); never a <Stack> of column sections.`,
|
|
137
|
+
),
|
|
138
|
+
];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export const GRID_CHECKS: readonly AxisCheck[] = [
|
|
142
|
+
{
|
|
143
|
+
id: "grid.board_columns_side_by_side",
|
|
144
|
+
axis: "render",
|
|
145
|
+
values: ["grid"],
|
|
146
|
+
run: runBoardColumnsSideBySide,
|
|
147
|
+
},
|
|
148
|
+
];
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// component. Dispatched via a "universal" gate that matches every render
|
|
5
5
|
// value.
|
|
6
6
|
|
|
7
|
+
import { LUCIDE_ICON_NAMES } from "@ggui-ai/design";
|
|
7
8
|
import type { EvalIssue } from "../../types-public.js";
|
|
8
9
|
import type { AxisCheck, AxisCheckInput } from "../types.js";
|
|
9
10
|
import {
|
|
@@ -119,7 +120,250 @@ function runPropSeedNoResync(input: AxisCheckInput): EvalIssue[] {
|
|
|
119
120
|
return issues;
|
|
120
121
|
}
|
|
121
122
|
|
|
123
|
+
// ── universal.icon_name_known (ggui#1015) ────────────────────────────
|
|
124
|
+
// The Icon primitive renders ONLY its curated Lucide subset; an unknown
|
|
125
|
+
// name renders an empty box. The model reaches for names from the full
|
|
126
|
+
// Lucide set ("sparkles", "arrow-up-right" on the hello frames), so the
|
|
127
|
+
// check flags every string-literal `<Icon name>` outside the subset —
|
|
128
|
+
// the fix turn has `get_available_icons`. Dynamic names (`name={x}`)
|
|
129
|
+
// and emoji/unicode (rendered as text) are outside the check's scope.
|
|
130
|
+
|
|
131
|
+
/** Icon.tsx's resolver rule: exact key, else dashes/underscores stripped + lowercased. */
|
|
132
|
+
const KNOWN_ICON_KEYS: ReadonlySet<string> = new Set(
|
|
133
|
+
LUCIDE_ICON_NAMES.map((n) => n.replace(/[-_]/g, "").toLowerCase()),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
export function isKnownIconName(name: string): boolean {
|
|
137
|
+
return KNOWN_ICON_KEYS.has(name.replace(/[-_]/g, "").toLowerCase());
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const ICON_NAME_LITERAL_RX =
|
|
141
|
+
/<Icon\b[^>]*?\bname\s*=\s*(?:"([^"]*)"|'([^']*)'|\{\s*"([^"]*)"\s*\}|\{\s*'([^']*)'\s*\})/g;
|
|
142
|
+
|
|
143
|
+
export function findUnknownIconNames(sourceCode: string): string[] {
|
|
144
|
+
const unknown = new Set<string>();
|
|
145
|
+
for (const m of sourceCode.matchAll(ICON_NAME_LITERAL_RX)) {
|
|
146
|
+
const name = m[1] ?? m[2] ?? m[3] ?? m[4] ?? "";
|
|
147
|
+
if (name.length === 0) continue;
|
|
148
|
+
if (/[^\x20-\x7e]/.test(name)) continue; // emoji / unicode passthrough renders as text
|
|
149
|
+
if (!isKnownIconName(name)) unknown.add(name);
|
|
150
|
+
}
|
|
151
|
+
return [...unknown];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function runIconNameKnown(input: AxisCheckInput): EvalIssue[] {
|
|
155
|
+
if (input.compiledCode === null) return [];
|
|
156
|
+
const unknown = findUnknownIconNames(input.sourceCode);
|
|
157
|
+
if (unknown.length === 0) return [];
|
|
158
|
+
const list = unknown.map((n) => `"${n}"`).join(", ");
|
|
159
|
+
return [
|
|
160
|
+
mkIssue(
|
|
161
|
+
"universal.icon_name_known",
|
|
162
|
+
`<Icon name=…> uses ${unknown.length} name(s) outside the design system's Lucide subset: ${list} — an unknown name renders an empty box.`,
|
|
163
|
+
"Call get_available_icons and pick a listed name (kebab-case), or use an emoji directly.",
|
|
164
|
+
),
|
|
165
|
+
];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── universal.accent_text_ink (ggui#1039) ────────────────────────────
|
|
169
|
+
// The brand ladder's mid stops (300–600) are FILL colours. On a
|
|
170
|
+
// low-saturation brand the 600 stop is mid-grey on the surface, and every
|
|
171
|
+
// one of four brand-directed hello frames painted its eyebrow and inline
|
|
172
|
+
// arrows with it — two of them at 3.5:1 and 2.91:1 on the ground. Accent
|
|
173
|
+
// TEXT is the theme's readable ink `--ggui-color-link` (derived per mode to
|
|
174
|
+
// clear 4.5:1, ggui#1035) or `<Text tone="emphasized">`; `link` resolves to
|
|
175
|
+
// the very same 600 wherever 600 already reads, so the fix never regresses a
|
|
176
|
+
// saturated brand. Out of scope by design: tints as text (50–200 — the
|
|
177
|
+
// on-fill idiom, `primary-50` on a `primary-600` bubble) and the deep stops
|
|
178
|
+
// (700–900 — text on a light primary tint), both documented uses. Matches the
|
|
179
|
+
// `color` property in every spelling the model writes — JSX prop, style
|
|
180
|
+
// object (source and compiled), CSS declaration — and never a compound
|
|
181
|
+
// property (`backgroundColor`, `border-color`, `accent-color`).
|
|
182
|
+
|
|
183
|
+
const LADDER_STOP_AS_TEXT_RX =
|
|
184
|
+
/(?<![\w-])color\s*(?:=\s*\{?\s*|:\s*)["'`]?\s*var\(--ggui-color-primary-(\d{2,3})\s*[,)]/g;
|
|
185
|
+
const TEXT_INK_STOP_MIN = 300;
|
|
186
|
+
const TEXT_INK_STOP_MAX = 600;
|
|
187
|
+
|
|
188
|
+
/** Distinct `primary-<stop>` tokens (300–600) the source paints as a text `color`, in source order. */
|
|
189
|
+
export function findLadderStopsAsText(sourceCode: string): string[] {
|
|
190
|
+
const found = new Set<string>();
|
|
191
|
+
for (const m of sourceCode.matchAll(LADDER_STOP_AS_TEXT_RX)) {
|
|
192
|
+
const digits = m[1] ?? "";
|
|
193
|
+
const stop = Number(digits);
|
|
194
|
+
if (stop >= TEXT_INK_STOP_MIN && stop <= TEXT_INK_STOP_MAX) found.add(`primary-${digits}`);
|
|
195
|
+
}
|
|
196
|
+
return [...found];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function runAccentTextInk(input: AxisCheckInput): EvalIssue[] {
|
|
200
|
+
if (input.compiledCode === null) return [];
|
|
201
|
+
const stops = findLadderStopsAsText(input.sourceCode);
|
|
202
|
+
if (stops.length === 0) return [];
|
|
203
|
+
const list = stops.map((s) => `\`${s}\``).join(", ");
|
|
204
|
+
return [
|
|
205
|
+
mkIssue(
|
|
206
|
+
"universal.accent_text_ink",
|
|
207
|
+
`Text is painted with a brand-ladder fill stop as its color (${list}) — on a low-saturation brand these stops are mid-grey on the surface and the text fails readability.`,
|
|
208
|
+
'Accent text (eyebrows, taglines, links, labels, inline arrows) takes var(--ggui-color-link) or <Text tone="emphasized">; body text takes var(--ggui-color-onContainer); keep primary-* stops on fills and borders.',
|
|
209
|
+
),
|
|
210
|
+
];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── universal.scoped_surface_owns_ground (ggui#1047) ─────────────────
|
|
214
|
+
// A SCOPED surface — `surface="inverted"` or `"hero"` on Card / Box — owns its
|
|
215
|
+
// ground: the primitive paints it, the scope rule swaps every ink beneath it.
|
|
216
|
+
// The served Mosaic hello repainted an inverted card's ground with the page's
|
|
217
|
+
// (`style={{ background: 'var(--ggui-color-ground)' }}`) and its hero text
|
|
218
|
+
// read 1:1 — the swapped inks over a ground the scope did not own. The design
|
|
219
|
+
// package now strips that background at render; this check makes the fix turn
|
|
220
|
+
// remove it at the source, so the author's intent never silently vanishes.
|
|
221
|
+
// Stands down in `free` design mode (the primitive is optional there).
|
|
222
|
+
|
|
223
|
+
const SCOPED_SURFACE_TAG_RX = /<(Card|Box)\b([^<>]*?)surface\s*=\s*["'](inverted|hero)["']([^<>]*?)>/g;
|
|
224
|
+
const STYLE_BACKGROUND_RX = /\bstyle\s*=\s*\{\{[^}]*\b(background|backgroundColor|backgroundImage)\s*:/;
|
|
225
|
+
|
|
226
|
+
/** `[tag, surface]` for every scoped Card / Box whose own `style` sets a background. */
|
|
227
|
+
export function findScopedSurfaceBackgrounds(sourceCode: string): Array<readonly [string, string]> {
|
|
228
|
+
const hits: Array<readonly [string, string]> = [];
|
|
229
|
+
for (const m of sourceCode.matchAll(SCOPED_SURFACE_TAG_RX)) {
|
|
230
|
+
const attrs = `${m[2] ?? ""} ${m[4] ?? ""}`;
|
|
231
|
+
if (STYLE_BACKGROUND_RX.test(attrs)) hits.push([m[1] ?? "", m[3] ?? ""]);
|
|
232
|
+
}
|
|
233
|
+
return hits;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function runScopedSurfaceOwnsGround(input: AxisCheckInput): EvalIssue[] {
|
|
237
|
+
if (input.compiledCode === null) return [];
|
|
238
|
+
if (input.designMode === "free") return [];
|
|
239
|
+
const hits = findScopedSurfaceBackgrounds(input.sourceCode);
|
|
240
|
+
if (hits.length === 0) return [];
|
|
241
|
+
const [tag, surface] = hits[0]!;
|
|
242
|
+
return [
|
|
243
|
+
mkIssue(
|
|
244
|
+
"universal.scoped_surface_owns_ground",
|
|
245
|
+
`<${tag} surface="${surface}"> sets its own background in style — a scoped surface owns its ground; repainting it puts the scope's swapped inks over the wrong ground (ink on ink).`,
|
|
246
|
+
`Remove the background from that ${tag}'s style — surface="${surface}" paints the ground; pick another surface if a different ground is wanted.`,
|
|
247
|
+
),
|
|
248
|
+
];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ── universal.caps_label_invented (ggui#1075 Track A) ─────────────────
|
|
252
|
+
// A caps label (`<Text caps>` — the eyebrow / kicker / overline treatment)
|
|
253
|
+
// whose entire content is a string literal is copy the model INVENTED: the
|
|
254
|
+
// contract carries no such member, so nothing the agent sends can change
|
|
255
|
+
// it, and every served card wears it ("LET'S BEGIN", "WELCOME" — 59 of 63
|
|
256
|
+
// generated sources in one sample, none bound to a prop). Copy comes from the
|
|
257
|
+
// props; a caps label exists only when a prop supplies its text. A label
|
|
258
|
+
// whose content is an expression (`{props.section}`, `{label ?? …}`) is
|
|
259
|
+
// bound and passes; a `<Text caps>` with no text children is not a label.
|
|
260
|
+
const CAPS_TEXT_RX = /<Text\b(?=[^>]*\bcaps\b)[^>]*>([\s\S]*?)<\/Text>/g;
|
|
261
|
+
|
|
262
|
+
/** Every `<Text caps>` whose content is nothing but literal text: `[literal]`. */
|
|
263
|
+
export function findInventedCapsLabels(sourceCode: string): string[] {
|
|
264
|
+
const out: string[] = [];
|
|
265
|
+
for (const m of sourceCode.matchAll(CAPS_TEXT_RX)) {
|
|
266
|
+
const inner = (m[1] ?? "").trim();
|
|
267
|
+
if (inner.length === 0) continue;
|
|
268
|
+
if (inner.includes("{")) continue; // bound to an expression — the props speak
|
|
269
|
+
out.push(inner.replace(/\s+/g, " ").slice(0, 60));
|
|
270
|
+
}
|
|
271
|
+
return out;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function runCapsLabelInvented(input: AxisCheckInput): EvalIssue[] {
|
|
275
|
+
if (input.compiledCode === null) return [];
|
|
276
|
+
const invented = findInventedCapsLabels(input.sourceCode);
|
|
277
|
+
if (invented.length === 0) return [];
|
|
278
|
+
const list = invented.map((t) => `"${t}"`).join(", ");
|
|
279
|
+
return [
|
|
280
|
+
mkIssue(
|
|
281
|
+
"universal.caps_label_invented",
|
|
282
|
+
`${invented.length} caps label(s) carry invented copy the props do not supply: ${list} — an eyebrow, kicker or overline the agent cannot change is chatter on every render.`,
|
|
283
|
+
"Remove the label, or bind its text to a prop (`<Text caps>{props.section}</Text>`); copy comes from the contract, never from the component.",
|
|
284
|
+
),
|
|
285
|
+
];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ── universal.viewport_sized (ggui#1075 Track A/B, receipted on #1096) ─
|
|
289
|
+
// An element sized to the viewport — `100vh` / `100dvh` / `100svh` / `100lvh`
|
|
290
|
+
// (bare or inside `calc()`) as a `height` / `min-height`, or the `h-screen` /
|
|
291
|
+
// `min-h-screen` utility classes. The frame owns the height: a content-sized
|
|
292
|
+
// frame is measured FROM the content, so a viewport-sized root can never
|
|
293
|
+
// shrink and grows the frame on every re-measure; under fullscreen the frame
|
|
294
|
+
// already stretches the root to its height. 20 of 48 minted cells in one
|
|
295
|
+
// sample carried `minHeight: "100vh"` on their root — one an inline chat
|
|
296
|
+
// card told "compact". A `max-height` cap on a scroll region is not sizing
|
|
297
|
+
// and passes; so do `height: 100%`, `flex: 1` and pixel heights.
|
|
298
|
+
// The unit, with a `calc(…)` wrapper closed when present — so the quoted
|
|
299
|
+
// snippet reads whole (`height: "calc(100dvh - 64px)"`).
|
|
300
|
+
const VIEWPORT_UNIT = String.raw`(?:calc\(\s*)?100(?:vh|dvh|svh|lvh)\b(?:[^)"'\x60;\n]*\))?`;
|
|
301
|
+
const VIEWPORT_STYLE_OBJECT_RX = new RegExp(
|
|
302
|
+
String.raw`\b(?:minHeight|height|minBlockSize|blockSize)\s*:\s*["'\x60]\s*${VIEWPORT_UNIT}["'\x60]?`,
|
|
303
|
+
"g",
|
|
304
|
+
);
|
|
305
|
+
const VIEWPORT_CSS_RX = new RegExp(
|
|
306
|
+
String.raw`(?<![\w-])(?:min-)?(?:height|block-size)\s*:\s*${VIEWPORT_UNIT}`,
|
|
307
|
+
"g",
|
|
308
|
+
);
|
|
309
|
+
const VIEWPORT_CLASS_RX = /(?<![\w-])(?:min-)?h-(?:screen|dvh|svh|lvh)\b/g;
|
|
310
|
+
|
|
311
|
+
/** Every viewport-sized height in the source, as written (trimmed, ≤ 60 chars), in order. */
|
|
312
|
+
export function findViewportSizing(sourceCode: string): string[] {
|
|
313
|
+
const hits: Array<{ at: number; text: string }> = [];
|
|
314
|
+
for (const rx of [VIEWPORT_STYLE_OBJECT_RX, VIEWPORT_CSS_RX, VIEWPORT_CLASS_RX]) {
|
|
315
|
+
for (const m of sourceCode.matchAll(rx)) {
|
|
316
|
+
hits.push({ at: m.index ?? 0, text: m[0].replace(/\s+/g, " ").trim().slice(0, 60) });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return hits.sort((a, b) => a.at - b.at).map((h) => h.text);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function runViewportSized(input: AxisCheckInput): EvalIssue[] {
|
|
323
|
+
if (input.compiledCode === null) return [];
|
|
324
|
+
const sized = findViewportSizing(input.sourceCode);
|
|
325
|
+
if (sized.length === 0) return [];
|
|
326
|
+
const list = sized.map((t) => "`" + t + "`").join(", ");
|
|
327
|
+
return [
|
|
328
|
+
mkIssue(
|
|
329
|
+
"universal.viewport_sized",
|
|
330
|
+
`${sized.length} element(s) are sized to the viewport: ${list} — the frame owns the height; a content-sized frame is measured from the content, so a viewport-sized root never shrinks and grows the frame on every re-measure.`,
|
|
331
|
+
"Remove the viewport height — size to content, never to the viewport. Under fullscreen the frame stretches your root already (centre inside it with `display: flex; flex-direction: column; justify-content: center`); to fill a parent that has a height use `height: 100%` or `flex: 1`.",
|
|
332
|
+
),
|
|
333
|
+
];
|
|
334
|
+
}
|
|
335
|
+
|
|
122
336
|
export const UNIVERSAL_CHECKS: readonly AxisCheck[] = [
|
|
337
|
+
{
|
|
338
|
+
id: "universal.icon_name_known",
|
|
339
|
+
axis: "render",
|
|
340
|
+
values: ALL_RENDER_VALUES,
|
|
341
|
+
run: runIconNameKnown,
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
id: "universal.caps_label_invented",
|
|
345
|
+
axis: "render",
|
|
346
|
+
values: ALL_RENDER_VALUES,
|
|
347
|
+
run: runCapsLabelInvented,
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
id: "universal.viewport_sized",
|
|
351
|
+
axis: "render",
|
|
352
|
+
values: ALL_RENDER_VALUES,
|
|
353
|
+
run: runViewportSized,
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
id: "universal.accent_text_ink",
|
|
357
|
+
axis: "render",
|
|
358
|
+
values: ALL_RENDER_VALUES,
|
|
359
|
+
run: runAccentTextInk,
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
id: "universal.scoped_surface_owns_ground",
|
|
363
|
+
axis: "render",
|
|
364
|
+
values: ALL_RENDER_VALUES,
|
|
365
|
+
run: runScopedSurfaceOwnsGround,
|
|
366
|
+
},
|
|
123
367
|
{
|
|
124
368
|
id: "universal.prop_coverage",
|
|
125
369
|
axis: "render",
|