The page workflows started one global webServer and ran with one worker. Each worker now owns its own tests/browser/server.py on an OS-chosen port, with its own temp root and database, through a shared `test` in tests/browser/fixtures.mjs. baseURL, and so page, request and browser.newContext, point at that worker's server. A server that dies fails the running test with its stderr tail and the next test gets a fresh one. The worker count comes from PM_TEST_WORKERS, else 1 under CI, else a quarter of the CPUs up to 8. Chromium workflows in the pinned image: 510 tests in 606 s with one worker on main, 511 (one new isolation spec) in 119 s with eight.
641 lines
51 KiB
JavaScript
641 lines
51 KiB
JavaScript
// Shared concept-vs-production inventory for the mock-true rebuild (#249, spec §7). One in-page measurer runs the
|
||
// same concept selectors in the immutable concept (/__test__/canonical/workspace) and in production, which now renders
|
||
// the concept's classes. The concept is never rewritten: each page's map<Page>Concept(reference) applies only the
|
||
// recorded spec-§8 deviations, each commented with its D-number, and diffInventory must then report nothing.
|
||
import {expect} from './fixtures.mjs';
|
||
export {diffInventory} from './library-editor-canonical-comparison.mjs';
|
||
|
||
export const CONCEPT_URL='/__test__/canonical/workspace';
|
||
export const PRODUCTION_URL='/prompt_manager/workspace';
|
||
export const SIZES=Object.freeze([[1600,1100],[390,844]]);
|
||
export const THEMES=Object.freeze(['dark','light']);
|
||
export const MOTIONS=Object.freeze(['no-preference','reduce']);
|
||
|
||
/** D35: production uses the singular for exactly one; the concept's plurals are hard-coded (app.js 447, 489, 517-521). */
|
||
export const countCopy=text=>text.replace(/(^|\D)1 positive prompts\b/g,'$11 positive prompt')
|
||
.replace(/(^|\D)1 more images\b/g,'$11 more image').replace(/(^|\D)1 images\b/g,'$11 image');
|
||
|
||
/**
|
||
* The row's flex lines laid out again from the concept's own item boxes, in order: an item breaks to a new line when its
|
||
* hypothetical size (`flex-basis: 100%` → the row; a growing item → its min-width; otherwise its box) no longer fits
|
||
* after the line's items and gaps; a growing item (the search field, `flex: 1`) takes its line's free space; items pack
|
||
* from the start and centre in their line (`align-items: center`). The test proves this model on the concept rows first.
|
||
*/
|
||
export function reflow(row,items) {
|
||
const gap=parseFloat(row.columnGap)||0,rowGap=parseFloat(row.rowGap)||0;
|
||
// The unrounded boxes (`exact`); rounding happens once, on the result.
|
||
const rowWidth=row.exact?.width??row.width;
|
||
const exact=item=>item.exact??{left:item.left,width:item.width};
|
||
const grows=item=>parseFloat(item.flexGrow)>0;
|
||
const basis=item=>item.flexBasis==='100%'?rowWidth:grows(item)?parseFloat(item.minWidth)||0:exact(item).width;
|
||
const lines=[];
|
||
const order=row.items.filter(entry=>entry.shown);
|
||
for(const item of items.filter(entry=>entry.shown)) {
|
||
const line=lines.at(-1),used=line?line.reduce((sum,entry)=>sum+basis(entry)+gap,0):0;
|
||
// The slack is one layout unit (1/60px in Firefox), which float sums of the browser's boxes may exceed.
|
||
if(line&&used+basis(item)<=rowWidth+1/60)line.push(item);else lines.push([item]);
|
||
}
|
||
const placed=new Map();let top=0;
|
||
const sameLine=(a,b)=>a.top<b.top+b.height&&b.top<a.top+a.height;
|
||
const freeOf=line=>rowWidth-line.reduce((sum,item)=>sum+basis(item),0)-gap*(line.length-1);
|
||
for(const line of lines) {
|
||
const free=freeOf(line);
|
||
const growing=line.filter(grows);
|
||
const height=Math.max(...line.map(item=>item.height));
|
||
let x=0,previous=null;
|
||
for(const item of line) {
|
||
// A growing item keeps its concept width plus the space its line gained.
|
||
const width=growing.includes(item)?exact(item).width+(free-freeOf(order.filter(entry=>sameLine(entry,item))))/growing.length:exact(item).width;
|
||
// An item that followed the same neighbour on the same line in the concept keeps the concept's own spacing,
|
||
// moved by however much that neighbour grew; otherwise it follows the previous box and the gap.
|
||
const adjacent=previous&&order.indexOf(item)===order.indexOf(previous)+1&&sameLine(item,previous);
|
||
const before=previous&&placed.get(previous);
|
||
const left=line.indexOf(item)===0?0:adjacent?before.left+exact(item).left-exact(previous).left+before.width-exact(previous).width:x;
|
||
placed.set(item,{left,width,top:Math.round(top+(height-item.height)/2)});
|
||
x=left+width+gap;previous=item;
|
||
}
|
||
top+=height+rowGap;
|
||
}
|
||
const moved=item=>{
|
||
const box=placed.get(item);
|
||
if(!box)return item;
|
||
const grown=box.width-exact(item).width;
|
||
return {...item,left:Math.round(box.left),top:box.top,width:Math.round(box.width),
|
||
input:item.input&&{...item.input,width:Math.round((item.input.exact?.width??item.input.width)+grown)}};
|
||
};
|
||
return {...row,height:Math.round(top-rowGap),items:items.map(moved)};
|
||
}
|
||
|
||
/** A flex row without the items `isRemoved` picks; the remaining items flow again from the concept's boxes. */
|
||
export function withoutItems(row,isRemoved) {
|
||
return reflow(row,row.items.filter(item=>!isRemoved(item)));
|
||
}
|
||
|
||
/** Seed the concept-shaped real fixture (tests/browser/workspace_concept_fixture.py) and return its manifest. */
|
||
export async function seedConcept(request,data={}) {
|
||
const response=await request.post('/__test__/concept/reset',{data});
|
||
await expect(response).toBeOK();
|
||
return response.json();
|
||
}
|
||
|
||
async function selectTheme(page,theme) {
|
||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||
}
|
||
|
||
/** The concept at `#<route>` with its own Theme select. */
|
||
export async function openConcept(page,route,theme,motion='no-preference') {
|
||
await page.emulateMedia({reducedMotion:motion});
|
||
await page.goto('about:blank');await page.goto(`${CONCEPT_URL}#${route}`);
|
||
await selectTheme(page,theme);
|
||
await expect(page.locator('#main #page-title')).toBeVisible();
|
||
}
|
||
|
||
/** Production at `#/<route>` with the D4 Theme select, once the sidebar's real counts (D27) have settled. */
|
||
export async function openProduction(page,route,theme,motion='no-preference') {
|
||
await page.emulateMedia({reducedMotion:motion});
|
||
await page.goto('about:blank');await page.goto(`${PRODUCTION_URL}#/${route}`);
|
||
await selectTheme(page,theme);
|
||
await expect(page.locator('#workspace-main #page-title')).toBeVisible();
|
||
await expect(page.locator('#primary-nav .nav-count')).toHaveCount(3);
|
||
// SAVED VIEWS' Favorites count (D51), the concept's starred images in the concept fixture.
|
||
await expect(page.locator('#saved-nav a[data-route="favorites"] .nav-count')).toHaveCount(1);
|
||
}
|
||
|
||
/** Computed outline of the focused element, after keyboard focus so `:focus-visible` applies. */
|
||
export async function focusRingOf(locator) {
|
||
return locator.evaluate(node=>{const style=getComputedStyle(node);return {focused:document.activeElement===node,
|
||
visible:node.matches(':focus-visible'),outlineStyle:style.outlineStyle,outlineWidth:style.outlineWidth,
|
||
outlineColor:style.outlineColor,outlineOffset:style.outlineOffset};});
|
||
}
|
||
|
||
/** Computed colors of an element while the pointer rests on it; the pointer is parked again afterwards. */
|
||
export async function hoverStateOf(page,locator) {
|
||
await locator.hover();
|
||
const state=await locator.evaluate(node=>{const style=getComputedStyle(node);return {hovered:node.matches(':hover'),
|
||
color:style.color,backgroundColor:style.backgroundColor,borderTopColor:style.borderTopColor,
|
||
textDecorationLine:style.textDecorationLine,boxShadow:style.boxShadow};});
|
||
await page.mouse.move(0,0);
|
||
return state;
|
||
}
|
||
|
||
/**
|
||
* Mutate the open page so a comparison can be shown to notice a change (the A7 mutation check): `css` is appended as
|
||
* the last stylesheet, so it wins over equally specific concept rules, and every element matching a `remove` selector
|
||
* is detached. Returns an async undo that restores the stylesheet and the removed elements in place.
|
||
*/
|
||
export async function mutate(page,{css=null,remove=[]}={}) {
|
||
const token=await page.evaluate(({css,remove})=>{
|
||
const token=`mutation-${Math.random().toString(36).slice(2)}`;
|
||
const undo=[];
|
||
if(css) {
|
||
const style=document.createElement('style');style.dataset.mutation=token;style.textContent=css;
|
||
document.head.append(style);undo.push(()=>style.remove());
|
||
}
|
||
for(const selector of remove) {
|
||
const nodes=[...document.querySelectorAll(selector)];
|
||
if(!nodes.length)throw new Error(`mutate: nothing matches ${selector}`);
|
||
for(const node of nodes) {
|
||
const parent=node.parentNode,next=node.nextSibling;
|
||
node.remove();undo.push(()=>parent.insertBefore(node,next));
|
||
}
|
||
}
|
||
(window.__conceptMutations??=new Map()).set(token,undo);
|
||
return token;
|
||
},{css,remove});
|
||
return ()=>page.evaluate(token=>{
|
||
const undo=window.__conceptMutations?.get(token);
|
||
if(!undo)throw new Error('mutate: this mutation was already undone or the page reloaded');
|
||
undo.reverse().forEach(step=>step());window.__conceptMutations.delete(token);
|
||
},token);
|
||
}
|
||
|
||
/**
|
||
* Inventory sections: {chrome, heading, toolbar, filterRow, bulkbar, caption, grid, cards, continuation, panel, library,
|
||
* history, noPageScroll}. A section is null when its element is absent. The Images sections (toolbar … panel, #249 C7)
|
||
* measure the concept's Images markup; `cards` holds the first six; `bulkbar` is the selection bar's place and, while
|
||
* anything is checked, its `.bulkbar` row. `library` (#249 E3) holds the Prompt library's toolbar,
|
||
* caption, grid, first six cards and whatever follows the grid. `history` (#249 F5) holds the Prompt history's summary,
|
||
* negative variants panel, comparison (while Compare negatives is pressed) and the shared gallery's heading.
|
||
*/
|
||
export function inventoryOf(page,scope='main') {
|
||
return page.evaluate(measureInventory,scope);
|
||
}
|
||
|
||
/** The inventory without its `exact` (unrounded) helper values, which only a re-layout reads; compare after this. */
|
||
export function stripExact(value) {
|
||
if(Array.isArray(value))return value.map(stripExact);
|
||
if(value&&typeof value==='object')return Object.fromEntries(Object.entries(value).filter(([key])=>key!=='exact').map(([key,item])=>[key,stripExact(item)]));
|
||
return value;
|
||
}
|
||
|
||
/** Runs in the page; self-contained. `scope` selects the page's main element. */
|
||
export function measureInventory(scope) {
|
||
const px=value=>Math.round(value);
|
||
const css=(node,...keys)=>{const style=getComputedStyle(node);return Object.fromEntries(keys.map(key=>[key,style[key]]));};
|
||
const shown=node=>Boolean(node)&&node.getClientRects().length>0&&getComputedStyle(node).visibility!=='hidden';
|
||
const rect=node=>node.getBoundingClientRect();
|
||
const font=node=>css(node,'fontFamily','fontSize','fontWeight','lineHeight','letterSpacing','color','textTransform');
|
||
const edges=node=>css(node,'paddingTop','paddingRight','paddingBottom','paddingLeft','marginTop','marginRight','marginBottom','marginLeft',
|
||
'borderTopWidth','borderTopStyle','borderTopColor','borderRightWidth','borderRightStyle','borderRightColor',
|
||
'borderBottomWidth','borderBottomStyle','borderBottomColor','borderTopLeftRadius','backgroundColor');
|
||
const layout=node=>css(node,'display','flexDirection','flexWrap','justifyContent','alignItems','columnGap','rowGap','position');
|
||
// Text of a node without its descendants matching `skip` (counts, nested controls), whitespace collapsed.
|
||
const textOf=(node,skip)=>{const copy=node.cloneNode(true);if(skip)copy.querySelectorAll(skip).forEach(item=>item.remove());
|
||
return copy.textContent.replace(/\s+/g,' ').trim();};
|
||
const relative=(node,origin)=>{const a=rect(node),b=rect(origin);return {left:px(a.left-b.left),top:px(a.top-b.top),width:px(a.width),height:px(a.height)};};
|
||
const main=document.querySelector(scope);
|
||
const sidebar=document.querySelector('aside.sidebar');
|
||
const workspace=document.querySelector('.workspace');
|
||
|
||
function iconOf(node) {
|
||
const svg=node.querySelector(':scope > svg.icon');
|
||
if(!svg)return null;
|
||
const box=rect(svg);
|
||
return {width:px(box.width),height:px(box.height),viewBox:svg.getAttribute('viewBox'),hidden:svg.getAttribute('aria-hidden'),
|
||
d:svg.querySelector('path')?.getAttribute('d')??null,...css(svg,'strokeWidth','color','verticalAlign','flexShrink')};
|
||
}
|
||
function navRow(link,index,links) {
|
||
const box=rect(link),count=link.querySelector('.nav-count'),icon=link.querySelector(':scope > svg.icon');
|
||
const style=getComputedStyle(link);
|
||
const content=(icon?rect(icon).width+parseFloat(style.columnGap||0):0)+(()=>{
|
||
const range=document.createRange();const text=[...link.childNodes].find(item=>item.nodeType===Node.TEXT_NODE&&item.textContent.trim());
|
||
if(!text)return 0;range.selectNodeContents(text);return range.getBoundingClientRect().width;})();
|
||
const next=links[index+1];let gapAfter=null;
|
||
if(next){const other=rect(next);gapAfter=px(other.top)===px(box.top)?{axis:'x',size:px(other.left-box.right)}:{axis:'y',size:px(other.top-box.bottom)};}
|
||
return {text:textOf(link,'.nav-count'),current:link.getAttribute('aria-current'),
|
||
...layout(link),...edges(link),...font(link),minHeight:style.minHeight,height:px(box.height),
|
||
fullWidth:px(box.width)===px(rect(link.parentElement).width),
|
||
hugs:Math.abs(box.width-content-parseFloat(style.paddingLeft)-parseFloat(style.paddingRight))<=1.5,
|
||
gapAfter,icon:iconOf(link),
|
||
count:count?{text:count.textContent,shown:shown(count),...font(count),...css(count,'marginInlineStart')}:null};
|
||
}
|
||
// Every subsection is null when its element is missing, so a removed node is reported by its path
|
||
// (for example `chrome.breadcrumbs`) instead of failing the measurement.
|
||
const when=(node,measure)=>node?measure(node):null;
|
||
// The accessible text of a node: its text without aria-hidden descendants.
|
||
const accessibleText=node=>textOf(node,'[aria-hidden="true"]');
|
||
function itemOf(node,actions) {
|
||
const base={tag:node.tagName.toLowerCase(),className:node.className,shown:shown(node)};
|
||
if(!base.shown)return base;
|
||
const box=relative(node,actions);
|
||
return {...base,text:textOf(node,'select, .count'),...font(node),...edges(node),...layout(node),
|
||
left:box.left,top:box.top,width:box.width,height:box.height,
|
||
count:node.querySelector('.count')?.textContent??null,
|
||
select:when(node.querySelector('select'),select=>({options:[...select.options].map(option=>[option.value,option.textContent]),
|
||
label:select.getAttribute('aria-label'),labelled:select.labels?.[0]===node,...font(select),...edges(select),
|
||
minHeight:getComputedStyle(select).minHeight,width:px(rect(select).width),height:px(rect(select).height)})),
|
||
hiddenSuffix:[...node.querySelectorAll('[aria-hidden="true"]')].map(child=>child.textContent).join('')||null,
|
||
whiteSpace:getComputedStyle(node).whiteSpace};
|
||
}
|
||
function sidebarOf() {
|
||
return when(sidebar,()=>({...layout(sidebar),...edges(sidebar),
|
||
...css(sidebar,'top','overflowY','borderInlineEndWidth'),width:px(rect(sidebar).width),
|
||
fillsViewport:Math.abs(rect(sidebar).height-innerHeight)<=1}));
|
||
}
|
||
function bottomOf(bottom) {
|
||
// In the column sidebar the bottom group is pinned to the end by `margin-top: auto`, whose used value is only
|
||
// the free space above it, so the pin (its inset from the sidebar's padding edge) is recorded instead.
|
||
const sidebarStyle=getComputedStyle(sidebar);
|
||
const pinned=sidebarStyle.display==='flex';
|
||
const {marginTop,...rest}=edges(bottom);
|
||
return {...rest,...css(bottom,'display'),marginTop:pinned?'auto':marginTop,
|
||
bottomInset:pinned?px(rect(sidebar).bottom-rect(bottom).bottom-parseFloat(sidebarStyle.paddingBottom)):null};
|
||
}
|
||
function topActionsOf(actions,topbar) {
|
||
const style=getComputedStyle(topbar);
|
||
return {...layout(actions),...edges(actions),width:px(rect(actions).width),height:px(rect(actions).height),
|
||
fullWidth:Math.abs(rect(actions).width-(rect(topbar).width-parseFloat(style.paddingLeft)-parseFloat(style.paddingRight)))<=1,
|
||
rightInset:px(rect(topbar).right-rect(actions).right),
|
||
items:[...actions.children].map(node=>itemOf(node,actions))};
|
||
}
|
||
function chromeOf() {
|
||
if(!sidebar&&!workspace)return null;
|
||
const shell=document.querySelector('.app-shell');
|
||
const brand=sidebar?.querySelector('.brand');
|
||
const topbar=workspace?.querySelector('.topbar');
|
||
const footer=workspace?.querySelector('.app-footer');
|
||
return {
|
||
title:document.title,
|
||
skip:when(document.querySelector('a.skip'),skip=>({text:skip.textContent.trim(),...css(skip,'position','top','left')})),
|
||
body:{...font(document.body),backgroundColor:getComputedStyle(document.body).backgroundColor,
|
||
tokens:Object.fromEntries(['--ca-text','--ca-muted','--ca-focus','--ca-accent','--ca-field','--ca-surface','--ca-line',
|
||
'--line','--page','--sidebar','--soft-blue','--green'].map(name=>[name,getComputedStyle(document.body).getPropertyValue(name).trim()]))},
|
||
shell:when(shell,()=>{const columns=getComputedStyle(shell).gridTemplateColumns.split(' ');
|
||
return {display:getComputedStyle(shell).display,columns:columns.length,sidebarColumn:columns.length>1?columns[0]:null,
|
||
minHeight:getComputedStyle(shell).minHeight};}),
|
||
sidebar:sidebarOf(),
|
||
brand:when(brand,()=>({text:textOf(brand),name:accessibleText(brand),href:brand.getAttribute('href')?.replace(/^#\/?/,''),
|
||
...relative(brand,sidebar),...font(brand),...layout(brand),...edges(brand),
|
||
mark:when(brand.querySelector('.brand-mark'),mark=>({text:textOf(mark),hidden:mark.getAttribute('aria-hidden'),...font(mark),
|
||
width:px(rect(mark).width),height:px(rect(mark).height)})),
|
||
small:when(brand.querySelector('small'),small=>({text:small.textContent,...font(small),...css(small,'display','marginTop')}))})),
|
||
labels:sidebar?[...sidebar.querySelectorAll('.sidebar-label')].map(node=>({text:textOf(node),shown:shown(node),...font(node),...layout(node),...edges(node)})):null,
|
||
navs:sidebar?[...sidebar.querySelectorAll('nav')].map(nav=>{const links=[...nav.querySelectorAll(':scope > a')];return {
|
||
id:nav.id,label:nav.getAttribute('aria-label'),shown:shown(nav),...layout(nav),...edges(nav),
|
||
width:px(rect(nav).width),rows:links.map(navRow)};}):null,
|
||
bottom:when(sidebar?.querySelector('.sidebar-bottom'),bottomOf),
|
||
connection:when(sidebar?.querySelector('.connection'),connection=>({shown:shown(connection),text:textOf(connection,'small'),
|
||
small:connection.querySelector('small')?.textContent??null,...layout(connection),...edges(connection),...font(connection),
|
||
smallFont:when(connection.querySelector('small'),font),
|
||
dot:when(connection.querySelector('i'),dot=>css(dot,'width','height','borderTopLeftRadius','backgroundColor','marginTop'))})),
|
||
topbar:when(topbar,()=>({...layout(topbar),...edges(topbar),...css(topbar,'height','minHeight'),...relative(topbar,workspace),
|
||
fullWidth:px(rect(topbar).width)===px(rect(workspace).width)})),
|
||
breadcrumbs:when(topbar?.querySelector('.breadcrumbs'),crumbs=>({shown:shown(crumbs),text:textOf(crumbs),...layout(crumbs),...font(crumbs),
|
||
parts:[...crumbs.querySelectorAll('span')].map(span=>({text:span.textContent,...font(span)})),
|
||
...(shown(crumbs)?{left:relative(crumbs,topbar).left,middle:px(rect(crumbs).top+rect(crumbs).height/2-rect(topbar).top)}:{})})),
|
||
topActions:when(topbar?.querySelector('.top-actions'),actions=>topActionsOf(actions,topbar)),
|
||
main:when(main,()=>({tag:main.tagName.toLowerCase(),tabIndex:main.tabIndex,
|
||
...css(main,'paddingTop','paddingRight','paddingBottom','paddingLeft','maxWidth','marginLeft','marginRight')})),
|
||
footer:when(footer,()=>({...layout(footer),...edges(footer),...font(footer),...css(footer,'height'),width:px(rect(footer).width),
|
||
fullWidth:px(rect(footer).width)===px(rect(workspace).width),
|
||
spans:[...footer.querySelectorAll(':scope > span')].map(span=>({text:span.textContent.replace(/\s+/g,' ').trim(),...font(span),height:px(rect(span).height)})),
|
||
buttons:[...footer.querySelectorAll('button')].map(button=>({text:button.textContent.trim(),...font(button),...edges(button),
|
||
minHeight:getComputedStyle(button).minHeight,height:px(rect(button).height)}))})),
|
||
};
|
||
}
|
||
function headingOf() {
|
||
const heading=main?.querySelector(':scope > .heading');
|
||
if(!heading)return null;
|
||
const copy=heading.firstElementChild,h1=copy?.querySelector('h1'),description=copy?.querySelector('p'),actions=heading.querySelector('.heading-actions');
|
||
return {...layout(heading),...edges(heading),height:px(rect(heading).height),
|
||
copy:when(copy,()=>({tag:copy.tagName.toLowerCase(),...layout(copy)})),
|
||
h1:when(h1,()=>({id:h1.id,tabIndex:h1.tabIndex,text:h1.textContent,...font(h1),...edges(h1)})),
|
||
description:when(description,()=>({className:description.className,text:description.textContent,...font(description),...edges(description)})),
|
||
actions:when(actions,()=>({...layout(actions),...edges(actions),
|
||
fullWidth:px(rect(actions).width)===px(rect(heading).width),stacked:copy?rect(actions).top>=rect(copy).bottom-0.5:null,
|
||
buttons:[...actions.querySelectorAll('button')].map(node=>({text:node.textContent.trim(),primary:node.classList.contains('ca-primary'),
|
||
...font(node),...edges(node),minHeight:getComputedStyle(node).minHeight,height:px(rect(node).height)}))}))};
|
||
}
|
||
// ---- Images (#249 C7). Every box is relative to its section, every section to `main` (vertical rhythm). ----
|
||
const classes=node=>[...node.classList].sort().join(' ');
|
||
const sized=node=>({width:px(rect(node).width),height:px(rect(node).height)});
|
||
/** One control or label in a toolbar-like row: what it is, says and offers, and its box in `origin`. */
|
||
function controlOf(node,origin) {
|
||
const base={tag:node.tagName.toLowerCase(),className:classes(node),id:node.id||null,shown:shown(node)};
|
||
if(!base.shown)return base;
|
||
const style=getComputedStyle(node),input=node.querySelector('input');
|
||
// A group (the layout `.segmented`) is recorded button by button: its joined text says nothing about either.
|
||
const buttons=[...node.querySelectorAll(':scope > button')];
|
||
return {...base,text:buttons.length?null:textOf(node,'select, option'),label:node.getAttribute('aria-label'),pressed:node.getAttribute('aria-pressed'),
|
||
role:buttons.length?node.getAttribute('role'):null,buttons:buttons.length?buttons.map(button=>controlOf(button,node)):null,
|
||
primary:node.classList.contains('ca-primary'),htmlFor:node.htmlFor||null,...relative(node,origin),
|
||
...font(node),...edges(node),...layout(node),minHeight:style.minHeight,minWidth:style.minWidth,flexGrow:style.flexGrow,flexBasis:style.flexBasis,
|
||
icon:iconOf(node),
|
||
options:node.tagName==='SELECT'?[...node.options].map(option=>option.textContent):null,
|
||
input:when(input,()=>({type:input.type,id:input.id,placeholder:input.placeholder,label:input.getAttribute('aria-label'),
|
||
...font(input),...edges(input),minHeight:getComputedStyle(input).minHeight,...relative(input,node),exact:{width:rect(input).width}})),
|
||
// Unrounded place, for a comparison that lays a row out again; `stripExact` removes it before any diff.
|
||
exact:{left:rect(node).left-rect(origin).left,width:rect(node).width}};
|
||
}
|
||
function rowOf(row) {
|
||
return {...layout(row),...edges(row),...relative(row,main),exact:{width:rect(row).width},items:[...row.children].map(node=>controlOf(node,row))};
|
||
}
|
||
const gallery=main?.querySelector(':scope > .gallery-layout');
|
||
const body=gallery?.querySelector(':scope > .gallery-body');
|
||
const grid=body?.querySelector(':scope > .image-grid');
|
||
function captionOf(caption) {
|
||
return {...layout(caption),...edges(caption),...font(caption),...relative(caption,body),
|
||
// A span's width follows its text, so only its line box is recorded (the D17 date's text differs).
|
||
spans:[...caption.children].filter(shown).map(span=>({tag:span.tagName.toLowerCase(),id:span.id||null,text:textOf(span),...font(span),
|
||
top:relative(span,caption).top,height:relative(span,caption).height}))};
|
||
}
|
||
function gridOf() {
|
||
const style=getComputedStyle(grid);
|
||
return {id:grid.id||null,className:classes(grid),columns:style.gridTemplateColumns.split(/\s+/).length,
|
||
...css(grid,'columnGap','rowGap'),...relative(grid,body),cardCount:grid.querySelectorAll(':scope > .media-card').length};
|
||
}
|
||
function cardOf(card) {
|
||
const art=card.querySelector(':scope > .media-art'),open=art?.querySelector('.image-open'),img=open?.querySelector('img');
|
||
const overlays=art?.querySelector('.image-overlays'),info=card.querySelector(':scope > .media-info');
|
||
const copy=info?.querySelector(':scope > div:not(.image-card-actions)'),actions=info?.querySelector('.image-card-actions');
|
||
const h3=copy?.querySelector('h3'),p=copy?.querySelector('p');
|
||
return {className:classes(card),ratio:card.style.getPropertyValue('--media-ratio').replace(/\s/g,''),...relative(card,grid),
|
||
exact:{left:rect(card).left-rect(grid).left,top:rect(card).top-rect(grid).top,height:rect(card).height},
|
||
...edges(card),...css(card,'boxShadow','overflow','display'),
|
||
art:when(art,()=>({...relative(art,card),...css(art,'position')})),
|
||
open:when(open,()=>({label:open.getAttribute('aria-label'),...relative(open,card),...edges(open),...css(open,'display','overflow')})),
|
||
img:when(img,()=>({alt:img.alt,width:img.getAttribute('width'),height:img.getAttribute('height'),...sized(img),
|
||
...css(img,'objectFit','aspectRatio','transform','transitionProperty','transitionDuration')})),
|
||
// An overlay item's horizontal margin may be `auto`, whose used value is the free space beside it: it is kept
|
||
// to the pixel, and the item's place is also recorded as its inset from the overlay's right edge.
|
||
overlays:when(overlays,()=>!shown(overlays)?{display:getComputedStyle(overlays).display,shown:false}:({shown:true,...layout(overlays),
|
||
...css(overlays,'display','pointerEvents'),...relative(overlays,card),
|
||
items:[...overlays.children].map(node=>{const item=controlOf(node,card);
|
||
return item.shown?{...item,marginLeft:`${px(parseFloat(item.marginLeft))}px`,marginRight:`${px(parseFloat(item.marginRight))}px`,
|
||
rightInset:px(rect(overlays).right-rect(node).right)}:item;})})),
|
||
info:when(info,()=>({...layout(info),...edges(info),...relative(info,card),
|
||
// The text and the actions are placed in the card (what is seen), so a centred list row compares by place.
|
||
// The text's heights are the unrounded flow (the same CSS lays them out alike in both documents), so lines
|
||
// of 16.5px and 13.5px add up exactly.
|
||
copy:when(copy,()=>({left:relative(copy,card).left,top:relative(copy,card).top,height:rect(copy).height,...css(copy,'minWidth')})),
|
||
h3:when(h3,()=>({text:h3.textContent,title:h3.getAttribute('title'),...font(h3),...edges(h3),
|
||
...css(h3,'overflowX','textOverflow','whiteSpace'),height:rect(h3).height})),
|
||
// `maxContent` is the subtitle's one-line width (an unwrapped hidden copy beside it), so a wider text column can
|
||
// be shown to fit it on one line.
|
||
p:when(p,()=>({text:p.textContent,...font(p),...edges(p),height:rect(p).height,maxContent:(()=>{
|
||
const probe=p.cloneNode(true);probe.style.cssText='position:absolute;visibility:hidden;white-space:nowrap;width:max-content';
|
||
p.after(probe);const width=rect(probe).width;probe.remove();return px(width);})()})),
|
||
actions:when(actions,()=>({...layout(actions),...relative(actions,card),...css(actions,'flexShrink','minWidth'),
|
||
buttons:[...actions.children].map(node=>controlOf(node,actions))}))}))};
|
||
}
|
||
function continuationOf(continuation) {
|
||
return {id:continuation.id||null,...layout(continuation),...edges(continuation),...relative(continuation,body),
|
||
items:[...continuation.children].filter(shown).map(node=>controlOf(node,continuation))};
|
||
}
|
||
function panelOf() {
|
||
const panel=gallery.children[1];
|
||
// The panel's content (and so the layout's height) is the inspector's, PR D; its box is compared here.
|
||
const {height,...place}=relative(gallery,main);
|
||
return {...css(gallery,'display','gridTemplateColumns','columnGap','rowGap','alignItems'),...place,
|
||
box:when(panel,()=>{const box=relative(panel,gallery);return {left:box.left,top:box.top,width:box.width,...css(panel,'position','top')};})};
|
||
}
|
||
const continuation=body?.querySelector(':scope > .infinite-continuation');
|
||
function bulkbarOf(bar) {
|
||
return {id:bar.id||null,...relative(bar,main),children:bar.children.length,bar:when(bar.querySelector(':scope > .bulkbar'),rowOf)};
|
||
}
|
||
// ---- Prompt library (#249 E3). The concept's main holds the toolbar, caption and grid; production's shelf wraps
|
||
// them in `section.library-shelf` and windows the cards into `.library-row`s (spec §3.4: "row windowing kept"),
|
||
// so every Library box is found by descendant and placed relative to `main` or its own card.
|
||
// A `display: contents` node has no box: its children stand in its place (the D23 prompt-block mount point).
|
||
const boxedChildren=node=>[...node.children].flatMap(child=>getComputedStyle(child).display==='contents'?boxedChildren(child):[child]);
|
||
function libraryCardOf(card,origin) {
|
||
const art=card.querySelector(':scope > .card-art'),content=card.querySelector(':scope > .card-content'),footer=card.querySelector(':scope > .card-footer');
|
||
const title=content?.querySelector(':scope > .card-title'),meta=content?.querySelector(':scope > .card-meta'),tags=content?.querySelector(':scope > .tags');
|
||
const section=content?.querySelector('.prompt-section'),heading=section?.querySelector(':scope > .prompt-heading');
|
||
const text=section?.querySelector(':scope > .prompt-text'),facts=footer?.querySelector(':scope > span'),actions=footer?.querySelector(':scope > .row-actions');
|
||
const lines=node=>Math.round(rect(node).height/parseFloat(getComputedStyle(node).lineHeight));
|
||
const spanOf=(node,parent)=>({tag:node.tagName.toLowerCase(),className:classes(node),text:textOf(node),...font(node),...edges(node),...relative(node,parent)});
|
||
return {tag:card.tagName.toLowerCase(),className:classes(card),...relative(card,origin),...edges(card),...css(card,'overflow','display','boxShadow'),
|
||
children:[...card.children].map(classes),
|
||
art:when(art,()=>({...relative(art,card),...css(art,'display','gridTemplateColumns','columnGap','rowGap','backgroundColor'),
|
||
items:[...art.children].map(node=>({tag:node.tagName.toLowerCase(),className:classes(node),alt:node.getAttribute('alt'),...relative(node,art),
|
||
...css(node,'objectFit')}))})),
|
||
content:when(content,()=>({...relative(content,card),...edges(content),children:boxedChildren(content).map(node=>`${node.tagName.toLowerCase()}.${classes(node)}`)})),
|
||
title:when(title,()=>({...layout(title),...edges(title),...relative(title,content),
|
||
items:[...title.children].map(node=>node.tagName==='BUTTON'?controlOf(node,title):spanOf(node,title))})),
|
||
section:when(section,()=>({className:classes(section),...relative(section,content),...edges(section),
|
||
label:when(heading?.querySelector('.role-label'),label=>({text:label.textContent,...font(label)})),
|
||
actions:when(heading?.querySelector('.prompt-actions'),group=>({...layout(group),...relative(group,section),
|
||
buttons:[...group.children].map(node=>controlOf(node,group))})),
|
||
text:when(text,()=>({text:text.textContent,...font(text),...edges(text),...relative(text,section),lines:lines(text)}))})),
|
||
meta:when(meta,()=>({...layout(meta),...edges(meta),...font(meta),...relative(meta,content),items:[...meta.children].map(node=>spanOf(node,meta))})),
|
||
// A chip's unrounded place too, so a re-ordered row can be laid out again (`reflow`; `stripExact` removes it).
|
||
tags:when(tags,()=>({...layout(tags),...edges(tags),...relative(tags,content),exact:{width:rect(tags).width},
|
||
items:[...tags.children].map(node=>({...spanOf(node,tags),shown:shown(node),exact:{left:rect(node).left-rect(tags).left,width:rect(node).width}}))})),
|
||
footer:when(footer,()=>({...layout(footer),...edges(footer),...font(footer),...relative(footer,card),
|
||
// What is seen (hidden-only text left out) and what is spoken (aria-hidden text left out).
|
||
facts:when(facts,()=>({text:textOf(facts,'.ca-visually-hidden'),spoken:accessibleText(facts),...font(facts),...relative(facts,footer)})),
|
||
actions:when(actions,()=>({...layout(actions),...relative(actions,footer),buttons:[...actions.children].map(node=>controlOf(node,actions))}))}))};
|
||
}
|
||
function libraryOf() {
|
||
const grid=main?.querySelector('.library-grid');
|
||
if(!grid)return null;
|
||
const toolbar=main.querySelector('search.toolbar'),caption=main.querySelector('.section-caption');
|
||
const cards=[...grid.querySelectorAll('.prompt-card')];
|
||
// Whatever the page shows after the grid, in document order (the concept: nothing).
|
||
const trailing=[];
|
||
for(let node=grid;node&&node!==main;node=node.parentElement)
|
||
for(let next=node.nextElementSibling;next;next=next.nextElementSibling)if(shown(next))trailing.push({tag:next.tagName.toLowerCase(),className:classes(next),text:next.innerText.replace(/\s+/g,' ').trim()});
|
||
const style=getComputedStyle(grid);
|
||
return {toolbar:when(toolbar,rowOf),
|
||
caption:when(caption,()=>({...layout(caption),...edges(caption),...font(caption),...relative(caption,main),
|
||
spans:[...caption.children].filter(shown).map(span=>({tag:span.tagName.toLowerCase(),text:textOf(span),...font(span),
|
||
top:relative(span,caption).top,height:relative(span,caption).height}))})),
|
||
grid:{columns:style.gridTemplateColumns.split(/\s+/).length,...css(grid,'columnGap','rowGap'),...relative(grid,main),cardCount:cards.length},
|
||
cards:cards.slice(0,6).map(card=>libraryCardOf(card,grid)),trailing};
|
||
}
|
||
// ---- Prompt history (#249 F5): renderHistory a587–622, style.css 1059–1179 and 1546–1567. The concept's main holds
|
||
// the summary, the variants panel, the comparison and the gallery; production's sit in `section.library-history`, so
|
||
// each is found by descendant and placed relative to `main` (vertical rhythm) or to its own section.
|
||
const spoken=node=>node.classList.contains('ca-visually-hidden');
|
||
const outline=node=>css(node,'outlineStyle','outlineWidth','outlineColor','outlineOffset');
|
||
const pseudo=(node,which)=>{const style=getComputedStyle(node,which);return Object.fromEntries(['content','position','top','left','width','height',
|
||
'borderLeftWidth','borderBottomWidth','borderLeftColor','borderBottomColor','borderBottomLeftRadius','backgroundColor','color'].map(key=>[key,style[key]]));};
|
||
// What a section shows, in order: each shown child as `tag.classes`, visually hidden statuses apart (`spoken`).
|
||
const structureOf=node=>({children:boxedChildren(node).filter(child=>shown(child)&&!spoken(child)).map(child=>`${child.tagName.toLowerCase()}.${classes(child)}`),
|
||
spoken:[...node.querySelectorAll('.ca-visually-hidden')].filter(shown).map(child=>({role:child.getAttribute('role'),text:child.textContent}))});
|
||
function promptSectionOf(section,origin) {
|
||
const heading=section.querySelector(':scope > .prompt-heading'),label=heading?.querySelector('.role-label');
|
||
const actions=heading?.querySelector('.prompt-actions'),text=section.querySelector(':scope > .prompt-text');
|
||
return {className:classes(section),...relative(section,origin),...edges(section),
|
||
label:when(label,()=>({text:label.textContent,...font(label)})),
|
||
actions:when(actions,()=>({...layout(actions),...relative(actions,section),buttons:[...actions.children].map(node=>controlOf(node,actions))})),
|
||
text:when(text,()=>({text:text.textContent,...font(text),...edges(text),...relative(text,section),
|
||
lines:Math.round(rect(text).height/parseFloat(getComputedStyle(text).lineHeight))}))};
|
||
}
|
||
function panelHeadingOf(heading,origin) {
|
||
const h2=heading.querySelector('h2'),note=heading.querySelector('.small'),actions=heading.querySelector(':scope > .row-actions');
|
||
return {...layout(heading),...edges(heading),...relative(heading,origin),
|
||
h2:when(h2,()=>({text:h2.textContent,...font(h2),...edges(h2),...relative(h2,heading)})),
|
||
note:when(note,()=>({tag:note.tagName.toLowerCase(),text:note.textContent,...font(note),...edges(note),...relative(note,heading)})),
|
||
actions:when(actions,()=>({...layout(actions),...relative(actions,heading),buttons:[...actions.children].filter(shown).map(node=>controlOf(node,actions))}))};
|
||
}
|
||
function laneOf(lane,timeline) {
|
||
const dot=lane.querySelector(':scope > .variant-dot'),label=lane.querySelector(':scope > span'),run=lane.querySelector(':scope > .run-dot');
|
||
return {className:classes(lane),offset:lane.style.getPropertyValue('--lane-offset'),...relative(lane,timeline),...edges(lane),...css(lane,'minWidth','position'),
|
||
before:pseudo(lane,'::before'),
|
||
dot:when(dot,()=>({...controlOf(dot,lane),...outline(dot),...css(dot,'position','top','left','width','height','color')})),
|
||
label:when(label,()=>({text:label.textContent,...font(label),...relative(label,lane),...css(label,'position','top','left')})),
|
||
run:shown(run)?{...controlOf(run,lane),...css(run,'position','top','left','width','height')}:null};
|
||
}
|
||
function timelineOf(timeline,origin) {
|
||
const positive=timeline.querySelector(':scope > .positive-lane'),span=positive?.querySelector('span');
|
||
return {...edges(timeline),...css(timeline,'overflowX','position'),...relative(timeline,origin),
|
||
positive:when(positive,()=>({...edges(positive),...css(positive,'height','minWidth'),...relative(positive,timeline),before:pseudo(positive,'::before'),
|
||
span:when(span,()=>({text:span.textContent,...font(span),...relative(span,positive)}))})),
|
||
lanes:[...timeline.querySelectorAll(':scope > .variant-lane')].map(lane=>laneOf(lane,timeline)),
|
||
structure:structureOf(timeline)};
|
||
}
|
||
function variantsOf(panel) {
|
||
const heading=panel.querySelector(':scope > .panel-heading'),timeline=panel.querySelector(':scope > .timeline'),lower=panel.querySelector(':scope > .timeline-lower');
|
||
return {className:classes(panel),...edges(panel),...relative(panel,main),structure:structureOf(panel),
|
||
heading:when(heading,()=>panelHeadingOf(heading,panel)),timeline:when(timeline,()=>timelineOf(timeline,panel)),
|
||
lower:when(lower,()=>({...layout(lower),...edges(lower),...relative(lower,panel),
|
||
items:[...lower.children].filter(shown).map(node=>({...controlOf(node,lower),...font(node)}))}))};
|
||
}
|
||
function compareOf(section) {
|
||
const h2=section.querySelector(':scope > h2'),intro=section.querySelector(':scope > p:not(.small)'),grid=section.querySelector(':scope > .compare-grid');
|
||
return {...edges(section),...relative(section,main),structure:structureOf(section),
|
||
h2:when(h2,()=>({text:h2.textContent,...font(h2),...edges(h2)})),
|
||
intro:when(intro,()=>({text:intro.textContent,...font(intro),...edges(intro),...relative(intro,section)})),
|
||
grid:when(grid,()=>({columns:getComputedStyle(grid).gridTemplateColumns.split(/\s+/).length,...css(grid,'columnGap','rowGap'),...relative(grid,section),
|
||
items:[...grid.children].map(item=>({className:classes(item),...edges(item),...relative(item,grid),
|
||
sections:[...item.querySelectorAll('.prompt-section')].map(node=>promptSectionOf(node,item))}))})),
|
||
notes:[...section.querySelectorAll(':scope > p.small')].map(note=>({text:note.textContent,...font(note),...edges(note),...relative(note,section)})),
|
||
added:[...section.querySelectorAll(':scope > .diff-added')].map(chip=>({text:chip.textContent,...font(chip),...edges(chip),...css(chip,'display'),...relative(chip,section)}))};
|
||
}
|
||
function historyOf() {
|
||
const panel=main?.querySelector('section.timeline-panel');
|
||
if(!panel)return null;
|
||
const summary=main.querySelector('article.surface'),gallery=main.querySelector('section.history-gallery');
|
||
const compare=[...main.querySelectorAll('section.surface:not(.timeline-panel)')].find(shown);
|
||
const chip=summary?.querySelector(':scope > .chip'),meta=summary?.querySelector(':scope > .card-meta');
|
||
const galleryHeading=gallery?.querySelector(':scope > .panel-heading');
|
||
return {
|
||
summary:when(summary,()=>({...edges(summary),...relative(summary,main),structure:structureOf(summary),
|
||
chip:when(chip,()=>({text:chip.textContent,...font(chip),...edges(chip),...relative(chip,summary)})),
|
||
sections:[...summary.querySelectorAll('.prompt-section')].map(node=>promptSectionOf(node,summary)),
|
||
meta:when(meta,()=>({...layout(meta),...edges(meta),...font(meta),...relative(meta,summary),
|
||
items:[...meta.children].filter(shown).map(node=>({text:node.textContent,...font(node),...relative(node,meta)}))}))})),
|
||
variants:variantsOf(panel),
|
||
compare:when(compare,compareOf),
|
||
gallery:when(gallery,()=>({...edges(gallery),...relative(gallery,main),
|
||
heading:when(galleryHeading,()=>panelHeadingOf(galleryHeading,gallery))})),
|
||
};
|
||
}
|
||
return {chrome:chromeOf(),heading:headingOf(),
|
||
toolbar:when(main?.querySelector(':scope > search.toolbar'),rowOf),
|
||
filterRow:when(main?.querySelector(':scope > .filter-row'),rowOf),
|
||
bulkbar:when(main?.querySelector(':scope > #bulkbar'),bulkbarOf),
|
||
caption:when(body?.querySelector(':scope > .section-caption'),captionOf),
|
||
grid:when(grid,gridOf),
|
||
cards:grid?[...grid.querySelectorAll(':scope > .media-card')].slice(0,6).map(cardOf):null,
|
||
continuation:when(continuation,continuationOf),
|
||
panel:when(gallery,panelOf),
|
||
library:libraryOf(),history:historyOf(),noPageScroll:document.documentElement.scrollWidth<=innerWidth+1};
|
||
}
|
||
|
||
/**
|
||
* The inspector (#249 D4): `panel` is the Images side panel's content (`aside#image-inspector`, app.js imageDetails
|
||
* a400–414) and `viewerDetails` the viewer's full details (`aside#viewer-details`, imageDetails(i, true)), each null
|
||
* when absent. Both are measured by one in-page function with the concept's selectors in either document.
|
||
*/
|
||
export async function inspectorOf(page) {
|
||
return {panel:await page.evaluate(measureInspector,'#image-inspector'),
|
||
viewerDetails:await page.evaluate(measureInspector,'#viewer-details')};
|
||
}
|
||
|
||
/** Runs in the page; self-contained. Every box is relative to the measured root. */
|
||
export function measureInspector(selector) {
|
||
const root=document.querySelector(selector);
|
||
if(!root)return null;
|
||
const px=value=>Math.round(value);
|
||
const css=(node,...keys)=>{const style=getComputedStyle(node);return Object.fromEntries(keys.map(key=>[key,style[key]]));};
|
||
const shown=node=>Boolean(node)&&node.getClientRects().length>0&&getComputedStyle(node).visibility!=='hidden';
|
||
const rect=node=>node.getBoundingClientRect();
|
||
const font=node=>css(node,'fontFamily','fontSize','fontWeight','lineHeight','letterSpacing','color','textTransform');
|
||
const edges=node=>css(node,'paddingTop','paddingRight','paddingBottom','paddingLeft','marginTop','marginRight','marginBottom','marginLeft',
|
||
'borderTopWidth','borderTopStyle','borderTopColor','borderRightWidth','borderBottomWidth','borderTopLeftRadius','backgroundColor');
|
||
const layout=node=>css(node,'display','flexDirection','flexWrap','justifyContent','alignItems','columnGap','rowGap');
|
||
const box=node=>{const a=rect(node),b=rect(root);return {left:px(a.left-b.left),top:px(a.top-b.top),width:px(a.width),height:px(a.height)};};
|
||
const classes=node=>[...node.classList].sort().join(' ');
|
||
const when=(node,measure)=>node?measure(node):null;
|
||
// Visible text: without visually hidden prefixes and aria-hidden glyphs, whitespace collapsed.
|
||
const textOf=(node,skip='.ca-visually-hidden, [aria-hidden="true"]')=>{const copy=node.cloneNode(true);copy.querySelectorAll(skip).forEach(item=>item.remove());
|
||
return copy.textContent.replace(/\s+/g,' ').trim();};
|
||
const hiddenOf=node=>[...node.querySelectorAll('.ca-visually-hidden')].map(item=>item.textContent).join('')||null;
|
||
const nameOf=node=>node.getAttribute('aria-label')??textOf(node,'[aria-hidden="true"]');
|
||
function iconOf(node) {
|
||
const svg=node.querySelector(':scope > svg.icon');
|
||
if(!svg)return null;
|
||
return {width:px(rect(svg).width),height:px(rect(svg).height),viewBox:svg.getAttribute('viewBox'),hidden:svg.getAttribute('aria-hidden'),
|
||
d:svg.querySelector('path')?.getAttribute('d')??null,...css(svg,'strokeWidth','color')};
|
||
}
|
||
function controlOf(node) {
|
||
const style=getComputedStyle(node);
|
||
return {tag:node.tagName.toLowerCase(),className:classes(node),text:textOf(node),name:nameOf(node),pressed:node.getAttribute('aria-pressed'),
|
||
primary:node.classList.contains('ca-primary'),...box(node),...font(node),...edges(node),...layout(node),
|
||
minHeight:style.minHeight,minWidth:style.minWidth,icon:iconOf(node)};
|
||
}
|
||
function chipOf(node) {
|
||
return {tag:node.tagName.toLowerCase(),className:classes(node),text:textOf(node),hidden:hiddenOf(node),...box(node),...font(node),...edges(node),
|
||
minHeight:getComputedStyle(node).minHeight};
|
||
}
|
||
function rowOf(row) {
|
||
return {...layout(row),...edges(row),...box(row),chips:[...row.children].map(chipOf)};
|
||
}
|
||
function sectionOf(section) {
|
||
const heading=section.querySelector(':scope > .prompt-heading'),label=heading?.querySelector('.role-label');
|
||
const actions=heading?.querySelector('.prompt-actions'),text=section.querySelector(':scope > .prompt-text');
|
||
return {className:classes(section),...edges(section),...box(section),
|
||
heading:when(heading,()=>({...layout(heading),...edges(heading),...box(heading)})),
|
||
label:when(label,()=>({tag:label.tagName.toLowerCase(),text:label.textContent,...font(label),...box(label)})),
|
||
actions:when(actions,()=>({...layout(actions),...box(actions),buttons:[...actions.children].map(controlOf)})),
|
||
text:when(text,()=>({tag:text.tagName.toLowerCase(),className:classes(text),text:text.textContent,tabIndex:text.tabIndex,...font(text),...edges(text),
|
||
...css(text,'whiteSpace','overflowWrap'),...box(text),lines:px(rect(text).height/parseFloat(getComputedStyle(text).lineHeight))}))};
|
||
}
|
||
function factsOf(list) {
|
||
const style=getComputedStyle(list);
|
||
return {...css(list,'display','columnGap','rowGap','fontSize'),...edges(list),columns:style.gridTemplateColumns.split(/\s+/).length,...box(list),
|
||
items:[...list.children].map(item=>{const dt=item.querySelector('dt'),dd=item.querySelector('dd');
|
||
return {...box(item),dt:when(dt,()=>({text:dt.textContent,...font(dt),...edges(dt)})),dd:when(dd,()=>({text:dd.textContent,...font(dd),...edges(dd)}))};})};
|
||
}
|
||
const describe=item=>`${item.localName}${item.classList.length?'.'+[...item.classList].join('.'):''}`;
|
||
const panel=root.classList.contains('detail-panel');
|
||
// The panel's `.detail-inner`, the viewer's class-less wrapper (D20). A prompt-block mount point that is
|
||
// `display: contents` is transparent: its sections count as the wrapper's own children.
|
||
const inner=root.querySelector(panel?':scope > .detail-inner':':scope > div');
|
||
const parts=inner?[...inner.children].flatMap(item=>getComputedStyle(item).display==='contents'?[...item.children]:[item]):[];
|
||
const sections=parts.filter(item=>item.classList.contains('prompt-section'));
|
||
const firstSection=parts.indexOf(sections[0]);
|
||
const rows=parts.filter(item=>item.classList.contains('tags'));
|
||
const heading=parts.find(item=>item.classList.contains('panel-heading')),h2=heading?.querySelector('h2'),format=heading?.querySelector('.chip');
|
||
const filename=parts.find(item=>item.matches('p.small'));
|
||
const cover=root.querySelector(':scope > .detail-cover'),footer=root.querySelector(':scope > .detail-footer');
|
||
// What a closed disclosure holds is not offered (only its summary is), whatever boxes the engine keeps for it.
|
||
const offered=node=>{const closed=node.closest('details:not([open])');return !closed||node.parentElement===closed&&node.localName==='summary';};
|
||
const controls=[...root.querySelectorAll('button, a[href], [tabindex]:not([tabindex="-1"]), summary')].filter(node=>shown(node)&&offered(node));
|
||
return {
|
||
className:classes(root),label:root.getAttribute('aria-label'),
|
||
...css(root,'position','top','overflowX','overflowY','maxHeight'),...edges(root),width:px(rect(root).width),height:px(rect(root).height),
|
||
structure:{outer:[...root.children].map(describe),inner:parts.map(describe)},
|
||
cover:when(cover,()=>({tag:cover.tagName.toLowerCase(),alt:cover.getAttribute('alt'),...box(cover),...css(cover,'objectFit','display')})),
|
||
inner:when(inner,()=>({...edges(inner),...box(inner)})),
|
||
heading:when(heading,()=>({...layout(heading),...edges(heading),...box(heading),
|
||
h2:when(h2,()=>({text:h2.textContent,...font(h2),...edges(h2),...box(h2)})),format:when(format,chipOf)})),
|
||
filename:when(filename,()=>({text:filename.textContent,...font(filename),...edges(filename),...box(filename)})),
|
||
badges:when(rows.find(row=>parts.indexOf(row)<firstSection),rowOf),
|
||
sections:sections.map(sectionOf),
|
||
tags:when(rows.find(row=>parts.indexOf(row)>firstSection),rowOf),
|
||
facts:when(parts.find(item=>item.matches('dl.meta-grid')),factsOf),
|
||
// Disclosures (viewer only) by their summary and frame; their content is the D20 production facts.
|
||
details:parts.filter(item=>item.localName==='details').map(item=>{const summary=item.querySelector(':scope > summary');
|
||
return {open:item.open,...edges(item),...box(item),summary:when(summary,()=>({text:summary.textContent,...font(summary),...box(summary)}))};}),
|
||
footer:when(footer,()=>({...layout(footer),...edges(footer),...box(footer),buttons:[...footer.children].map(controlOf)})),
|
||
// The viewer's pair send and history follow the wrapper directly (imageDetails(i, true) has no footer).
|
||
actions:panel?null:[...root.children].filter(item=>item.localName==='button').map(controlOf),
|
||
controls:controls.map(node=>({tag:node.tagName.toLowerCase(),text:textOf(node),name:nameOf(node),pressed:node.getAttribute('aria-pressed'),
|
||
primary:node.classList.contains('ca-primary'),minHeight:getComputedStyle(node).minHeight})),
|
||
};
|
||
}
|