cursedbelt 4.1.0 → 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/react/master-lock/MasterLockDialog.d.ts.map +1 -1
- package/dist/react/master-lock/MasterLockDialog.js +25 -5
- package/dist/react/master-lock/MasterLockDialog.js.map +1 -1
- package/dist/react/master-lock/MasterLockProvider.d.ts +19 -0
- package/dist/react/master-lock/MasterLockProvider.d.ts.map +1 -1
- package/dist/react/master-lock/MasterLockProvider.js +19 -0
- package/dist/react/master-lock/MasterLockProvider.js.map +1 -1
- package/dist/react/media/CanvasVideoPlayer.d.ts +3 -2
- package/dist/react/media/CanvasVideoPlayer.d.ts.map +1 -1
- package/dist/react/media/CanvasVideoPlayer.js +4 -3
- package/dist/react/media/CanvasVideoPlayer.js.map +1 -1
- package/dist/react/media/FrameGrabber.d.ts +16 -2
- package/dist/react/media/FrameGrabber.d.ts.map +1 -1
- package/dist/react/media/FrameGrabber.js +68 -43
- package/dist/react/media/FrameGrabber.js.map +1 -1
- package/dist/react/media/PictureEditor.d.ts +17 -2
- package/dist/react/media/PictureEditor.d.ts.map +1 -1
- package/dist/react/media/PictureEditor.js +33 -7
- package/dist/react/media/PictureEditor.js.map +1 -1
- package/dist/react/media/VideoChapterEditor.d.ts +17 -2
- package/dist/react/media/VideoChapterEditor.d.ts.map +1 -1
- package/dist/react/media/VideoChapterEditor.js +21 -3
- package/dist/react/media/VideoChapterEditor.js.map +1 -1
- package/dist/react/media/VideoCutEditor.d.ts +16 -2
- package/dist/react/media/VideoCutEditor.d.ts.map +1 -1
- package/dist/react/media/VideoCutEditor.js +21 -3
- package/dist/react/media/VideoCutEditor.js.map +1 -1
- package/dist/react/media/VideoPlayer.d.ts +37 -9
- package/dist/react/media/VideoPlayer.d.ts.map +1 -1
- package/dist/react/media/VideoPlayer.js +130 -39
- package/dist/react/media/VideoPlayer.js.map +1 -1
- package/dist/react/media/hlsSource.d.ts +24 -3
- package/dist/react/media/hlsSource.d.ts.map +1 -1
- package/dist/react/media/hlsSource.js +109 -8
- package/dist/react/media/hlsSource.js.map +1 -1
- package/dist/react/media/pictureEditor.css +2 -0
- package/dist/react/media/sourceFault.d.ts +146 -0
- package/dist/react/media/sourceFault.d.ts.map +1 -0
- package/dist/react/media/sourceFault.js +139 -0
- package/dist/react/media/sourceFault.js.map +1 -0
- package/dist/react/media/videoChapterEditor.css +19 -0
- package/dist/react/media/videoCutEditor.css +22 -0
- package/dist/react/media-gallery/MediaGallery.d.ts +12 -1
- package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
- package/dist/react/media-gallery/MediaGallery.js +166 -13
- package/dist/react/media-gallery/MediaGallery.js.map +1 -1
- package/dist/react/media-gallery/galleryItemMedia.d.ts +4 -0
- package/dist/react/media-gallery/galleryItemMedia.d.ts.map +1 -1
- package/dist/react/media-gallery/galleryItemMedia.js +27 -3
- package/dist/react/media-gallery/galleryItemMedia.js.map +1 -1
- package/dist/react/media-gallery/mediaGalleryModel.d.ts.map +1 -1
- package/dist/react/media-gallery/mediaGalleryModel.js +5 -0
- package/dist/react/media-gallery/mediaGalleryModel.js.map +1 -1
- package/dist/react/media-gallery/types.d.ts +23 -0
- package/dist/react/media-gallery/types.d.ts.map +1 -1
- package/dist/scripts/guardrailsEnforce.d.ts +1 -0
- package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
- package/dist/styles-areas/master-lock.css +1 -1
- package/dist/styles-areas/media-gallery.css +1 -1
- package/dist/styles-areas/media.css +1 -1
- package/package.json +3 -2
- package/scripts/guardrailsEnforce.spec.ts +244 -1
- package/scripts/guardrailsEnforce.ts +224 -7
- package/src/iframesMakeNoRealRequest.spec.ts +92 -0
- package/src/react/components/IsolatedFrame.spec.tsx +27 -9
- package/src/react/master-lock/MasterLockDialog.tsx +24 -5
- package/src/react/master-lock/MasterLockProvider.spec.tsx +39 -0
- package/src/react/master-lock/MasterLockProvider.tsx +19 -0
- package/src/react/media/CanvasVideoPlayer.tsx +11 -6
- package/src/react/media/FrameGrabber.spec.tsx +57 -1
- package/src/react/media/FrameGrabber.tsx +138 -84
- package/src/react/media/PictureEditor.spec.tsx +90 -0
- package/src/react/media/PictureEditor.tsx +78 -23
- package/src/react/media/VideoChapterEditor.spec.tsx +86 -0
- package/src/react/media/VideoChapterEditor.tsx +64 -18
- package/src/react/media/VideoCutEditor.spec.tsx +44 -0
- package/src/react/media/VideoCutEditor.tsx +63 -18
- package/src/react/media/VideoPlayer.spec.tsx +101 -0
- package/src/react/media/VideoPlayer.tsx +221 -73
- package/src/react/media/hlsSource.answer.spec.ts +255 -0
- package/src/react/media/hlsSource.ts +136 -8
- package/src/react/media/pictureEditor.css +2 -0
- package/src/react/media/sourceFault.spec.tsx +224 -0
- package/src/react/media/sourceFault.tsx +220 -0
- package/src/react/media/videoChapterEditor.css +19 -0
- package/src/react/media/videoCutEditor.css +22 -0
- package/src/react/media-gallery/MediaGallery.spec.tsx +288 -0
- package/src/react/media-gallery/MediaGallery.tsx +356 -45
- package/src/react/media-gallery/galleryItemMedia.tsx +27 -4
- package/src/react/media-gallery/mediaGalleryModel.spec.ts +36 -0
- package/src/react/media-gallery/mediaGalleryModel.ts +4 -0
- package/src/react/media-gallery/types.ts +23 -0
- package/src/styles-areas/master-lock.css +1 -1
- package/src/styles-areas/media-gallery.css +1 -1
- package/src/styles-areas/media.css +1 -1
|
@@ -8,13 +8,31 @@
|
|
|
8
8
|
// invoked from each package's `lint`/`done` script, exiting non-zero on violation).
|
|
9
9
|
//
|
|
10
10
|
// Usage:
|
|
11
|
-
// bun run ../cursedbelt/scripts/guardrailsEnforce.ts <rule>[,<rule>...]
|
|
12
|
-
// [--
|
|
11
|
+
// bun run ../cursedbelt/scripts/guardrailsEnforce.ts <rule>[,<rule>...]
|
|
12
|
+
// [--scope <name> | --baseline <path>]
|
|
13
|
+
// [--reimplements <Name,Name,…> | --reimplements-from-belt]
|
|
14
|
+
// [--exclude <path>[,<path>…]] [root...]
|
|
13
15
|
//
|
|
14
16
|
// `--scope <name>` selects the ratchet baseline row (see "Ratchet baseline" below) for
|
|
15
17
|
// the calling package — `cursedbelt` and `cursedshell` each pass their own scope name
|
|
16
18
|
// from their `lint` script. Omitting `--scope` defaults every rule's baseline to 0.
|
|
17
19
|
//
|
|
20
|
+
// A CONSUMING APP uses the other three instead, and needs no wrapper script of its own:
|
|
21
|
+
//
|
|
22
|
+
// "guardrails": "bun node_modules/cursedbelt/scripts/guardrailsEnforce.ts <rules>
|
|
23
|
+
// --baseline scripts/guardrails-baseline.json --reimplements-from-belt src"
|
|
24
|
+
//
|
|
25
|
+
// --baseline <path> a flat `{ "<rule>": <count> }` ratchet in the CALLER's
|
|
26
|
+
// repo, TWO-SIDED — exceeding a row fails, and so does
|
|
27
|
+
// falling below one, which prints the number to paste.
|
|
28
|
+
// The rows and the scanned rules must agree exactly, both
|
|
29
|
+
// directions. See "a CONSUMER's ratchet" below.
|
|
30
|
+
// --reimplements-from-belt derive `no-local-reimplementation`'s name list from THIS
|
|
31
|
+
// package's own react index files, so no app hand-maintains
|
|
32
|
+
// 384 names. Fails closed on an empty list.
|
|
33
|
+
// --exclude <path>[,<path>…] drop build output that lives under a source root. Fails
|
|
34
|
+
// if it matches nothing.
|
|
35
|
+
//
|
|
18
36
|
// Currently implemented rules:
|
|
19
37
|
// no-raw-action-button — STANDARDS.md §4a: "a plugin or generated app must not render
|
|
20
38
|
// a raw <button> for an action (a raw <button> is acceptable only for a purely
|
|
@@ -148,7 +166,7 @@
|
|
|
148
166
|
// ignored (its violations still fire) and a warning is printed.
|
|
149
167
|
|
|
150
168
|
import { type Dirent, existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
151
|
-
import { basename, extname, join, relative } from "node:path";
|
|
169
|
+
import { basename, extname, join, relative, resolve } from "node:path";
|
|
152
170
|
|
|
153
171
|
const IGNORE_DIR_NAMES = new Set(["node_modules", "dist", "dist-deploy", ".git", "build", "coverage"]);
|
|
154
172
|
const SOURCE_EXTENSIONS = new Set([".tsx", ".jsx", ".css"]);
|
|
@@ -1678,18 +1696,122 @@ function loadBaseline(): Baseline {
|
|
|
1678
1696
|
}
|
|
1679
1697
|
}
|
|
1680
1698
|
|
|
1699
|
+
// ── `--baseline <path>`: a CONSUMER's ratchet, in the consumer's own repo ───────
|
|
1700
|
+
// The packaged baseline above cannot be a consuming app's: it lives inside
|
|
1701
|
+
// `node_modules/cursedbelt`, so nobody can check their debt ceiling in beside their
|
|
1702
|
+
// code. Nine apps each carried an identical ~140-line wrapper to work around that
|
|
1703
|
+
// (`apps/*/scripts/guardrails.ts`, deleted 2026-09-18 by tasks/…/202-*.md); this flag
|
|
1704
|
+
// is that wrapper, folded into the one place it belongs.
|
|
1705
|
+
//
|
|
1706
|
+
// The file is FLAT — `{ "<rule>": <count> }`, no scope level, because a repo's
|
|
1707
|
+
// baseline is about one repo. A `"$comment"` key is reserved for the why (desk's row
|
|
1708
|
+
// list carries fifty lines of it) and is the only non-numeric value tolerated:
|
|
1709
|
+
// anything else non-numeric is a typo in a row, and a typo read as `?? 0` would demand
|
|
1710
|
+
// a whole inherited pile in one commit, so it is refused by name instead.
|
|
1711
|
+
//
|
|
1712
|
+
// 🔴 It is TWO-SIDED, and the packaged `--scope` baseline is not. Deliberate, and the
|
|
1713
|
+
// asymmetry is the point: `--scope` names a row the CALLER cannot edit (it ships inside
|
|
1714
|
+
// the package), so failing a consumer for a number only a cursedbelt release can lower
|
|
1715
|
+
// would be a gate nobody can turn green. `--baseline` names a file in the caller's own
|
|
1716
|
+
// repo, which they lower in the same commit — so falling below a row fails, printing
|
|
1717
|
+
// the number to paste. A one-sided ratchet accumulates slack: fix forty colours, leave
|
|
1718
|
+
// the row at 353, and forty may quietly come back. Same idiom as the generation's
|
|
1719
|
+
// `check-paths` baseline — inherited debt only falls.
|
|
1720
|
+
type FlatBaseline = Record<string, number>;
|
|
1721
|
+
|
|
1722
|
+
const BASELINE_COMMENT_KEY = "$comment";
|
|
1723
|
+
|
|
1724
|
+
/** Parse a flat `{rule: count}` baseline, refusing anything that is not a count. */
|
|
1725
|
+
function parseFlatBaseline(json: string, label: string): FlatBaseline {
|
|
1726
|
+
let raw: unknown;
|
|
1727
|
+
try {
|
|
1728
|
+
raw = JSON.parse(json);
|
|
1729
|
+
} catch (error) {
|
|
1730
|
+
throw new Error(`${label}: invalid JSON — ${(error as Error).message}`);
|
|
1731
|
+
}
|
|
1732
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
1733
|
+
throw new Error(`${label}: expected an object of {"<rule>": <count>}`);
|
|
1734
|
+
}
|
|
1735
|
+
const rows: FlatBaseline = {};
|
|
1736
|
+
for (const [rule, value] of Object.entries(raw as Record<string, unknown>)) {
|
|
1737
|
+
if (rule === BASELINE_COMMENT_KEY) continue;
|
|
1738
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
|
|
1739
|
+
throw new Error(`${label}: "${rule}" is ${JSON.stringify(value)}, not a violation count`);
|
|
1740
|
+
}
|
|
1741
|
+
rows[rule] = value;
|
|
1742
|
+
}
|
|
1743
|
+
return rows;
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
/**
|
|
1747
|
+
* Both directions of "the rows and the rules must agree", which is the hole this
|
|
1748
|
+
* closes for the wrappers that used to do it by hand. A rule with no row is scored 0
|
|
1749
|
+
* and demands its whole inherited pile in one commit; a row with no rule is coverage
|
|
1750
|
+
* that is not there — the same defect as a `check-paths` baseline entry matching
|
|
1751
|
+
* nothing. Returns the complaints, empty when the two lists agree.
|
|
1752
|
+
*/
|
|
1753
|
+
function baselineDisagreements(rows: FlatBaseline, ruleNames: readonly string[]): string[] {
|
|
1754
|
+
const complaints: string[] = [];
|
|
1755
|
+
const missing = ruleNames.filter((name) => !(name in rows));
|
|
1756
|
+
const orphans = Object.keys(rows).filter((name) => !ruleNames.includes(name));
|
|
1757
|
+
if (missing.length > 0) complaints.push(`no row for rule(s) being scanned: ${missing.join(", ")}`);
|
|
1758
|
+
if (orphans.length > 0) complaints.push(`row(s) for rules nobody scans: ${orphans.join(", ")}`);
|
|
1759
|
+
return complaints;
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
// ── `--reimplements-from-belt`: the name list, off this package's own exports ───
|
|
1763
|
+
// `no-local-reimplementation` matches app-declared names against the ones cursedbelt
|
|
1764
|
+
// exports (384 of them as this lands). A hand-kept copy of a dependency's export list
|
|
1765
|
+
// is a second source of truth that drifts the moment the belt publishes, so the list
|
|
1766
|
+
// is read off the react index files of the INSTALLED package — this file's own
|
|
1767
|
+
// package, whatever version resolved. `--reimplements <Name,…>` stays for a caller
|
|
1768
|
+
// with a list of its own (the CURSEDBELT_INVENTORY.md path in the rule's header).
|
|
1769
|
+
const BELT_REACT_ROOT = join(import.meta.dir, "..", "src", "react");
|
|
1770
|
+
|
|
1771
|
+
export function beltExportedComponentNames(reactRoot: string = BELT_REACT_ROOT): string[] {
|
|
1772
|
+
const names = new Set<string>();
|
|
1773
|
+
const indexFiles: string[] = [];
|
|
1774
|
+
// `withFileTypes` rather than a `statSync` per entry — the react tree is ~1,300
|
|
1775
|
+
// entries, and one syscall each is 4s of a gate that is meant to be under a minute.
|
|
1776
|
+
const collect = (dir: string) => {
|
|
1777
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
1778
|
+
const full = join(dir, entry.name);
|
|
1779
|
+
if (entry.isDirectory()) collect(full);
|
|
1780
|
+
else if (entry.name === "index.ts" || entry.name === "index.tsx") indexFiles.push(full);
|
|
1781
|
+
}
|
|
1782
|
+
};
|
|
1783
|
+
collect(reactRoot);
|
|
1784
|
+
for (const file of indexFiles) {
|
|
1785
|
+
const source = readFileSync(file, "utf8");
|
|
1786
|
+
for (const block of source.matchAll(/export\s*\{([^}]*)\}/gs)) {
|
|
1787
|
+
for (const part of block[1]?.split(",") ?? []) {
|
|
1788
|
+
const trimmed = part.trim();
|
|
1789
|
+
if (!trimmed || trimmed.startsWith("type ")) continue;
|
|
1790
|
+
const name = (trimmed.split(/\s+as\s+/).pop() ?? "").trim();
|
|
1791
|
+
if (/^[A-Z][A-Za-z0-9]*$/.test(name)) names.add(name);
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
return [...names];
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1681
1798
|
function main() {
|
|
1682
1799
|
const [ruleArg, ...rest] = process.argv.slice(2);
|
|
1683
1800
|
const ruleNames = ruleArg ? ruleArg.split(",") : [];
|
|
1684
1801
|
const rules = ruleNames.map((n) => RULES[n]).filter((r): r is Rule => Boolean(r));
|
|
1685
1802
|
if (ruleNames.length === 0 || rules.length !== ruleNames.length) {
|
|
1686
1803
|
console.error(
|
|
1687
|
-
`Usage: bun run guardrailsEnforce.ts <rule>[,<rule>...] [--scope <name>
|
|
1804
|
+
`Usage: bun run guardrailsEnforce.ts <rule>[,<rule>...] [--scope <name> | --baseline <path>]\n` +
|
|
1805
|
+
` [--reimplements <Name,…> | --reimplements-from-belt] [--exclude <path>[,<path>…]] [root...]\n` +
|
|
1806
|
+
`Known rules: ${Object.keys(RULES).join(", ")}`,
|
|
1688
1807
|
);
|
|
1689
1808
|
process.exit(2);
|
|
1690
1809
|
}
|
|
1691
1810
|
|
|
1692
1811
|
let scope: string | undefined;
|
|
1812
|
+
let baselinePath: string | undefined;
|
|
1813
|
+
let fromBelt = false;
|
|
1814
|
+
const excludes: string[] = [];
|
|
1693
1815
|
const roots: string[] = [];
|
|
1694
1816
|
for (let i = 0; i < rest.length; i++) {
|
|
1695
1817
|
if (rest[i] === "--scope") {
|
|
@@ -1697,6 +1819,12 @@ function main() {
|
|
|
1697
1819
|
i++;
|
|
1698
1820
|
continue;
|
|
1699
1821
|
}
|
|
1822
|
+
// A flat `{rule: count}` ratchet in the CALLER's repo — see "a CONSUMER's ratchet".
|
|
1823
|
+
if (rest[i] === "--baseline") {
|
|
1824
|
+
baselinePath = rest[i + 1];
|
|
1825
|
+
i++;
|
|
1826
|
+
continue;
|
|
1827
|
+
}
|
|
1700
1828
|
// The inventoried component names `no-local-reimplementation` looks for. Passed
|
|
1701
1829
|
// in rather than hardcoded so the list lives beside the inventory it comes from
|
|
1702
1830
|
// (fleet/standards/CURSEDBELT_INVENTORY.md) — see that rule's header.
|
|
@@ -1705,10 +1833,73 @@ function main() {
|
|
|
1705
1833
|
i++;
|
|
1706
1834
|
continue;
|
|
1707
1835
|
}
|
|
1836
|
+
if (rest[i] === "--reimplements-from-belt") {
|
|
1837
|
+
fromBelt = true;
|
|
1838
|
+
continue;
|
|
1839
|
+
}
|
|
1840
|
+
// Build output that lives under a source root (desk's `src/generated/assets.ts` is
|
|
1841
|
+
// the compiled browser app, inlined as a module and rewritten by every build). Named
|
|
1842
|
+
// here rather than by listing every sibling root, because "name the siblings" fails
|
|
1843
|
+
// OPEN — a directory added later is silently unscanned.
|
|
1844
|
+
if (rest[i] === "--exclude") {
|
|
1845
|
+
for (const part of (rest[i + 1] ?? "").split(",")) {
|
|
1846
|
+
const trimmed = part.trim();
|
|
1847
|
+
if (trimmed) excludes.push(resolve(trimmed));
|
|
1848
|
+
}
|
|
1849
|
+
i++;
|
|
1850
|
+
continue;
|
|
1851
|
+
}
|
|
1708
1852
|
roots.push(rest[i]);
|
|
1709
1853
|
}
|
|
1854
|
+
|
|
1855
|
+
if (scope && baselinePath) {
|
|
1856
|
+
console.error("--scope and --baseline are two different ratchets; pass one.");
|
|
1857
|
+
process.exit(2);
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
let flatBaseline: FlatBaseline | undefined;
|
|
1861
|
+
if (baselinePath) {
|
|
1862
|
+
if (!existsSync(baselinePath)) {
|
|
1863
|
+
console.error(`--baseline ${baselinePath}: no such file (relative to ${process.cwd()})`);
|
|
1864
|
+
process.exit(2);
|
|
1865
|
+
}
|
|
1866
|
+
try {
|
|
1867
|
+
flatBaseline = parseFlatBaseline(readFileSync(baselinePath, "utf8"), baselinePath);
|
|
1868
|
+
} catch (error) {
|
|
1869
|
+
console.error((error as Error).message);
|
|
1870
|
+
process.exit(2);
|
|
1871
|
+
}
|
|
1872
|
+
const complaints = baselineDisagreements(flatBaseline, ruleNames);
|
|
1873
|
+
if (complaints.length > 0) {
|
|
1874
|
+
console.error(`${baselinePath} and the rules being scanned disagree:`);
|
|
1875
|
+
for (const complaint of complaints) console.error(` ${complaint}`);
|
|
1876
|
+
process.exit(2);
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
if (fromBelt) {
|
|
1881
|
+
// 🔴 Fails CLOSED. An empty list makes `no-local-reimplementation` inert — it would
|
|
1882
|
+
// print "0 violations" for a check that never ran, and a ratchet row of 0 would then
|
|
1883
|
+
// look earned. If the react tree has moved, the run stops instead.
|
|
1884
|
+
let names: string[] = [];
|
|
1885
|
+
try {
|
|
1886
|
+
names = beltExportedComponentNames();
|
|
1887
|
+
} catch (error) {
|
|
1888
|
+
console.error(`--reimplements-from-belt: cannot read ${BELT_REACT_ROOT} — ${(error as Error).message}`);
|
|
1889
|
+
process.exit(2);
|
|
1890
|
+
}
|
|
1891
|
+
if (names.length === 0) {
|
|
1892
|
+
console.error(`--reimplements-from-belt: ${BELT_REACT_ROOT} exports no component names — refusing to scan against an empty list.`);
|
|
1893
|
+
process.exit(2);
|
|
1894
|
+
}
|
|
1895
|
+
setReimplementedNames(names);
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1710
1898
|
const baseline = loadBaseline();
|
|
1711
|
-
const scopeBaseline = (scope
|
|
1899
|
+
const scopeBaseline: Record<string, number> = flatBaseline ?? (scope ? (baseline[scope] ?? {}) : {});
|
|
1900
|
+
// `--baseline` names a file the caller owns and can lower; `--scope` does not. That is
|
|
1901
|
+
// the whole reason one is two-sided and the other suggests.
|
|
1902
|
+
const twoSided = flatBaseline !== undefined;
|
|
1712
1903
|
|
|
1713
1904
|
const searchRoots = roots.length > 0 ? roots : ["."];
|
|
1714
1905
|
const files: string[] = [];
|
|
@@ -1721,6 +1912,17 @@ function main() {
|
|
|
1721
1912
|
// every directory, e.g. a plugin package with no `apps/` dir)
|
|
1722
1913
|
}
|
|
1723
1914
|
}
|
|
1915
|
+
if (excludes.length > 0) {
|
|
1916
|
+
const kept = files.filter((file) => !excludes.some((dir) => resolve(file) === dir || resolve(file).startsWith(`${dir}/`)));
|
|
1917
|
+
if (kept.length === files.length) {
|
|
1918
|
+
// An exclusion matching nothing is either a typo or a directory that has moved,
|
|
1919
|
+
// and both read as "that build output is handled" while it is being scanned.
|
|
1920
|
+
console.error(`--exclude matched no scanned file: ${excludes.join(", ")}`);
|
|
1921
|
+
process.exit(2);
|
|
1922
|
+
}
|
|
1923
|
+
files.length = 0;
|
|
1924
|
+
files.push(...kept);
|
|
1925
|
+
}
|
|
1724
1926
|
|
|
1725
1927
|
const sourcesByFile = new Map<string, string>();
|
|
1726
1928
|
for (const file of files) sourcesByFile.set(file, readFileSync(file, "utf8"));
|
|
@@ -1751,13 +1953,28 @@ function main() {
|
|
|
1751
1953
|
}
|
|
1752
1954
|
|
|
1753
1955
|
const allowedByBaseline = scopeBaseline[rule.name] ?? 0;
|
|
1754
|
-
if (violations.length
|
|
1956
|
+
if (violations.length === allowedByBaseline) {
|
|
1755
1957
|
console.log(
|
|
1756
1958
|
`✓ ${rule.name}: ${violations.length} violation(s) across ${files.length} files (baseline allows ${allowedByBaseline})`,
|
|
1757
1959
|
);
|
|
1758
|
-
|
|
1960
|
+
continue;
|
|
1961
|
+
}
|
|
1962
|
+
if (violations.length < allowedByBaseline) {
|
|
1963
|
+
// The ratchet's other side. Under `--scope` this is a suggestion printed at the
|
|
1964
|
+
// end; under `--baseline` it is a failure, because the caller owns that file and
|
|
1965
|
+
// leaving slack in it is how a row stops meaning anything.
|
|
1966
|
+
if (!twoSided) {
|
|
1967
|
+
console.log(
|
|
1968
|
+
`✓ ${rule.name}: ${violations.length} violation(s) across ${files.length} files (baseline allows ${allowedByBaseline})`,
|
|
1969
|
+
);
|
|
1759
1970
|
lowerableBaselines[rule.name] = violations.length;
|
|
1971
|
+
continue;
|
|
1760
1972
|
}
|
|
1973
|
+
anyExceededBaseline = true;
|
|
1974
|
+
console.error(
|
|
1975
|
+
`✗ ${rule.name}: ${violations.length} violation(s), below the baseline of ${allowedByBaseline} — lower "${rule.name}" ` +
|
|
1976
|
+
`to ${violations.length} in ${baselinePath} (the ratchet only falls)`,
|
|
1977
|
+
);
|
|
1761
1978
|
continue;
|
|
1762
1979
|
}
|
|
1763
1980
|
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 🔴 An `<iframe src>` in a spec must not put a request on a real socket (2026-09-17).
|
|
3
|
+
*
|
|
4
|
+
* happy-dom loads an iframe's `src` for real, and `happydom.ts` registers the window at
|
|
5
|
+
* `http://localhost/`, so `<IsolatedFrame src='/preview/abc'>` used to issue an actual
|
|
6
|
+
* request to `http://localhost/preview/abc`. Two things came of that, and both are
|
|
7
|
+
* expensive:
|
|
8
|
+
*
|
|
9
|
+
* 1. Nothing listens there, so the socket errored — after `afterEach(cleanup)` had
|
|
10
|
+
* unmounted the frame, and therefore after the spec that caused it had finished.
|
|
11
|
+
* `bun test --parallel=6` hands a late unhandled error to whatever file is running, and
|
|
12
|
+
* the stack (`happy-dom/lib/fetch/Fetch.js:540`) names no cursedbelt code at all. One
|
|
13
|
+
* session spent twenty minutes reading that error against a test which is 8 pass /
|
|
14
|
+
* 0 fail on its own.
|
|
15
|
+
* 2. If anything ever DOES listen on that host and path, the unit suite hits it. On a
|
|
16
|
+
* machine that runs one shared dev host, "nothing is listening" is a property of the
|
|
17
|
+
* afternoon, not of the test.
|
|
18
|
+
*
|
|
19
|
+
* `happydom.ts` fixes it with `settings.navigation.disableChildFrameNavigation` — see the
|
|
20
|
+
* comment there for the measured trap in the flag you would reach for first.
|
|
21
|
+
*
|
|
22
|
+
* ── Why a live server rather than reading the setting ───────────────────────
|
|
23
|
+
* The setting is one line in a preload nobody opens, and the symptom of losing it is an
|
|
24
|
+
* error attributed to an innocent file — the exact failure this repo just paid for. So the
|
|
25
|
+
* assertion is the behaviour, not the spelling: bind a real port, frame it, and prove the
|
|
26
|
+
* handler never ran. Verified failing by removing the setting from `happydom.ts`, where
|
|
27
|
+
* `hits` becomes 1.
|
|
28
|
+
*/
|
|
29
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
30
|
+
import type { Server } from 'bun';
|
|
31
|
+
|
|
32
|
+
type AnyServer = Server<undefined>;
|
|
33
|
+
|
|
34
|
+
/** `globalThis.happyDOM`, which `@happy-dom/global-registrator` defines but does not type. */
|
|
35
|
+
const happyDOM = (globalThis as unknown as {
|
|
36
|
+
happyDOM?: {
|
|
37
|
+
settings: { navigation: { disableChildFrameNavigation: boolean } };
|
|
38
|
+
waitUntilComplete: () => Promise<void>;
|
|
39
|
+
};
|
|
40
|
+
}).happyDOM;
|
|
41
|
+
|
|
42
|
+
let server: AnyServer | undefined;
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
server?.stop(true);
|
|
46
|
+
server = undefined;
|
|
47
|
+
document.body.innerHTML = '';
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('an iframe src in a spec never reaches the network', () => {
|
|
51
|
+
test('framing a URL that IS being served does not call the server', async () => {
|
|
52
|
+
let hits = 0;
|
|
53
|
+
server = Bun.serve({
|
|
54
|
+
port: 0,
|
|
55
|
+
fetch: () => {
|
|
56
|
+
hits += 1;
|
|
57
|
+
return new Response('<p>framed</p>', { headers: { 'content-type': 'text/html' } });
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const frame = document.createElement('iframe');
|
|
62
|
+
// A raw element, not a React render: this is about happy-dom's loader, and going
|
|
63
|
+
// through a component would only add that component's own `onLoad` state update.
|
|
64
|
+
frame.title = 'network probe';
|
|
65
|
+
frame.src = `http://127.0.0.1:${server.port}/preview/abc`;
|
|
66
|
+
document.body.append(frame);
|
|
67
|
+
|
|
68
|
+
// Drains happy-dom's async task manager — with navigation enabled the iframe's fetch
|
|
69
|
+
// IS such a task, so this waits for exactly the request we are claiming never happens.
|
|
70
|
+
await happyDOM?.waitUntilComplete();
|
|
71
|
+
await Bun.sleep(50);
|
|
72
|
+
|
|
73
|
+
expect(
|
|
74
|
+
hits,
|
|
75
|
+
'an <iframe src> in a spec made a real HTTP request. happy-dom loads iframe `src` ' +
|
|
76
|
+
'for real; `happydom.ts` sets `settings.navigation.disableChildFrameNavigation` to ' +
|
|
77
|
+
'stop it. Without that, the request outlives the test, fails on `afterEach(cleanup)` ' +
|
|
78
|
+
'unmounting the frame, and `--parallel=6` prints the NetworkError against whichever ' +
|
|
79
|
+
'innocent spec is running. Restore the setting rather than the URL.',
|
|
80
|
+
).toBe(0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('the preload setting that makes that true is still on', () => {
|
|
84
|
+
// Cheap second opinion on the same fact: if the probe above ever goes green for an
|
|
85
|
+
// unrelated reason (a loader change, a port that would not bind), this still reds when
|
|
86
|
+
// the preload loses the setting.
|
|
87
|
+
expect(
|
|
88
|
+
happyDOM?.settings.navigation.disableChildFrameNavigation,
|
|
89
|
+
'happydom.ts must register with `settings: { navigation: { disableChildFrameNavigation: true } }`.',
|
|
90
|
+
).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -1,15 +1,33 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
-
import { cleanup, fireEvent, render } from '@testing-library/react';
|
|
2
|
+
import { act, cleanup, fireEvent, render } from '@testing-library/react';
|
|
3
|
+
import type { ReactElement } from 'react';
|
|
3
4
|
import { IsolatedFrame } from './IsolatedFrame';
|
|
4
5
|
|
|
5
6
|
afterEach(cleanup);
|
|
6
7
|
|
|
8
|
+
/**
|
|
9
|
+
* `render` plus a microtask flush inside `act`, for the cases that pass a `src`.
|
|
10
|
+
*
|
|
11
|
+
* happy-dom still runs its iframe loader when `src` is set — `happydom.ts` only stops it
|
|
12
|
+
* reaching the NETWORK (`navigation.disableChildFrameNavigation`), and the refused
|
|
13
|
+
* navigation still resolves and fires a real `load` on the next tick. That lands on
|
|
14
|
+
* `IsolatedFrame`'s `onLoad` → `setLoadedSource`, i.e. a genuine state update arriving
|
|
15
|
+
* just after a synchronous test body has returned, which React reports as
|
|
16
|
+
* "An update to IsolatedFrame inside a test was not wrapped in act(...)" against whatever
|
|
17
|
+
* file is running. Flushing it here keeps that update inside the test that caused it.
|
|
18
|
+
*/
|
|
19
|
+
const renderFramed = async (ui: ReactElement) => {
|
|
20
|
+
const result = render(ui);
|
|
21
|
+
await act(async () => {});
|
|
22
|
+
return result;
|
|
23
|
+
};
|
|
24
|
+
|
|
7
25
|
// The fullscreen modal is Radix-Dialog-backed (focus-trap + portal) and asserted
|
|
8
26
|
// in e2e against the studio; here we lock the props contract: which mode frames
|
|
9
27
|
// what, the src/srcDoc precedence, the loading skeleton, and the error slot.
|
|
10
28
|
describe('IsolatedFrame', () => {
|
|
11
|
-
test('desktop: frames the src url with the given accessible title', () => {
|
|
12
|
-
const { getByTitle } =
|
|
29
|
+
test('desktop: frames the src url with the given accessible title', async () => {
|
|
30
|
+
const { getByTitle } = await renderFramed(
|
|
13
31
|
<IsolatedFrame mode='desktop' src='/preview/abc' title='App preview' />,
|
|
14
32
|
);
|
|
15
33
|
const frame = getByTitle('App preview') as HTMLIFrameElement;
|
|
@@ -17,8 +35,8 @@ describe('IsolatedFrame', () => {
|
|
|
17
35
|
expect(frame.getAttribute('src')).toBe('/preview/abc');
|
|
18
36
|
});
|
|
19
37
|
|
|
20
|
-
test('src wins over srcDoc when both are provided', () => {
|
|
21
|
-
const { getByTitle } =
|
|
38
|
+
test('src wins over srcDoc when both are provided', async () => {
|
|
39
|
+
const { getByTitle } = await renderFramed(
|
|
22
40
|
<IsolatedFrame mode='desktop' src='/preview/abc' srcDoc='<p>inline</p>' title='pv' />,
|
|
23
41
|
);
|
|
24
42
|
const frame = getByTitle('pv') as HTMLIFrameElement;
|
|
@@ -62,8 +80,8 @@ describe('IsolatedFrame', () => {
|
|
|
62
80
|
expect(queryByTitle('pv')).toBeNull();
|
|
63
81
|
});
|
|
64
82
|
|
|
65
|
-
test('mobile: frames the src inside a fixed-size device bezel', () => {
|
|
66
|
-
const { getByTitle } =
|
|
83
|
+
test('mobile: frames the src inside a fixed-size device bezel', async () => {
|
|
84
|
+
const { getByTitle } = await renderFramed(
|
|
67
85
|
<IsolatedFrame mode='mobile' src='/preview/abc' title='mobile pv' />,
|
|
68
86
|
);
|
|
69
87
|
const frame = getByTitle('mobile pv') as HTMLIFrameElement;
|
|
@@ -73,8 +91,8 @@ describe('IsolatedFrame', () => {
|
|
|
73
91
|
expect(bezel.style.height).toBe('720px');
|
|
74
92
|
});
|
|
75
93
|
|
|
76
|
-
test('fullscreen: frames the src inside a modal dialog', () => {
|
|
77
|
-
const { getByRole, getByTitle } =
|
|
94
|
+
test('fullscreen: frames the src inside a modal dialog', async () => {
|
|
95
|
+
const { getByRole, getByTitle } = await renderFramed(
|
|
78
96
|
<IsolatedFrame mode='fullscreen' src='/preview/abc' title='fs pv' onClose={() => {}} />,
|
|
79
97
|
);
|
|
80
98
|
expect(getByRole('dialog')).toBeTruthy();
|
|
@@ -22,7 +22,6 @@ import {
|
|
|
22
22
|
MAX_IDLE_MS,
|
|
23
23
|
MIN_IDLE_MS,
|
|
24
24
|
deriveMasterLockVerifier,
|
|
25
|
-
newMasterLockKdfParams,
|
|
26
25
|
} from "cursedbelt-core/master-lock";
|
|
27
26
|
import { ActionBar } from "../components/ActionBar";
|
|
28
27
|
import { Button } from "../components/Button";
|
|
@@ -107,13 +106,31 @@ export function MasterLockDialog({
|
|
|
107
106
|
setNote({ text: "Deriving…", tone: "ok" });
|
|
108
107
|
try {
|
|
109
108
|
const currentVerifier = await deriveMasterLockVerifier(current, kdf);
|
|
110
|
-
|
|
111
|
-
|
|
109
|
+
/*
|
|
110
|
+
* 🔴 BOTH verifiers derive under the app's EXISTING `kdf`, and the request carries
|
|
111
|
+
* no `kdf` at all.
|
|
112
|
+
*
|
|
113
|
+
* Until 2026-09-17 this minted a fresh salt (`newMasterLockKdfParams()`) and sent it
|
|
114
|
+
* along — the shape the wire had before accounts existed, when one app meant one
|
|
115
|
+
* password. Every account on an app now derives under ONE shared descriptor, so a
|
|
116
|
+
* fresh salt here would store a verifier hash nothing could ever match: the owner
|
|
117
|
+
* sets a new password, the rotation logs him out, and neither the old nor the new
|
|
118
|
+
* one opens the app. It would also invalidate every SIBLING account's stored hash
|
|
119
|
+
* at once — rotating account 2 would silently lock him out of account 1.
|
|
120
|
+
*
|
|
121
|
+
* `cursedbelt-server`'s guard refuses the old shape with `409 {"error":"stale-client"}`
|
|
122
|
+
* rather than honouring it, so this dialog did not corrupt anything; it simply
|
|
123
|
+
* stopped working, and the owner could not change his master password from inside
|
|
124
|
+
* any app. The salt never rotating is the accepted cost — it is 16 random bytes
|
|
125
|
+
* whose job is to stop precomputation across apps, and it does that for as long as
|
|
126
|
+
* it exists. `MasterLockChangeRequest` in `cursedbelt-core` carries the same note.
|
|
127
|
+
*/
|
|
128
|
+
const verifier = await deriveMasterLockVerifier(next, kdf);
|
|
112
129
|
const res = await fetch(MASTER_LOCK_PATHS.change, {
|
|
113
130
|
method: "POST",
|
|
114
131
|
credentials: "same-origin",
|
|
115
132
|
headers: { "content-type": "application/json" },
|
|
116
|
-
body: JSON.stringify({ currentVerifier,
|
|
133
|
+
body: JSON.stringify({ currentVerifier, verifier }),
|
|
117
134
|
});
|
|
118
135
|
if (!res.ok) {
|
|
119
136
|
const body = (await res.json().catch(() => null)) as { error?: string } | null;
|
|
@@ -123,7 +140,9 @@ export function MasterLockDialog({
|
|
|
123
140
|
? "That is not the current master password."
|
|
124
141
|
: body?.error === "unconfigured"
|
|
125
142
|
? "This app's stored lock cannot be read — it needs re-seeding at the machine."
|
|
126
|
-
:
|
|
143
|
+
: res.status === 409
|
|
144
|
+
? "This page is out of date. Reload it and try again."
|
|
145
|
+
: "The change was refused.",
|
|
127
146
|
tone: "error",
|
|
128
147
|
});
|
|
129
148
|
setBusy(false);
|
|
@@ -87,3 +87,42 @@ describe("the idle watcher", () => {
|
|
|
87
87
|
await waitFor(() => expect(locked).toBeGreaterThan(0), { timeout: IDLE_MS + 4_000 });
|
|
88
88
|
});
|
|
89
89
|
});
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 🔴 The rotation's request body, pinned by reading the SHIPPED source.
|
|
93
|
+
*
|
|
94
|
+
* `MasterLockDialog` minted a fresh `kdf` salt on every rotation until 2026-09-17 — the wire
|
|
95
|
+
* shape from before accounts existed. Every account on an app now derives under one shared
|
|
96
|
+
* descriptor, so a fresh salt stores a verifier hash nothing can ever match: the owner sets a
|
|
97
|
+
* new password, the rotation logs him out, and neither the old nor the new one opens the app.
|
|
98
|
+
* `cursedbelt-server`'s guard answers that shape `409 stale-client` rather than honouring it,
|
|
99
|
+
* so the dialog did not corrupt anything — it simply refused, and the owner could not change
|
|
100
|
+
* his master password from inside any app for as long as it shipped.
|
|
101
|
+
*
|
|
102
|
+
* A rendering test cannot see this: the dialog renders identically either way and the bug is
|
|
103
|
+
* in two words of a fetch body. So this reads the module's own source, which is the only thing
|
|
104
|
+
* that would have caught the regression on the day it was written.
|
|
105
|
+
*/
|
|
106
|
+
describe("🔴 the rotation never mints a KDF", () => {
|
|
107
|
+
test("the dialog derives under the app's existing params and sends no kdf", async () => {
|
|
108
|
+
const raw = await Bun.file(
|
|
109
|
+
new URL("./MasterLockDialog.tsx", import.meta.url).pathname,
|
|
110
|
+
).text();
|
|
111
|
+
// 🔴 Comments STRIPPED before asserting. The note in the source explains the bug by
|
|
112
|
+
// naming `newMasterLockKdfParams()`, so a naive grep over the raw file fails on the
|
|
113
|
+
// documentation of the fix — which would teach the next person to delete the note.
|
|
114
|
+
const code = raw.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "");
|
|
115
|
+
|
|
116
|
+
// The body that goes to POST /__lock/change carries exactly the two verifiers.
|
|
117
|
+
expect(code).toContain("JSON.stringify({ currentVerifier, verifier })");
|
|
118
|
+
// Nothing in the file can produce a salt: the mint is not imported, so it is not in scope.
|
|
119
|
+
expect(code).not.toContain("newMasterLockKdfParams");
|
|
120
|
+
// Both verifiers derive under the SAME params — the ones the server published.
|
|
121
|
+
expect(code).toContain("deriveMasterLockVerifier(current, kdf)");
|
|
122
|
+
expect(code).toContain("deriveMasterLockVerifier(next, kdf)");
|
|
123
|
+
// `kdf` survives only as the PROP the server's params arrive on — never as a key in a
|
|
124
|
+
// request body. Pinned on the body itself above rather than by grepping the identifier,
|
|
125
|
+
// which would also hit that declaration.
|
|
126
|
+
expect(code).toContain("kdf: MasterLockKdfParams;");
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -243,6 +243,25 @@ export interface MasterLockMenuItemsProps {
|
|
|
243
243
|
* collections lock or other locks like that."*
|
|
244
244
|
*
|
|
245
245
|
* Renders nothing when no master password is configured for the app.
|
|
246
|
+
*
|
|
247
|
+
* ── 🔴 The second entry is a LINK now, not a dialog (2026-09-17) ─────────────────────────
|
|
248
|
+
* It used to call {@link MasterLockControl.openSettings}, and that dialog minted a FRESH `kdf`
|
|
249
|
+
* salt on every rotation — the shape the wire had before accounts existed. Every account on an
|
|
250
|
+
* app now derives under ONE shared descriptor, so honouring that salt would store a verifier
|
|
251
|
+
* hash nothing could ever match: the owner sets a new password, the rotation logs him out, and
|
|
252
|
+
* neither the old nor the new one opens the app. `cursedbelt-server`'s guard therefore answers
|
|
253
|
+
* that shape `409 {"error":"stale-client"}` — deliberately distinguishable from a wrong
|
|
254
|
+
* password, because it describes a broken CLIENT. So the entry did not merely look wrong; it
|
|
255
|
+
* refused, and the owner could lock a site and enroll a first password but could not CHANGE one.
|
|
256
|
+
*
|
|
257
|
+
* `cursedbelt-server` serves a richer replacement at `GET /__lock/accounts`, from the same guard
|
|
258
|
+
* that serves the lock page: server-rendered, content-negotiated, listing the accounts and
|
|
259
|
+
* adding/renaming them as well as rotating. This package does NOT link to it yet — the path
|
|
260
|
+
* constant lives in `cursedbelt-core@2`'s wire and this package is still on `^1.1.0`, so the
|
|
261
|
+
* link is a task, not a one-liner (`tasks/Not_Started`, filed 2026-09-17). What IS fixed here
|
|
262
|
+
* is the dialog itself: `MasterLockDialog` no longer mints a salt, so this entry works again
|
|
263
|
+
* rather than 409ing. `apps/collections` links to the page directly from its own rail in the
|
|
264
|
+
* meantime, and says why in a comment there.
|
|
246
265
|
*/
|
|
247
266
|
export function MasterLockMenuItems({ close, itemClassName }: MasterLockMenuItemsProps) {
|
|
248
267
|
const lock = useMasterLock();
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import type { PlayableVideo } from 'cursedbelt-core/media';
|
|
3
2
|
import { Button } from '../components/Button';
|
|
4
3
|
import { IconButton } from '../components/IconButton';
|
|
5
4
|
import { useHlsSource } from './hlsSource';
|
|
6
|
-
import { DEFAULT_VIDEO_ASPECT_RATIO, ProcessingIndicator } from './VideoPlayer';
|
|
5
|
+
import { DEFAULT_VIDEO_ASPECT_RATIO, type PlayerVideo, ProcessingIndicator } from './VideoPlayer';
|
|
7
6
|
|
|
8
7
|
// ── The green-frame-immune fallback player ───────────────────────────────────────
|
|
9
8
|
// On some GPUs Chrome composites a hardware-decoded <video> as a solid GREEN frame when it's
|
|
@@ -12,7 +11,7 @@ import { DEFAULT_VIDEO_ASPECT_RATIO, ProcessingIndicator } from './VideoPlayer';
|
|
|
12
11
|
// reintroduced it). The decode is correct — drawing the video to a <canvas> yields right pixels.
|
|
13
12
|
// So this keeps a HIDDEN <video> as the decode/audio/seek engine and paints each presented frame
|
|
14
13
|
// to a <canvas> (composited as an ordinary texture, immune to the bug). It's a drop-in swap for
|
|
15
|
-
// the Vidstack <VideoPlayer> (same
|
|
14
|
+
// the Vidstack <VideoPlayer> (same PlayerVideo + props); switch to it if you see green frames.
|
|
16
15
|
// Ported from ca's CanvasVideoPlayer (Tailwind, themed off cursedbelt's --accent token). The
|
|
17
16
|
// source attach — native HLS / hls.js / progressive — is `hlsSource.ts`, shared with the
|
|
18
17
|
// gallery's native stage.
|
|
@@ -26,7 +25,8 @@ function fmtTime(s: number): string {
|
|
|
26
25
|
}
|
|
27
26
|
|
|
28
27
|
export interface CanvasVideoPlayerProps {
|
|
29
|
-
|
|
28
|
+
/** Same clip shape as {@link VideoPlayer} — see {@link PlayerVideo}. */
|
|
29
|
+
video: PlayerVideo;
|
|
30
30
|
autoPlay?: boolean;
|
|
31
31
|
muted?: boolean;
|
|
32
32
|
className?: string;
|
|
@@ -204,7 +204,8 @@ export function CanvasVideoPlayer({
|
|
|
204
204
|
};
|
|
205
205
|
|
|
206
206
|
const status = video.processingStatus;
|
|
207
|
-
|
|
207
|
+
// An unreachable ladder is its own reason to draw — see `VideoPlayer`'s copy of this line.
|
|
208
|
+
const showProcessing = (status != null && status !== 'ready') || video.playbackDegraded === true;
|
|
208
209
|
|
|
209
210
|
return (
|
|
210
211
|
// Same intrinsic-sizing contract as <VideoPlayer>: aspect-ratio keeps the surface visible
|
|
@@ -234,7 +235,11 @@ export function CanvasVideoPlayer({
|
|
|
234
235
|
/>
|
|
235
236
|
|
|
236
237
|
{showProcessing ? (
|
|
237
|
-
<ProcessingIndicator
|
|
238
|
+
<ProcessingIndicator
|
|
239
|
+
status={status}
|
|
240
|
+
progress={video.processingProgress}
|
|
241
|
+
playbackDegraded={video.playbackDegraded}
|
|
242
|
+
/>
|
|
238
243
|
) : null}
|
|
239
244
|
|
|
240
245
|
{/* Controls */}
|