docs: isolate screenshot tooling from release fixtures
Validate / process-gate (push) Failing after 4m52s
Validate / provenance (push) Successful in 4m55s
Validate / docs (push) Failing after 5m3s
Validate / changes (push) Successful in 4m39s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / frontend (push) Skipped
Validate / smoke (push) Skipped
Validate / golden (push) Skipped
Validate / performance_smoke (push) Skipped
Validate / backend (push) Skipped
Issue: #35 User-Visible: no
@@ -22,7 +22,7 @@ jobs:
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: 22 }
|
||||
- name: Validate public documentation
|
||||
run: npm run docs:check -- --external
|
||||
run: node scripts/check-docs.mjs --external
|
||||
|
||||
provenance:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -150,8 +150,8 @@ concurrent clients, but avoid editing the same object in two browsers at once.
|
||||
may be replaced with fictional ones.
|
||||
|
||||
Documentation screenshots are produced by the reproducible
|
||||
`npm run docs:capture` command using synthetic data only. Scenario version,
|
||||
`npm run build && node demo/docs/capture.mjs` command using synthetic data only. Scenario version,
|
||||
source fingerprint and every image hash are recorded in the
|
||||
[screenshot manifest](docs/images/manifest.json).
|
||||
[screenshot index](docs/images/screenshots.json).
|
||||
|
||||
License: [MIT](LICENSE).
|
||||
|
||||
@@ -154,8 +154,8 @@ default_floor: ground
|
||||
воспроизведения; приватные entity ID можно заменить вымышленными.
|
||||
|
||||
Скриншоты в документации получены воспроизводимой командой
|
||||
`npm run docs:capture` только на синтетических данных. Версия сценариев,
|
||||
`npm run build && node demo/docs/capture.mjs` только на синтетических данных. Версия сценариев,
|
||||
fingerprint исходников и хеш каждого изображения находятся в
|
||||
[манифесте снимков](docs/images/manifest.json).
|
||||
[индексе снимков](docs/images/screenshots.json).
|
||||
|
||||
Лицензия: [MIT](LICENSE).
|
||||
|
||||
@@ -102,6 +102,74 @@ const roomCardClip = (page) => page.evaluate(() => {
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Documentation-only presentation state. Keep these mutations out of the
|
||||
* golden harness: changing that release fixture would invalidate every visual
|
||||
* baseline even though the production component and golden matrix are intact.
|
||||
*/
|
||||
const applyDocumentationState = (page, scenario) => page.evaluate(async (current) => {
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const card = window.__goldenCard;
|
||||
if (!card) throw new Error(`documentation card is missing: ${current.id}`);
|
||||
|
||||
if (current.title) {
|
||||
card.setConfig({ ...card._config, title: current.title });
|
||||
}
|
||||
|
||||
if (current.roomMetrics) {
|
||||
const space = card._serverCfg?.spaces?.find((item) => item.id === current.space);
|
||||
if (!space) throw new Error(`documentation room metrics space is missing: ${current.space}`);
|
||||
space.settings = {
|
||||
...(space.settings || {}),
|
||||
label_temp: true,
|
||||
label_hum: true,
|
||||
label_lqi: true,
|
||||
label_light: true,
|
||||
};
|
||||
card._cfgEpoch += 1;
|
||||
card._modelCache = null;
|
||||
}
|
||||
|
||||
if (current.fixture === 'empty') {
|
||||
card._serverCfg = { ...(card._serverCfg || {}), spaces: [] };
|
||||
card._cfgEpoch += 1;
|
||||
card._modelCache = null;
|
||||
card._space = '';
|
||||
card._onboardingShown = true;
|
||||
card.hass = { ...card.hass, floors: {} };
|
||||
card._openSpaceDialog('create');
|
||||
}
|
||||
|
||||
if (current.dialog === 'device-info') {
|
||||
const device = card._devices.find((item) => item.id === current.deviceId);
|
||||
if (!device) throw new Error(`documentation device is missing: ${current.deviceId}`);
|
||||
card._infoCard = device;
|
||||
}
|
||||
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
|
||||
if (current.devicePresentationPreview) {
|
||||
const dialog = card.renderRoot.querySelector('hp-dialog');
|
||||
const body = dialog?.querySelector('.body');
|
||||
const preview = dialog?.querySelector('hp-device-preview');
|
||||
await preview?.updateComplete;
|
||||
if (!body || !preview)
|
||||
throw new Error('documentation device presentation preview is missing');
|
||||
const bodyRect = body.getBoundingClientRect();
|
||||
const previewRect = preview.getBoundingClientRect();
|
||||
body.scrollTop += previewRect.top - bodyRect.top - 180;
|
||||
await frame();
|
||||
const visibleBody = body.getBoundingClientRect();
|
||||
const visiblePreview = preview.getBoundingClientRect();
|
||||
if (visiblePreview.top < visibleBody.top - 1 || visiblePreview.bottom > visibleBody.bottom + 1)
|
||||
throw new Error('documentation viewport does not show the device presentation preview');
|
||||
}
|
||||
|
||||
return { dialog: !!card.renderRoot.querySelector('hp-dialog') };
|
||||
}, scenario);
|
||||
|
||||
mkdirSync(OUTPUT, { recursive: true });
|
||||
copyFileSync(BUNDLE, DEMO_BUNDLE);
|
||||
copyFileSync(BUNDLE, INTEGRATION_BUNDLE);
|
||||
@@ -114,7 +182,8 @@ try {
|
||||
const fingerprint = await assertFreshDemoBundle(page, ROOT);
|
||||
const scenarios = {};
|
||||
for (const scenario of DOC_SCREENSHOTS) {
|
||||
const runtime = await prepareGoldenScenario(page, scenario);
|
||||
await prepareGoldenScenario(page, scenario);
|
||||
const runtime = await applyDocumentationState(page, scenario);
|
||||
if (scenario.expectDialog && !runtime.dialog)
|
||||
throw new Error(`documentation scenario did not open its dialog: ${scenario.id}`);
|
||||
const clip = scenario.capture === 'room-card'
|
||||
@@ -140,10 +209,10 @@ try {
|
||||
fixture: 'synthetic-only',
|
||||
sourceFingerprint: fingerprint,
|
||||
captureScriptSha256: sha256(readFileSync(SCRIPT)),
|
||||
command: 'npm run docs:capture',
|
||||
command: 'npm run build && node demo/docs/capture.mjs',
|
||||
scenarios,
|
||||
};
|
||||
writeFileSync(resolve(OUTPUT, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
writeFileSync(resolve(OUTPUT, 'screenshots.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
import { makeLargeHouseFixture } from '../fixtures/large-house.mjs';
|
||||
import { fixtureWallKey, makeVisualMatrixFixture } from '../fixtures/visual-matrix.mjs';
|
||||
|
||||
const fixtureFor = (name) => {
|
||||
if (name === 'large') return makeLargeHouseFixture();
|
||||
if (name === 'empty') {
|
||||
return {
|
||||
config: { spaces: [], markers: [], settings: {} },
|
||||
layout: {}, devices: {}, entities: {}, states: {}, areas: {},
|
||||
};
|
||||
}
|
||||
return makeVisualMatrixFixture();
|
||||
};
|
||||
const fixtureFor = (name) => name === 'large' ? makeLargeHouseFixture() : makeVisualMatrixFixture();
|
||||
|
||||
const themeVars = {
|
||||
dark: {
|
||||
@@ -129,8 +120,7 @@ export function prepareGoldenFixture(scenario) {
|
||||
fixture.devices[scenario.deviceId].name = scenario.deviceName;
|
||||
}
|
||||
if (scenario.fillMode || scenario.bgMode || typeof scenario.glowEnabled === 'boolean'
|
||||
|| typeof scenario.sunRays === 'boolean' || typeof scenario.showBorders === 'boolean'
|
||||
|| scenario.roomMetrics) {
|
||||
|| typeof scenario.sunRays === 'boolean' || typeof scenario.showBorders === 'boolean') {
|
||||
const space = requireSpace();
|
||||
space.settings = {
|
||||
...(space.settings || {}),
|
||||
@@ -139,9 +129,6 @@ export function prepareGoldenFixture(scenario) {
|
||||
...(typeof scenario.glowEnabled === 'boolean' ? { glow_enabled: scenario.glowEnabled } : {}),
|
||||
...(typeof scenario.sunRays === 'boolean' ? { sun_rays: scenario.sunRays } : {}),
|
||||
...(typeof scenario.showBorders === 'boolean' ? { show_borders: scenario.showBorders } : {}),
|
||||
...(scenario.roomMetrics ? {
|
||||
label_temp: true, label_hum: true, label_lqi: true, label_light: true,
|
||||
} : {}),
|
||||
...(scenario.customFill ? { custom_fill: scenario.customFill } : {}),
|
||||
};
|
||||
}
|
||||
@@ -289,7 +276,7 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
}
|
||||
const host = document.getElementById('host');
|
||||
const cardConfig = {
|
||||
type: 'custom:houseplan-card', title: scenario.title || `Golden ${scenario.id}`, icon_size: 3.4,
|
||||
type: 'custom:houseplan-card', title: `Golden ${scenario.id}`, icon_size: 3.4,
|
||||
language: scenario.language || 'en',
|
||||
...(scenario.kiosk ? { kiosk: true } : {}),
|
||||
};
|
||||
@@ -298,7 +285,7 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
user: { id: 'golden', name: 'Golden fixture', is_admin: true },
|
||||
devices: fixture.devices || {}, entities: fixture.entities || {},
|
||||
areas: fixture.areas || {}, states: fixture.states || {},
|
||||
floors: scenario.noFloors ? {} : {
|
||||
floors: {
|
||||
one: { floor_id: 'one', name: 'One', level: 0 },
|
||||
two: { floor_id: 'two', name: 'Two', level: 1 },
|
||||
three: { floor_id: 'three', name: 'Three', level: 2 },
|
||||
@@ -551,21 +538,6 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
if (visibleRole.top < visibleBody.top - 1 || visibleRadius.bottom > visibleBody.bottom + 1)
|
||||
throw new Error('golden viewport does not show the complete device light-source controls');
|
||||
}
|
||||
if (scenario.devicePresentationPreview) {
|
||||
const dialog = card.renderRoot.querySelector('hp-dialog');
|
||||
const body = dialog?.querySelector('.body');
|
||||
const preview = dialog?.querySelector('hp-device-preview');
|
||||
await preview?.updateComplete;
|
||||
if (!body || !preview) throw new Error('golden device presentation preview is missing');
|
||||
const bodyRect = body.getBoundingClientRect();
|
||||
const previewRect = preview.getBoundingClientRect();
|
||||
body.scrollTop += previewRect.top - bodyRect.top - 180;
|
||||
await frame();
|
||||
const visibleBody = body.getBoundingClientRect();
|
||||
const visiblePreview = preview.getBoundingClientRect();
|
||||
if (visiblePreview.top < visibleBody.top - 1 || visiblePreview.bottom > visibleBody.bottom + 1)
|
||||
throw new Error('golden viewport does not show the device presentation preview');
|
||||
}
|
||||
if (scenario.openHelp) {
|
||||
const help = card.renderRoot.querySelector(`hp-help[data-help-key="${scenario.openHelp}"]`);
|
||||
await help?.updateComplete;
|
||||
@@ -585,15 +557,6 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
await dialog?.updateComplete;
|
||||
dialog?.renderRoot?.querySelector('.close')?.focus();
|
||||
}
|
||||
} else if (scenario.dialog === 'device-info') {
|
||||
const device = card._devices.find((item) => item.id === scenario.deviceId);
|
||||
if (!device) throw new Error(`golden device missing: ${scenario.deviceId}`);
|
||||
card._infoCard = device;
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
if (!card.renderRoot.querySelector('hp-dialog'))
|
||||
throw new Error(`golden device info did not open: ${scenario.deviceId}`);
|
||||
} else if (scenario.dialog === 'backup-full' || scenario.dialog === 'backup-space') {
|
||||
const full = scenario.dialog === 'backup-full';
|
||||
card._backupImportDialog = {
|
||||
|
||||
@@ -250,9 +250,9 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
|
||||
`~/.git-credentials`; pushes go over SSH with the `ha_jb` key. The old classic PAT
|
||||
expired and is gone.
|
||||
3. Privacy: legacy real-house plan sources (`assets/`) and screenshots were
|
||||
removed from the current tree. Public documentation images are generated
|
||||
from synthetic fixtures by `npm run docs:capture` and indexed in
|
||||
`docs/images/manifest.json`. Old images persist in git history and release
|
||||
removed from the current tree. Public documentation images are generated
|
||||
from synthetic fixtures by `npm run build && node demo/docs/capture.mjs` and indexed in
|
||||
`docs/images/screenshots.json`. Old images persist in git history and release
|
||||
archives; history rewrite is deliberately not done because it would break
|
||||
release tags and HACS installs.
|
||||
4. Stale files on the mount that cannot be deleted from the sandbox: `src/data/` leftovers,
|
||||
|
||||
|
Before Width: | Height: | Size: 342 KiB After Width: | Height: | Size: 342 KiB |
|
Before Width: | Height: | Size: 328 KiB After Width: | Height: | Size: 328 KiB |
|
Before Width: | Height: | Size: 282 KiB After Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 286 KiB After Width: | Height: | Size: 286 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 128 KiB |
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"version": 1,
|
||||
"fixture": "synthetic-only",
|
||||
"sourceFingerprint": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a",
|
||||
"captureScriptSha256": "a0022c98553c5baae8335d7ba26f64129699c4ef6162dd1d88df9f70226ec404",
|
||||
"command": "npm run docs:capture",
|
||||
"sourceFingerprint": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
|
||||
"captureScriptSha256": "34f2219790d46efd8250e7a1bd829cb8fc0b0547e1260635fefa52407551b41b",
|
||||
"command": "npm run build && node demo/docs/capture.mjs",
|
||||
"scenarios": {
|
||||
"view-desktop": {
|
||||
"file": "01-view-desktop.png",
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a",
|
||||
"sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
|
||||
"imageSha256": "d36b6f9f8139f31ef73a780c6511a640a26055efd9d7a24c24fd48b1d8379bf0"
|
||||
},
|
||||
"view-touch": {
|
||||
@@ -24,7 +24,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a",
|
||||
"sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
|
||||
"imageSha256": "358e25ff9984d0fb0c03cfbb848df40c425fdfe64cd5f4f613b1e754ca9d4249"
|
||||
},
|
||||
"space-create": {
|
||||
@@ -35,7 +35,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a",
|
||||
"sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
|
||||
"imageSha256": "c53db2e5c642a5549c13f3c93a5b359fed69bdb2621bf71a243a877ffcb95e6b"
|
||||
},
|
||||
"room-contour-close": {
|
||||
@@ -46,8 +46,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a",
|
||||
"imageSha256": "59fe5b9df034d255840e7ae6984efb675d39e84ad80dee6f9ad9394c54fb9cda"
|
||||
"sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
|
||||
"imageSha256": "2f4770869f04c8d7f1e8ff33af1e8bd79f45ad6d97be6d5e6135b3a82fec9e4c"
|
||||
},
|
||||
"plan-context-tray": {
|
||||
"file": "05-plan-context-tray.png",
|
||||
@@ -57,8 +57,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a",
|
||||
"imageSha256": "997bba208897b219051131dc51254ee3c76ed9f73597fde33070ac8cefdcd745"
|
||||
"sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
|
||||
"imageSha256": "47d8fc2ac1c14cb699e990ca9c00d89b9ac57f6cc76b40420ceb26b4d7f50df4"
|
||||
},
|
||||
"device-editor": {
|
||||
"file": "06-device-editor.png",
|
||||
@@ -68,8 +68,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a",
|
||||
"imageSha256": "7a22a81e2abedf5a520bd36b7509d953cbfa9962b48c0ffbc233666a6215c04b"
|
||||
"sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
|
||||
"imageSha256": "7c3e25534fc819c45431907616c3d523961505859ee68af27cce2daec9f04fb0"
|
||||
},
|
||||
"device-display-preview": {
|
||||
"file": "06-device-display-preview.png",
|
||||
@@ -79,8 +79,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a",
|
||||
"imageSha256": "f6014caed7b7d28790b8548996ba09d806a4e4d51fbb7e8d3fb3c582ebe49167"
|
||||
"sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
|
||||
"imageSha256": "df3a54395c83bffd7a161f1fb3de0143e12e76567535aedf9883873ead74df91"
|
||||
},
|
||||
"background-editor": {
|
||||
"file": "07-background-editor.png",
|
||||
@@ -90,7 +90,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a",
|
||||
"sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
|
||||
"imageSha256": "4e7b3b1220a3fe104cf11e5b32a31b343dc95c45fd2cbe3365d145f0a66a560d"
|
||||
},
|
||||
"room-card": {
|
||||
@@ -101,7 +101,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a",
|
||||
"sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
|
||||
"imageSha256": "176abba71d41cfb045a33f82a794d9fbb2a5d3c48e6df66e6ec3a8e448311b11"
|
||||
},
|
||||
"device-info": {
|
||||
@@ -112,8 +112,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a",
|
||||
"imageSha256": "655873ba709a2a702fde533b33ef109343ce0c6ca85ff985fdd7c4d41c877b33"
|
||||
"sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
|
||||
"imageSha256": "2199ed88b215bf2bff63bf665028f78b2aa6a92c3032b803931790f2ef071893"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,6 @@
|
||||
"golden:verify": "node demo/golden/run.mjs --mode=verify",
|
||||
"continuity:screencast": "node demo/screencast_visual_continuity.mjs",
|
||||
"golden:accept": "node demo/golden/accept.mjs",
|
||||
"docs:capture": "npm run build && node demo/docs/capture.mjs",
|
||||
"docs:check": "node scripts/check-docs.mjs",
|
||||
"release:check": "node scripts/release-prerelease.mjs --check",
|
||||
"release:prerelease": "node scripts/release-prerelease.mjs",
|
||||
"prepare": "node scripts/install-hooks.mjs"
|
||||
|
||||
@@ -123,16 +123,17 @@ for (const relative of PUBLIC_DOCS.slice(0, 4)) {
|
||||
}
|
||||
}
|
||||
|
||||
const manifestPath = resolve(ROOT, 'docs/images/manifest.json');
|
||||
const manifestPath = resolve(ROOT, 'docs/images/screenshots.json');
|
||||
if (!existsSync(manifestPath)) {
|
||||
errors.push('docs/images/manifest.json: missing screenshot manifest');
|
||||
errors.push('docs/images/screenshots.json: missing screenshot index');
|
||||
} else {
|
||||
const manifest = JSON.parse(canonicalText(manifestPath));
|
||||
if (manifest.fixture !== 'synthetic-only') errors.push('screenshot manifest must declare synthetic-only fixture');
|
||||
if (manifest.sourceFingerprint !== sourceFingerprint(ROOT)) errors.push('screenshot source fingerprint is stale; run npm run docs:capture');
|
||||
if (manifest.sourceFingerprint !== sourceFingerprint(ROOT))
|
||||
errors.push('screenshot source fingerprint is stale; run npm run build && node demo/docs/capture.mjs');
|
||||
const scriptPath = resolve(ROOT, 'demo/docs/capture.mjs');
|
||||
if (manifest.captureScriptSha256 !== sha256(readFileSync(scriptPath)))
|
||||
errors.push('screenshot capture script changed; run npm run docs:capture');
|
||||
errors.push('screenshot capture script changed; run npm run build && node demo/docs/capture.mjs');
|
||||
const ids = Object.keys(manifest.scenarios || {});
|
||||
if (JSON.stringify(ids.sort()) !== JSON.stringify([...EXPECTED_SCREENSHOTS].sort()))
|
||||
errors.push('screenshot manifest scenario set is incomplete');
|
||||
|
||||