@12ui/design 0.2.51 → 0.2.53
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/SKILL.md +1 -1
- package/dist/branch-client.d.ts +5 -0
- package/dist/branch-client.d.ts.map +1 -1
- package/dist/branch-client.js.map +1 -1
- package/dist/branch-completion.d.ts.map +1 -1
- package/dist/branch-completion.js +7 -0
- package/dist/branch-completion.js.map +1 -1
- package/dist/branch-identity.d.ts +25 -0
- package/dist/branch-identity.d.ts.map +1 -1
- package/dist/branch-identity.js +28 -15
- package/dist/branch-identity.js.map +1 -1
- package/dist/branch-page-conversion.d.ts.map +1 -1
- package/dist/branch-page-conversion.js +49 -4
- package/dist/branch-page-conversion.js.map +1 -1
- package/dist/branch-plan.d.ts +4 -1
- package/dist/branch-plan.d.ts.map +1 -1
- package/dist/branch-plan.js +7 -0
- package/dist/branch-plan.js.map +1 -1
- package/dist/branch-run-record.d.ts +4 -0
- package/dist/branch-run-record.d.ts.map +1 -1
- package/dist/branch-run-record.js.map +1 -1
- package/dist/cli-draft-command.d.ts.map +1 -1
- package/dist/cli-draft-command.js +17 -1
- package/dist/cli-draft-command.js.map +1 -1
- package/dist/hosted-draft-run.d.ts +25 -1
- package/dist/hosted-draft-run.d.ts.map +1 -1
- package/dist/hosted-draft-run.js +82 -2
- package/dist/hosted-draft-run.js.map +1 -1
- package/dist/package-client.d.ts +11 -0
- package/dist/package-client.d.ts.map +1 -1
- package/dist/package-client.js.map +1 -1
- package/dist/package-submission.d.ts +13 -2
- package/dist/package-submission.d.ts.map +1 -1
- package/dist/package-submission.js +34 -1
- package/dist/package-submission.js.map +1 -1
- package/dist/prototype-retention.d.ts +10 -0
- package/dist/prototype-retention.d.ts.map +1 -0
- package/dist/prototype-retention.js +14 -0
- package/dist/prototype-retention.js.map +1 -0
- package/dist/prototype-revision.d.ts +1 -1
- package/dist/prototype-revision.js +1 -1
- package/dist/prototype-runner.d.ts.map +1 -1
- package/dist/prototype-runner.js +39 -15
- package/dist/prototype-runner.js.map +1 -1
- package/dist/review-server.d.ts.map +1 -1
- package/dist/review-server.js +10 -1
- package/dist/review-server.js.map +1 -1
- package/open-design.json +2 -2
- package/package.json +1 -1
- package/prototype-kit/build.mjs +54 -9
- package/prototype-kit/lib/action-target.mjs +34 -8
- package/prototype-kit/lib/runtime-check.mjs +31 -12
- package/prototype-kit/polish/ground-probe.mjs +4 -1
- package/prototype-kit/polish/measure.mjs +6 -6
- package/prototype-kit/polish/rules/section-breathing.mjs +461 -237
- package/prototype-kit/polish/tests/breathing-check.mjs +75 -27
- package/prototype-kit/polish/tests/fixtures/section-breathing/diagram-silent.html +20 -0
- package/prototype-kit/polish/tests/fixtures/section-breathing/fire.html +2 -2
- package/prototype-kit/polish/tests/fixtures/section-breathing/floor.html +3 -3
- package/prototype-kit/polish/tests/fixtures/section-breathing/no-scale-silent.html +18 -0
- package/prototype-kit/polish/tests/fixtures/section-breathing/rhythm-nested.html +21 -0
- package/prototype-kit/polish/tests/fixtures/section-breathing/semantic-fire.html +10 -4
- package/prototype-kit/polish/tests/fixtures/section-breathing/semantic-silent.html +2 -2
- package/prototype-kit/polish/tests/fixtures/section-breathing/silent.html +2 -2
- package/prototype-kit/polish/tests/fixtures/section-breathing/tight.html +4 -4
- package/prototype-kit/runtime/proto.js +52 -2
- package/prototype-kit/tests/adversarial-runtime.mjs +179 -4
- package/prototype-kit/tests/fixtures/blocking-runtime/broken-interaction.html +1 -0
- package/prototype-kit/tests/fixtures/interaction-runtime/PROVENANCE.md +5 -0
- package/prototype-kit/tests/fixtures/interaction-runtime/inert-field.html +23 -0
- package/prototype-kit/tests/fixtures/interaction-runtime/pointer-cascade.html +19 -0
- package/prototype-kit/tests/fixtures/multiple-trigger-runtime/probe-all.html +14 -0
- package/prototype-kit/tests/fixtures/nested-inert/branch.plan.json +5 -0
- package/prototype-kit/tests/fixtures/nested-inert/pages/p0.html +17 -0
- package/prototype-kit/tests/offline-check.mjs +6 -1
- package/prototype-kit/tests/rules-check.mjs +28 -9
- package/prototype-kit/verify-runtime.mjs +10 -5
- package/prototype-kit/polish/tests/fixtures/section-breathing/attachment-fire.html +0 -5
- package/prototype-kit/polish/tests/fixtures/section-breathing/attachment-silent.html +0 -5
- package/prototype-kit/polish/tests/fixtures/section-breathing/shadow-fire.html +0 -5
- package/prototype-kit/polish/tests/fixtures/section-breathing/shadow-silent.html +0 -5
|
@@ -7,6 +7,7 @@ import path from 'node:path';
|
|
|
7
7
|
import { chromium } from '@playwright/test';
|
|
8
8
|
import { checkRuntime } from '../lib/runtime-check.mjs';
|
|
9
9
|
import { startStaticServer } from '../lib/static-server.mjs';
|
|
10
|
+
import { buildPrototype } from './lib/run-process.mjs';
|
|
10
11
|
|
|
11
12
|
const outIndex = process.argv.indexOf('--out');
|
|
12
13
|
const outFile = outIndex >= 0 ? process.argv[outIndex + 1] : null;
|
|
@@ -131,13 +132,125 @@ const interactionReachability = {
|
|
|
131
132
|
occludedExactFailure: interactionPage('occluded-saved-journeys.html')?.dropdown,
|
|
132
133
|
ordinaryControl: interactionPage('ordinary-dropdown.html')?.dropdown,
|
|
133
134
|
reviewBottomControl: interactionPage('review-bottom-action.html')?.modal,
|
|
135
|
+
pointerCascade: interactionPage('pointer-cascade.html')?.dropdown,
|
|
134
136
|
};
|
|
135
137
|
|
|
138
|
+
const pointerPage = await browser.newPage();
|
|
139
|
+
await pointerPage.goto(`${server.baseUrl}/tests/fixtures/interaction-runtime/pointer-cascade.html`, { waitUntil: 'networkidle' });
|
|
140
|
+
const pointerCascade = [];
|
|
141
|
+
for (const id of ['inline-none', 'rule-none', 'rule-important']) {
|
|
142
|
+
const locator = pointerPage.locator(`#${id}`);
|
|
143
|
+
pointerCascade.push(await locator.evaluate((element) => ({
|
|
144
|
+
id: element.id,
|
|
145
|
+
computed: getComputedStyle(element).pointerEvents,
|
|
146
|
+
inline: element.style.getPropertyValue('pointer-events'),
|
|
147
|
+
priority: element.style.getPropertyPriority('pointer-events'),
|
|
148
|
+
runtimeBound: element.getAttribute('data-proto-runtime-bound'),
|
|
149
|
+
})));
|
|
150
|
+
await locator.click();
|
|
151
|
+
pointerCascade[pointerCascade.length - 1].opened = await pointerPage.locator('.proto-menu').count();
|
|
152
|
+
await pointerPage.keyboard.press('Escape');
|
|
153
|
+
}
|
|
154
|
+
async function inertControlProbe(page, selector) {
|
|
155
|
+
const state = await page.locator(selector).evaluate((element) => ({
|
|
156
|
+
computed: getComputedStyle(element).pointerEvents,
|
|
157
|
+
inline: element.style.getPropertyValue('pointer-events'),
|
|
158
|
+
priority: element.style.getPropertyPriority('pointer-events'),
|
|
159
|
+
runtimeBound: element.getAttribute('data-proto-runtime-bound'),
|
|
160
|
+
suppressed: element.getAttribute('data-proto-suppressed'),
|
|
161
|
+
}));
|
|
162
|
+
await page.locator(selector).evaluate((element) => element.click());
|
|
163
|
+
const opened = await page.locator('.proto-modal').count();
|
|
164
|
+
return { ...state, opened };
|
|
165
|
+
}
|
|
166
|
+
const disabledPointerControl = await inertControlProbe(pointerPage, '#disabled-control');
|
|
167
|
+
const disabledOpened = disabledPointerControl.opened;
|
|
168
|
+
// The converter's own presentational-control marker and an uppercase
|
|
169
|
+
// aria-disabled boolean token must both stay inert exactly like a native
|
|
170
|
+
// disabled control -- this is the boundary gap a prior review found live
|
|
171
|
+
// (an end-to-end build repaired and bound one of these, and a real click
|
|
172
|
+
// opened it).
|
|
173
|
+
const presentationalControl = await inertControlProbe(pointerPage, '#presentational-control');
|
|
174
|
+
const ariaDisabledUpperControl = await inertControlProbe(pointerPage, '#aria-disabled-upper');
|
|
175
|
+
// WAI-ARIA 1.2: an ancestor's aria-disabled="true" is not cancelled by a
|
|
176
|
+
// nearer descendant's own aria-disabled="false".
|
|
177
|
+
const ariaAncestorGuardControl = await inertControlProbe(pointerPage, '#aria-ancestor-guard');
|
|
178
|
+
await pointerPage.close();
|
|
179
|
+
|
|
180
|
+
// Converter-build-runtime regression: a real submit button can be disabled
|
|
181
|
+
// while build.mjs promotes binding to its (non-disabled) icon wrapper --
|
|
182
|
+
// checking only the promoted wrapper for source-inert state misses the
|
|
183
|
+
// disabled attribute sitting on the button it wraps. This runs the actual
|
|
184
|
+
// inventory -> analyze -> build pipeline (not a hand-authored fixture) and
|
|
185
|
+
// physically clicks the wrapper, exactly the path a real user takes.
|
|
186
|
+
const nestedInertScratch = fs.mkdtempSync(path.join(os.tmpdir(), 'prototype-nested-inert-'));
|
|
187
|
+
const nestedInertBuild = await buildPrototype({
|
|
188
|
+
kitDir,
|
|
189
|
+
run: { runDir: path.join(kitDir, 'tests', 'fixtures', 'nested-inert') },
|
|
190
|
+
runWorkDir: nestedInertScratch,
|
|
191
|
+
});
|
|
192
|
+
if (!nestedInertBuild.ok) throw new Error(`nested-inert fixture failed to build at stage ${nestedInertBuild.failedStage}`);
|
|
193
|
+
const nestedInertManifest = JSON.parse(fs.readFileSync(path.join(nestedInertBuild.protoDir, 'proto-manifest.json'), 'utf8'));
|
|
194
|
+
const nestedInertServer = await startStaticServer(nestedInertBuild.protoDir);
|
|
195
|
+
const nestedInertPage = await browser.newPage();
|
|
196
|
+
await nestedInertPage.goto(`${nestedInertServer.baseUrl}/p0.html`, { waitUntil: 'networkidle' });
|
|
197
|
+
const nestedInertWrap = await submitProbe(nestedInertPage, 'viewport-1-a-search-submit-wrap');
|
|
198
|
+
await nestedInertPage.locator('#viewport-1-a-search-submit-wrap').click();
|
|
199
|
+
const nestedInertModalsOpened = await nestedInertPage.locator('.proto-modal').count();
|
|
200
|
+
await nestedInertPage.close();
|
|
201
|
+
await nestedInertServer.close();
|
|
202
|
+
fs.rmSync(nestedInertScratch, { recursive: true, force: true });
|
|
203
|
+
|
|
204
|
+
const fieldPage = await browser.newPage();
|
|
205
|
+
await fieldPage.goto(`${server.baseUrl}/tests/fixtures/interaction-runtime/inert-field.html`, { waitUntil: 'networkidle' });
|
|
206
|
+
async function fieldProbe(page, fieldId) {
|
|
207
|
+
const before = await page.locator(`#${fieldId}-text`).textContent();
|
|
208
|
+
await page.locator(`#${fieldId}-text`).click();
|
|
209
|
+
await page.keyboard.type('probe');
|
|
210
|
+
const after = await page.locator(`#${fieldId}-text`).textContent();
|
|
211
|
+
return { editable: after !== before, contentEditable: await page.locator(`#${fieldId}-text`).evaluate((el) => el.isContentEditable) };
|
|
212
|
+
}
|
|
213
|
+
async function submitProbe(page, submitId) {
|
|
214
|
+
return page.locator(`#${submitId}`).evaluate((element) => ({
|
|
215
|
+
computed: getComputedStyle(element).pointerEvents,
|
|
216
|
+
runtimeBound: element.getAttribute('data-proto-runtime-bound'),
|
|
217
|
+
suppressed: element.getAttribute('data-proto-suppressed'),
|
|
218
|
+
}));
|
|
219
|
+
}
|
|
220
|
+
const liveField = await fieldProbe(fieldPage, 'live-field');
|
|
221
|
+
const liveSubmit = await submitProbe(fieldPage, 'live-submit');
|
|
222
|
+
const inertField = await fieldProbe(fieldPage, 'inert-field');
|
|
223
|
+
const inertFieldSubmit = await submitProbe(fieldPage, 'inert-field-submit');
|
|
224
|
+
const mixedField = await fieldProbe(fieldPage, 'mixed-field');
|
|
225
|
+
const mixedSubmit = await submitProbe(fieldPage, 'mixed-submit');
|
|
226
|
+
await fieldPage.close();
|
|
227
|
+
|
|
228
|
+
const multipleTriggerRuntime = await checkRuntime({
|
|
229
|
+
browser,
|
|
230
|
+
baseUrl: server.baseUrl,
|
|
231
|
+
relativeProtoPath: 'tests/fixtures/multiple-trigger-runtime',
|
|
232
|
+
protoDir: path.join(kitDir, 'tests', 'fixtures', 'multiple-trigger-runtime'),
|
|
233
|
+
});
|
|
234
|
+
const multipleTriggerProbe = multipleTriggerRuntime.pages.find((page) => page.file === 'probe-all.html')?.dropdown;
|
|
235
|
+
const multipleModalProbe = multipleTriggerRuntime.pages.find((page) => page.file === 'probe-all.html')?.modal;
|
|
236
|
+
|
|
237
|
+
// verify-runtime.mjs is invoked exactly as it runs against a real build:
|
|
238
|
+
// protoDir is a directory whose own siblings are the page HTML plus its own
|
|
239
|
+
// copy of the runtime, not a source fixture reached through prototype-kit's
|
|
240
|
+
// own tree. A real build.mjs run copyFileSync's runtime/proto.{js,css}
|
|
241
|
+
// alongside the converted pages (see build.mjs's own copyFileSync calls) and
|
|
242
|
+
// the pages reference them same-directory ("proto.js", not "../../../..."),
|
|
243
|
+
// so the scratch protoDir mirrors that shape rather than referencing the
|
|
244
|
+
// source tree by a relative path the server's root cannot reach.
|
|
136
245
|
const blockingScratch = fs.mkdtempSync(path.join(os.tmpdir(), 'prototype-blocking-runtime-'));
|
|
246
|
+
const blockingProtoDir = path.join(blockingScratch, 'blocking-runtime');
|
|
247
|
+
fs.cpSync(path.join(kitDir, 'tests', 'fixtures', 'blocking-runtime'), blockingProtoDir, { recursive: true });
|
|
248
|
+
fs.copyFileSync(path.join(kitDir, 'runtime', 'proto.js'), path.join(blockingProtoDir, 'proto.js'));
|
|
249
|
+
fs.copyFileSync(path.join(kitDir, 'runtime', 'proto.css'), path.join(blockingProtoDir, 'proto.css'));
|
|
137
250
|
const blockingAuditFile = path.join(blockingScratch, 'runtime-audit.json');
|
|
138
251
|
const blockingVerification = spawnSync(process.execPath, [
|
|
139
252
|
path.join(kitDir, 'verify-runtime.mjs'),
|
|
140
|
-
|
|
253
|
+
blockingProtoDir,
|
|
141
254
|
blockingAuditFile,
|
|
142
255
|
], { cwd: kitDir, encoding: 'utf8', timeout: 30_000 });
|
|
143
256
|
const blockingAudit = JSON.parse(fs.readFileSync(blockingAuditFile, 'utf8'));
|
|
@@ -145,7 +258,7 @@ fs.rmSync(blockingScratch, { recursive: true, force: true });
|
|
|
145
258
|
|
|
146
259
|
await browser.close();
|
|
147
260
|
await server.close();
|
|
148
|
-
const result = { doubleBinding, nestedBinding, dropdownGuard, hoverStacking, deepLinkBack, accessibleBack, pageBack, holdingComposition, shellNavigation, interactionReachability, blockingInteraction: { status: blockingVerification.status, failures: blockingAudit.interactionFailures } };
|
|
261
|
+
const result = { doubleBinding, nestedBinding, dropdownGuard, hoverStacking, deepLinkBack, accessibleBack, pageBack, holdingComposition, shellNavigation, interactionReachability, pointerCascade, disabledPointerControl, disabledOpened, presentationalControl, ariaDisabledUpperControl, ariaAncestorGuardControl, liveField, liveSubmit, inertField, inertFieldSubmit, mixedField, mixedSubmit, multipleTriggerProbe, multipleModalProbe, blockingInteraction: { status: blockingVerification.status, failures: blockingAudit.interactionFailures }, nestedInertWrap, nestedInertModalsOpened, nestedInertManifest: { inertActionCandidates: nestedInertManifest.inertActionCandidates } };
|
|
149
262
|
if (process.argv.includes('--assert')) {
|
|
150
263
|
assert.deepEqual(doubleBinding, { overlayRoots: 1, modals: 1 }, 'runtime must bind exactly once when injected twice');
|
|
151
264
|
assert.deepEqual(nestedBinding, { modals: 1, menus: 0 }, 'the innermost action must stop a parent binding from also firing');
|
|
@@ -193,10 +306,72 @@ if (process.argv.includes('--assert')) {
|
|
|
193
306
|
assert.equal(shellNavigation.activeNavigationFailures.length, 2, 'must fire: a visible marker under the wrong linked nav item is gated at both widths');
|
|
194
307
|
assert.ok(shellNavigation.activeNavigationFailures.every((failure) => failure.file === 'active-fire.html'), 'must stay silent: already-correct and unlinked source ink produce no active-navigation failure');
|
|
195
308
|
assert.equal(interactionReachability.occludedExactFailure?.ok, true, 'must fire: the exact Saved Journeys dropdown is scrolled clear of the fixed review framing before pointer hit-testing');
|
|
196
|
-
assert.ok(interactionReachability.occludedExactFailure?.point?.y < 700, 'must fire: the exact initially occluded target is measured outside the review panel');
|
|
309
|
+
assert.ok(interactionReachability.occludedExactFailure?.probes[0]?.point?.y < 700, 'must fire: the exact initially occluded target is measured outside the review panel');
|
|
197
310
|
assert.equal(interactionReachability.ordinaryControl?.ok, true, 'must stay silent: an ordinary already-visible dropdown remains pointer reachable');
|
|
198
311
|
assert.equal(interactionReachability.reviewBottomControl?.ok, true, 'must fire: a bottom-edge action receives temporary scroll clearance from fixed review chrome before hit-testing');
|
|
199
|
-
assert.ok(interactionReachability.reviewBottomControl?.point?.y < 800, 'must fire: the bottom-edge action is measured above the review panel');
|
|
312
|
+
assert.ok(interactionReachability.reviewBottomControl?.probes[0]?.point?.y < 800, 'must fire: the bottom-edge action is measured above the review panel');
|
|
313
|
+
assert.deepEqual(pointerCascade, [
|
|
314
|
+
{ id: 'inline-none', computed: 'auto', inline: 'auto', priority: 'important', runtimeBound: 'dropdown', opened: 1 },
|
|
315
|
+
{ id: 'rule-none', computed: 'auto', inline: 'auto', priority: 'important', runtimeBound: 'dropdown', opened: 1 },
|
|
316
|
+
{ id: 'rule-important', computed: 'auto', inline: 'auto', priority: 'important', runtimeBound: 'dropdown', opened: 1 },
|
|
317
|
+
], 'must fire: inline important in the real DOM wins over ordinary inline, exact-ID, and exact-ID important pointer suppression');
|
|
318
|
+
assert.deepEqual(disabledPointerControl, {
|
|
319
|
+
computed: 'none',
|
|
320
|
+
inline: 'none',
|
|
321
|
+
priority: 'important',
|
|
322
|
+
runtimeBound: null,
|
|
323
|
+
suppressed: 'source-disabled',
|
|
324
|
+
opened: 0,
|
|
325
|
+
}, 'must stay silent: a disabled source control retains its intentional pointer suppression');
|
|
326
|
+
assert.equal(disabledOpened, 0, 'must stay silent: the disabled source control never opens a modal');
|
|
327
|
+
assert.deepEqual(presentationalControl, {
|
|
328
|
+
computed: 'none',
|
|
329
|
+
inline: 'none',
|
|
330
|
+
priority: 'important',
|
|
331
|
+
runtimeBound: null,
|
|
332
|
+
suppressed: 'source-presentational-control',
|
|
333
|
+
opened: 0,
|
|
334
|
+
}, 'must stay silent: the converter\'s own presentational-control marker keeps its pointer suppression and never opens, even though it is otherwise a well-formed bound-looking action');
|
|
335
|
+
assert.deepEqual(ariaDisabledUpperControl, {
|
|
336
|
+
computed: 'none',
|
|
337
|
+
inline: 'none',
|
|
338
|
+
priority: 'important',
|
|
339
|
+
runtimeBound: null,
|
|
340
|
+
suppressed: 'source-aria-disabled',
|
|
341
|
+
opened: 0,
|
|
342
|
+
}, 'must stay silent: aria-disabled="TRUE" is honored the same as lowercase, not escaped by casing');
|
|
343
|
+
assert.deepEqual(ariaAncestorGuardControl, {
|
|
344
|
+
computed: 'auto',
|
|
345
|
+
inline: '',
|
|
346
|
+
priority: '',
|
|
347
|
+
runtimeBound: null,
|
|
348
|
+
suppressed: 'source-aria-disabled',
|
|
349
|
+
opened: 0,
|
|
350
|
+
}, 'must stay silent: an aria-disabled="TRUE" ancestor is not cancelled by this element\'s own aria-disabled="false" — a real physical click on it opens nothing');
|
|
351
|
+
assert.deepEqual(nestedInertManifest.inertActionCandidates, [
|
|
352
|
+
{ state: 'p0', id: 'viewport-1-a-search-submit-wrap', action: 'field-submit', reason: 'disabled' },
|
|
353
|
+
], 'must fire: a converter-shaped build recognizes the disabled native submit nested inside the promoted, non-disabled icon wrapper');
|
|
354
|
+
assert.equal(nestedInertWrap.runtimeBound, null, 'must stay silent: the promoted wrapper never binds when the button it wraps is disabled');
|
|
355
|
+
assert.equal(nestedInertModalsOpened, 0, 'must stay silent: a real physical click on the wrapper opens nothing');
|
|
356
|
+
assert.equal(liveField.editable, true, 'must fire: an ordinary field becomes editable');
|
|
357
|
+
assert.equal(liveSubmit.runtimeBound, 'field-submit', 'must fire: an ordinary field\'s submit button binds');
|
|
358
|
+
assert.equal(inertField.editable, false, 'must stay silent: a field marked presentational-control by the converter never becomes editable');
|
|
359
|
+
assert.equal(inertField.contentEditable, false, 'must stay silent: an inert field is never given contentEditable');
|
|
360
|
+
assert.equal(inertFieldSubmit.runtimeBound, null, 'must stay silent: the submit button for an inert field never binds either, even though it carries no inert marker of its own');
|
|
361
|
+
assert.equal(mixedField.editable, true, 'must fire: a live field still works even when its own submit button is disabled');
|
|
362
|
+
assert.equal(mixedSubmit.runtimeBound, null, 'must stay silent: a disabled submit button never binds, independent of its field');
|
|
363
|
+
assert.equal(interactionReachability.pointerCascade?.probed, 3, 'must fire: every valid cascade trigger is pointer-probed');
|
|
364
|
+
assert.equal(multipleTriggerProbe?.probed, 2, 'must fire: both triggers on one page are audited');
|
|
365
|
+
assert.deepEqual(multipleTriggerProbe?.probes.map(({ id, ok }) => ({ id, ok })), [
|
|
366
|
+
{ id: 'first-dropdown', ok: true },
|
|
367
|
+
{ id: 'blocked-second-dropdown', ok: false },
|
|
368
|
+
], 'must fire: a broken later trigger cannot hide behind a working first trigger');
|
|
369
|
+
assert.equal(multipleModalProbe?.probed, 2, 'must fire: both modal triggers on one page are audited');
|
|
370
|
+
assert.deepEqual(multipleModalProbe?.probes.map(({ id, ok }) => ({ id, ok })), [
|
|
371
|
+
{ id: 'first-modal', ok: true },
|
|
372
|
+
{ id: 'blocked-second-modal', ok: false },
|
|
373
|
+
], 'must fire: modal probing covers every trigger, including a broken later one');
|
|
374
|
+
assert.deepEqual(multipleTriggerRuntime.interactionFailures, ['probe-all.html'], 'must fire: the page-level gate retains the every-trigger failure');
|
|
200
375
|
assert.notEqual(blockingVerification.status, 0, 'must fire: a claimed dropdown with broken pointer wiring still refuses publication');
|
|
201
376
|
assert.match(blockingVerification.stderr, /Prototype runtime gate failed: 1 page\(s\) with broken dropdown\/modal interaction/, 'must fire: the refusal names the genuinely blocking interaction gate');
|
|
202
377
|
assert.deepEqual(blockingAudit.interactionFailures, ['broken-interaction.html'], 'must stay silent: the blocking fixture identifies only the broken page');
|
|
@@ -4,4 +4,5 @@ html,body{margin:0;width:1536px;height:1024px}.control{position:absolute;left:12
|
|
|
4
4
|
<button id="broken-dropdown" class="control" data-proto-action="dropdown" data-proto-options='["One","Two","Three"]'>Menu</button>
|
|
5
5
|
<div class="interceptor" aria-hidden="true"></div>
|
|
6
6
|
<script id="proto-state" type="application/json">{"unit":"broken-interaction","tokens":{},"contentFile":null}</script>
|
|
7
|
+
<script src="proto.js"></script>
|
|
7
8
|
</body></html>
|
|
@@ -6,3 +6,8 @@ framing collision. It must become reachable only after the runtime gate centers
|
|
|
6
6
|
the target before hit-testing.
|
|
7
7
|
|
|
8
8
|
`ordinary-dropdown.html` is the paired must-stay-reachable control.
|
|
9
|
+
|
|
10
|
+
`pointer-cascade.html` is the CSS cascade boundary: ordinary inline `none`, an
|
|
11
|
+
exact-ID ordinary rule, and an exact-ID important rule must all become bound in
|
|
12
|
+
the real browser. Its disabled native button is the paired inert control and
|
|
13
|
+
must retain its source `none!important` declaration.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<!doctype html><html><head><meta charset="utf-8"><link rel="stylesheet" href="../../../runtime/proto.css"><style>
|
|
2
|
+
html,body{margin:0}.page{padding:80px;display:flex;flex-direction:column;gap:28px}
|
|
3
|
+
.field{height:44px;width:260px;border:1px solid #ccc}
|
|
4
|
+
button{height:44px}
|
|
5
|
+
</style></head><body>
|
|
6
|
+
<main class="page">
|
|
7
|
+
<!-- control: an ordinary field + submit button, both must work -->
|
|
8
|
+
<div id="live-field" class="field text" data-proto-field="live-field-text"><span id="live-field-text">Search</span></div>
|
|
9
|
+
<button id="live-submit" data-proto-submit-for="live-field-text">Go</button>
|
|
10
|
+
|
|
11
|
+
<!-- the field itself is a converter-marked presentational control: must
|
|
12
|
+
never become editable, and its own submit button must never bind -->
|
|
13
|
+
<div id="inert-field" data-12ui-presentational-control class="field text" data-proto-field="inert-field-text"><span id="inert-field-text">Search</span></div>
|
|
14
|
+
<button id="inert-field-submit" data-proto-submit-for="inert-field-text">Go</button>
|
|
15
|
+
|
|
16
|
+
<!-- the field is live, but its submit button is disabled: the field must
|
|
17
|
+
still work via Enter, the button must never bind -->
|
|
18
|
+
<div id="mixed-field" class="field text" data-proto-field="mixed-field-text"><span id="mixed-field-text">Search</span></div>
|
|
19
|
+
<button id="mixed-submit" disabled data-proto-submit-for="mixed-field-text">Go</button>
|
|
20
|
+
</main>
|
|
21
|
+
<script id="proto-state" type="application/json">{"unit":"inert-field","tokens":{},"contentFile":null}</script>
|
|
22
|
+
<script src="../../../runtime/proto.js"></script>
|
|
23
|
+
</body></html>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<!doctype html><html><head><meta charset="utf-8"><link rel="stylesheet" href="../../../runtime/proto.css"><style>
|
|
2
|
+
html,body{margin:0}.page{padding:80px;display:grid;grid-template-columns:repeat(2,220px);gap:28px}button{height:44px}
|
|
3
|
+
#rule-none{pointer-events:none}
|
|
4
|
+
#rule-important{pointer-events:none!important}
|
|
5
|
+
</style></head><body>
|
|
6
|
+
<main class="page">
|
|
7
|
+
<button id="inline-none" style="pointer-events:none" data-proto-action="dropdown" data-proto-options='["One","Two","Three"]'>Inline none</button>
|
|
8
|
+
<button id="rule-none" data-proto-action="dropdown" data-proto-options='["One","Two","Three"]'>Rule none</button>
|
|
9
|
+
<button id="rule-important" data-proto-action="dropdown" data-proto-options='["One","Two","Three"]'>Important rule none</button>
|
|
10
|
+
<button id="disabled-control" disabled style="pointer-events:none!important" data-proto-action="modal" data-proto-modal='{"title":"Must stay inert"}'>Disabled control</button>
|
|
11
|
+
<button id="presentational-control" data-12ui-presentational-control style="pointer-events:none!important" data-proto-action="modal" data-proto-modal='{"title":"Must stay inert"}'>Converter-marked static</button>
|
|
12
|
+
<button id="aria-disabled-upper" aria-disabled="TRUE" style="pointer-events:none!important" data-proto-action="modal" data-proto-modal='{"title":"Must stay inert"}'>Uppercase aria-disabled</button>
|
|
13
|
+
<div aria-disabled="TRUE">
|
|
14
|
+
<button id="aria-ancestor-guard" aria-disabled="false" data-proto-action="modal" data-proto-modal='{"title":"Must stay inert"}'>Ancestor TRUE, self false</button>
|
|
15
|
+
</div>
|
|
16
|
+
</main>
|
|
17
|
+
<script id="proto-state" type="application/json">{"unit":"pointer-cascade","tokens":{},"contentFile":null}</script>
|
|
18
|
+
<script src="../../../runtime/proto.js"></script>
|
|
19
|
+
</body></html>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<!doctype html><html><head><meta charset="utf-8"><link rel="stylesheet" href="../../../runtime/proto.css"><style>
|
|
2
|
+
html,body{margin:0;width:1536px;height:1024px}.page{position:relative;width:1536px;height:1024px}.control{position:absolute;left:120px;width:220px;height:44px}.modal{left:480px}.first{top:120px}.second{top:220px}.interceptor{position:absolute;top:220px;width:220px;height:44px;z-index:2;background:rgba(255,255,255,.01)}.dropdown-interceptor{left:120px}.modal-interceptor{left:480px}
|
|
3
|
+
</style></head><body>
|
|
4
|
+
<main class="page">
|
|
5
|
+
<button id="first-dropdown" class="control first" data-proto-action="dropdown" data-proto-options='["One","Two","Three"]'>First menu</button>
|
|
6
|
+
<button id="blocked-second-dropdown" class="control second" data-proto-action="dropdown" data-proto-options='["One","Two","Three"]'>Second menu</button>
|
|
7
|
+
<button id="first-modal" class="control modal first" data-proto-action="modal" data-proto-modal='{"title":"First dialog"}'>First dialog</button>
|
|
8
|
+
<button id="blocked-second-modal" class="control modal second" data-proto-action="modal" data-proto-modal='{"title":"Second dialog"}'>Second dialog</button>
|
|
9
|
+
<div id="second-dropdown-interceptor" class="interceptor dropdown-interceptor" aria-hidden="true"></div>
|
|
10
|
+
<div id="second-modal-interceptor" class="interceptor modal-interceptor" aria-hidden="true"></div>
|
|
11
|
+
</main>
|
|
12
|
+
<script id="proto-state" type="application/json">{"unit":"probe-all","tokens":{},"contentFile":null}</script>
|
|
13
|
+
<script src="../../../runtime/proto.js"></script>
|
|
14
|
+
</body></html>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html><head><meta charset="utf-8"><style>
|
|
3
|
+
html,body,#page-flow,#viewport-1-a{margin:0;width:1536px;height:1024px;font-family:Arial,sans-serif}
|
|
4
|
+
.text{position:absolute}
|
|
5
|
+
#viewport-1-a-row{position:absolute;left:80px;top:120px;width:420px;height:44px;display:flex;align-items:center}
|
|
6
|
+
#viewport-1-a-search-field{position:relative;width:320px;height:44px;background:#fff;border:1px solid #ddd}
|
|
7
|
+
#viewport-1-a-search-submit-wrap{position:relative;width:44px;height:44px;display:flex;align-items:center;justify-content:center}
|
|
8
|
+
</style></head><body><main id="page-flow"><section id="viewport-1-a">
|
|
9
|
+
<div id="viewport-1-a-row">
|
|
10
|
+
<div id="viewport-1-a-search-field"><span id="viewport-1-a-search-field-text" class="text">Search</span></div>
|
|
11
|
+
<!-- the native submit button is disabled, but it is wrapped: build.mjs promotes
|
|
12
|
+
binding to the wrapper because the icon sits beside, not inside, the button.
|
|
13
|
+
Regression: an earlier fix checked only the promoted wrapper for source-inert
|
|
14
|
+
state and missed the disabled attribute sitting on the button it wraps. -->
|
|
15
|
+
<div id="viewport-1-a-search-submit-wrap"><button id="viewport-1-a-search-submit" disabled></button></div>
|
|
16
|
+
</div>
|
|
17
|
+
</section></main></body></html>
|
|
@@ -60,7 +60,12 @@ const outputDigest = (directory) => {
|
|
|
60
60
|
};
|
|
61
61
|
assert.equal(
|
|
62
62
|
outputDigest(threeShell.protoDir),
|
|
63
|
-
|
|
63
|
+
// Updated a third time: the round-2 review found the aria-disabled
|
|
64
|
+
// ancestry walk (an ancestor's "true" is not cancelled by a nearer
|
|
65
|
+
// descendant's "false") and the submit-wrapper promotion check (must
|
|
66
|
+
// check the native control, not just the promoted wrapper). Only
|
|
67
|
+
// proto.js differs, verified by diffing before/after build output.
|
|
68
|
+
'8d3ea96e4c645100dea36d74804d3a9f4411a45a529de59d1b5fb9051d0c6169',
|
|
64
69
|
'must stay silent: review support leaves the accepted shell/navigation complete output byte-identical',
|
|
65
70
|
);
|
|
66
71
|
const threeShellModel = JSON.parse(fs.readFileSync(threeShell.modelFile, 'utf8'));
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import { parseHTML } from 'linkedom';
|
|
4
4
|
import { resolveDropdownAction, suppressDeterministicAction, usableDropdownOptions } from '../lib/actions.mjs';
|
|
5
|
-
import {
|
|
5
|
+
import { selectUniqueActionOwners, sourceInertActionReason } from '../lib/action-target.mjs';
|
|
6
6
|
import { checkAlignRule } from './align-rule-check.mjs';
|
|
7
7
|
import { declaredActiveMarkers, linkedNavForUnit, markerOverlapsNav } from '../lib/nav-active-state.mjs';
|
|
8
8
|
import { matchPlannedPage } from '../lib/navigation.mjs';
|
|
@@ -62,14 +62,33 @@ assert.equal(
|
|
|
62
62
|
'must fire: a deterministic dropdown with no usable label options is recorded and kept static',
|
|
63
63
|
);
|
|
64
64
|
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
65
|
+
const sourceInertDocument = parseHTML([
|
|
66
|
+
'<div inert><button id="inert-child"></button></div>',
|
|
67
|
+
'<button id="disabled" disabled></button>',
|
|
68
|
+
'<button id="live"></button>',
|
|
69
|
+
'<div data-12ui-presentational-control><button id="presentational-child"></button></div>',
|
|
70
|
+
'<button id="aria-disabled-lower" aria-disabled="true"></button>',
|
|
71
|
+
'<button id="aria-disabled-upper" aria-disabled="TRUE"></button>',
|
|
72
|
+
'<button id="aria-disabled-false" aria-disabled="false"></button>',
|
|
73
|
+
'<div aria-disabled="TRUE"><button id="aria-ancestor-true-child-false" aria-disabled="false"></button></div>',
|
|
74
|
+
'<div id="wrap-with-disabled-child" class="wrap"><button id="disabled-child" disabled></button></div>',
|
|
75
|
+
].join('')).document;
|
|
76
|
+
assert.equal(sourceInertActionReason(sourceInertDocument.getElementById('inert-child')), 'inert', 'must fire: an inert ancestor rejects a model-selected action');
|
|
77
|
+
assert.equal(sourceInertActionReason(sourceInertDocument.getElementById('disabled')), 'disabled', 'must fire: a disabled native control remains intentionally inert');
|
|
78
|
+
assert.equal(sourceInertActionReason(sourceInertDocument.getElementById('live')), null, 'must stay silent: an ordinary resolved control remains eligible for runtime binding');
|
|
79
|
+
assert.equal(sourceInertActionReason(sourceInertDocument.getElementById('presentational-child')), 'presentational-control', 'must fire: the converter\'s own presentational-control marker rejects a model-selected action');
|
|
80
|
+
assert.equal(sourceInertActionReason(sourceInertDocument.getElementById('aria-disabled-lower')), 'aria-disabled', 'must fire: aria-disabled="true" rejects a model-selected action');
|
|
81
|
+
assert.equal(sourceInertActionReason(sourceInertDocument.getElementById('aria-disabled-upper')), 'aria-disabled', 'must fire: aria-disabled="TRUE" is the same boolean token as lowercase and must not escape the boundary on case alone');
|
|
82
|
+
assert.equal(sourceInertActionReason(sourceInertDocument.getElementById('aria-disabled-false')), null, 'must stay silent: aria-disabled="false" is not a disabled state');
|
|
83
|
+
// WAI-ARIA 1.2: aria-disabled="true" on an ancestor disables all focusable
|
|
84
|
+
// descendants. A nearer descendant's own aria-disabled="false" must not
|
|
85
|
+
// cancel it -- checking only the closest aria-disabled attribute finds the
|
|
86
|
+
// "false" and misses the "true" further up.
|
|
87
|
+
assert.equal(sourceInertActionReason(sourceInertDocument.getElementById('aria-ancestor-true-child-false')), 'aria-disabled', 'must fire: an aria-disabled="TRUE" ancestor is not cancelled by a nearer descendant\'s aria-disabled="false"');
|
|
88
|
+
// A promoted wrapper's own check must not be the only check: sourceInertActionReason
|
|
89
|
+
// called on the wrapped native control (not the wrapper) must still see the wrapper's
|
|
90
|
+
// own inert state via its ancestry walk.
|
|
91
|
+
assert.equal(sourceInertActionReason(sourceInertDocument.getElementById('disabled-child')), 'disabled', 'must fire: a disabled control nested inside a non-disabled wrapper is still found by checking the control itself, which is what a submit-wrapper promotion must do rather than checking only the promoted wrapper');
|
|
73
92
|
|
|
74
93
|
const repeatedDropdown = { action: 'dropdown', options: ['All opportunities', 'High intent', 'Low intent'] };
|
|
75
94
|
const distinctDropdown = { action: 'dropdown', options: ['All countries', 'Australia', 'Canada'] };
|
|
@@ -6,8 +6,9 @@ import { chromium } from '@playwright/test';
|
|
|
6
6
|
import { checkRuntime } from './lib/runtime-check.mjs';
|
|
7
7
|
import { startStaticServer } from './lib/static-server.mjs';
|
|
8
8
|
|
|
9
|
-
const [protoDirArg, auditFileArg] = process.argv.slice(2);
|
|
9
|
+
const [protoDirArg, auditFileArg, mode] = process.argv.slice(2);
|
|
10
10
|
if (!protoDirArg || !auditFileArg) throw new Error('usage: verify-runtime.mjs <prototype-dir> <audit.json>');
|
|
11
|
+
if (mode && mode !== '--audit-only') throw new Error(`unknown runtime verification mode: ${mode}`);
|
|
11
12
|
const protoDir = path.resolve(protoDirArg);
|
|
12
13
|
const auditFile = path.resolve(auditFileArg);
|
|
13
14
|
const server = await startStaticServer(path.dirname(protoDir));
|
|
@@ -24,7 +25,6 @@ try {
|
|
|
24
25
|
await browser.close();
|
|
25
26
|
await server.close();
|
|
26
27
|
}
|
|
27
|
-
fs.writeFileSync(auditFile, JSON.stringify(audit, null, 2) + '\n');
|
|
28
28
|
const unsafe = [];
|
|
29
29
|
if (audit.pageErrors > 0) unsafe.push(`${audit.pageErrors} page/load error(s)`);
|
|
30
30
|
if (audit.consoleErrors > 0) unsafe.push(`${audit.consoleErrors} console error(s)`);
|
|
@@ -35,6 +35,11 @@ if (audit.compositionFailures.length > 0) unsafe.push(`${audit.compositionFailur
|
|
|
35
35
|
if (audit.shellConsistencyFailures.length > 0) unsafe.push(`${audit.shellConsistencyFailures.length} canonical shell consistency failure(s)`);
|
|
36
36
|
if (audit.activeNavigationFailures.length > 0) unsafe.push(`${audit.activeNavigationFailures.length} active navigation failure(s)`);
|
|
37
37
|
if (audit.deepLinkBack.present && !audit.deepLinkBack.ok) unsafe.push('deep-link history did not return to its source');
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
fs.writeFileSync(auditFile, JSON.stringify({ ...audit, failures: unsafe }, null, 2) + '\n');
|
|
39
|
+
if (unsafe.length > 0 && mode !== '--audit-only') throw new Error(`Prototype runtime gate failed: ${unsafe.join('; ')}`);
|
|
40
|
+
if (unsafe.length > 0) {
|
|
41
|
+
console.error(`runtime audit found blockers: ${unsafe.join('; ')}`);
|
|
42
|
+
} else {
|
|
43
|
+
const viewportWidths = [...new Set(audit.pages.flatMap((page) => page.viewports.map((viewport) => viewport.width)))].sort((left, right) => left - right);
|
|
44
|
+
console.error(`runtime gate passed: ${audit.pages.length} pages at ${viewportWidths.join('/')}, ${audit.brokenTargets.length} broken targets, ${audit.interactionFailures.length} interaction failures, ${audit.shellConsistencyFailures.length} shell failures, ${audit.activeNavigationFailures.length} active navigation failures`);
|
|
45
|
+
}
|
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
<!doctype html><meta charset="utf-8"><style>*{margin:0;padding:0;box-sizing:border-box}body{font-family:system-ui}.generated-screen{position:relative}#page-flow{position:relative;width:1536px;height:1400px}section{position:absolute;left:0;width:1536px;overflow:visible}.text{position:absolute;white-space:nowrap}</style>
|
|
2
|
-
<div class="generated-screen"><div class="ly-anchor-canvas" style="position:relative;height:1400px"><div id="page-flow">
|
|
3
|
-
<section id="viewport-1-a" style="top:0;height:700px;background:#fbf6ee"><p id="viewport-1-a-text" class="text" style="left:300px;top:660px;font-size:16px">The market ends here.</p><div id="viewport-1-a-fruit" class="decoration" style="position:absolute;left:0;bottom:0;width:180px;height:150px;background:#df8b22;border-radius:100% 100% 0 0"></div></section>
|
|
4
|
-
<section id="viewport-2-a" style="top:700px;height:700px;background:#fbf6ee"><p id="viewport-2-a-text" class="text" style="left:300px;top:20px;font-size:16px">The next section starts here.</p></section>
|
|
5
|
-
</div></div></div>
|
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
<!doctype html><meta charset="utf-8"><style>*{margin:0;padding:0;box-sizing:border-box}body{font-family:system-ui}.generated-screen{position:relative}#page-flow{position:relative;width:1536px;height:1400px}section{position:absolute;left:0;width:1536px;overflow:visible}.text{position:absolute;white-space:nowrap}</style>
|
|
2
|
-
<div class="generated-screen"><div class="ly-anchor-canvas" style="position:relative;height:1400px"><div id="page-flow">
|
|
3
|
-
<section id="viewport-1-a" style="top:0;height:700px;background:#fbf6ee"><p id="viewport-1-a-text" class="text" style="left:300px;top:620px;font-size:16px">The market ends here.</p><div id="viewport-1-a-product-image" class="image" style="position:absolute;left:0;bottom:0;width:180px;height:40px;background:#925b24"></div></section>
|
|
4
|
-
<section id="viewport-2-a" style="top:700px;height:700px;background:#fbf6ee"><p id="viewport-2-a-text" class="text" style="left:300px;top:20px;font-size:16px">The next section starts here.</p></section>
|
|
5
|
-
</div></div></div>
|
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
<!doctype html><meta charset="utf-8"><style>*{margin:0;padding:0;box-sizing:border-box}body{font-family:system-ui}.generated-screen{position:relative}#page-flow{position:relative;width:1536px;height:1400px}section{position:absolute;left:0;width:1536px;overflow:visible}.text{position:absolute;white-space:nowrap}</style>
|
|
2
|
-
<div class="generated-screen"><div class="ly-anchor-canvas" style="position:relative;height:1400px"><div id="page-flow">
|
|
3
|
-
<section id="viewport-1-a" style="top:0;height:700px;background:#fefefe"><div id="viewport-1-a-card" style="position:absolute;left:500px;top:500px;width:536px;height:200px;background:#fff;box-shadow:0 0 16px rgba(0,0,0,.05)"><p id="viewport-1-a-text" class="text" style="left:40px;top:80px">A card casts a soft shadow.</p></div></section>
|
|
4
|
-
<section id="viewport-2-a" style="top:700px;height:700px;background:#fefefe"><p id="viewport-2-a-text" class="text" style="left:80px;top:20px">The next section.</p></section>
|
|
5
|
-
</div></div></div>
|
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
<!doctype html><meta charset="utf-8"><style>*{margin:0;padding:0;box-sizing:border-box}body{font-family:system-ui}.generated-screen{position:relative}#page-flow{position:relative;width:1536px;height:1400px}section{position:absolute;left:0;width:1536px;overflow:visible}.text{position:absolute;white-space:nowrap}</style>
|
|
2
|
-
<div class="generated-screen"><div class="ly-anchor-canvas" style="position:relative;height:1400px"><div id="page-flow">
|
|
3
|
-
<section id="viewport-1-a" style="top:0;height:700px;background:#fefefe"><div id="viewport-1-a-card" style="position:absolute;left:500px;top:500px;width:536px;height:200px;background:#fff;box-shadow:0 8px 0 #c83b3b"><p id="viewport-1-a-text" class="text" style="left:40px;top:80px">A hard coloured band is not a shadow gradient.</p></div></section>
|
|
4
|
-
<section id="viewport-2-a" style="top:700px;height:700px;background:#fefefe"><p id="viewport-2-a-text" class="text" style="left:80px;top:20px">The next section.</p></section>
|
|
5
|
-
</div></div></div>
|