cursedbelt 4.7.0 → 5.0.1

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.
Files changed (175) hide show
  1. package/README.md +36 -0
  2. package/dist/react/chat/index.d.ts +1 -0
  3. package/dist/react/chat/index.d.ts.map +1 -1
  4. package/dist/react/chat/index.js +1 -0
  5. package/dist/react/chat/index.js.map +1 -1
  6. package/dist/react/chat/usePresence.d.ts +75 -0
  7. package/dist/react/chat/usePresence.d.ts.map +1 -0
  8. package/dist/react/chat/usePresence.js +109 -0
  9. package/dist/react/chat/usePresence.js.map +1 -0
  10. package/dist/react/components/SecretInput.d.ts +7 -2
  11. package/dist/react/components/SecretInput.d.ts.map +1 -1
  12. package/dist/react/components/SecretInput.js +5 -2
  13. package/dist/react/components/SecretInput.js.map +1 -1
  14. package/dist/react/components/TooltipImpl.d.ts.map +1 -1
  15. package/dist/react/components/TooltipImpl.js +52 -3
  16. package/dist/react/components/TooltipImpl.js.map +1 -1
  17. package/dist/react/emoji/EmojiPicker.d.ts.map +1 -1
  18. package/dist/react/emoji/EmojiPicker.js +49 -8
  19. package/dist/react/emoji/EmojiPicker.js.map +1 -1
  20. package/dist/react/index.d.ts +1 -0
  21. package/dist/react/index.d.ts.map +1 -1
  22. package/dist/react/index.js +1 -0
  23. package/dist/react/index.js.map +1 -1
  24. package/dist/react/lib/form.d.ts +2 -2
  25. package/dist/react/lib/form.d.ts.map +1 -1
  26. package/dist/react/lib/form.js +4 -41
  27. package/dist/react/lib/form.js.map +1 -1
  28. package/dist/react/lib/zodResolver.d.ts +9 -0
  29. package/dist/react/lib/zodResolver.d.ts.map +1 -0
  30. package/dist/react/lib/zodResolver.js +105 -0
  31. package/dist/react/lib/zodResolver.js.map +1 -0
  32. package/dist/react/master-lock/MasterLockDialog.d.ts.map +1 -1
  33. package/dist/react/master-lock/MasterLockDialog.js +2 -1
  34. package/dist/react/master-lock/MasterLockDialog.js.map +1 -1
  35. package/dist/react/master-lock/MasterLockProvider.d.ts +12 -18
  36. package/dist/react/master-lock/MasterLockProvider.d.ts.map +1 -1
  37. package/dist/react/master-lock/MasterLockProvider.js +14 -20
  38. package/dist/react/master-lock/MasterLockProvider.js.map +1 -1
  39. package/dist/react/media/MediaUploadControl.d.ts +3 -7
  40. package/dist/react/media/MediaUploadControl.d.ts.map +1 -1
  41. package/dist/react/media/MediaUploadControl.js +4 -13
  42. package/dist/react/media/MediaUploadControl.js.map +1 -1
  43. package/dist/react/media/fileUploadClient.d.ts +0 -8
  44. package/dist/react/media/fileUploadClient.d.ts.map +1 -1
  45. package/dist/react/media/fileUploadClient.js +0 -8
  46. package/dist/react/media/fileUploadClient.js.map +1 -1
  47. package/dist/react/media/hlsSource.d.ts.map +1 -1
  48. package/dist/react/media/hlsSource.js +4 -2
  49. package/dist/react/media/hlsSource.js.map +1 -1
  50. package/dist/react/media/index.d.ts +0 -4
  51. package/dist/react/media/index.d.ts.map +1 -1
  52. package/dist/react/media/index.js +0 -4
  53. package/dist/react/media/index.js.map +1 -1
  54. package/dist/react/sessionAuth.d.ts +1 -2
  55. package/dist/react/sessionAuth.d.ts.map +1 -1
  56. package/dist/react/sessionAuth.js +2 -3
  57. package/dist/react/sessionAuth.js.map +1 -1
  58. package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
  59. package/dist/styles-areas/activity-bar.css +1 -1
  60. package/dist/styles-areas/analytics.css +1 -1
  61. package/dist/styles-areas/auth.css +1 -1
  62. package/dist/styles-areas/charts.css +1 -1
  63. package/dist/styles-areas/chat.css +1 -1
  64. package/dist/styles-areas/core.css +1 -1
  65. package/dist/styles-areas/dashboard-grid.css +1 -1
  66. package/dist/styles-areas/data-table.css +1 -1
  67. package/dist/styles-areas/deep-link.css +1 -1
  68. package/dist/styles-areas/disk-usage.css +1 -1
  69. package/dist/styles-areas/emoji.css +1 -1
  70. package/dist/styles-areas/fields.css +1 -1
  71. package/dist/styles-areas/file-tree.css +1 -1
  72. package/dist/styles-areas/keep-awake.css +1 -1
  73. package/dist/styles-areas/layout-engine.css +1 -1
  74. package/dist/styles-areas/markdown.css +1 -1
  75. package/dist/styles-areas/master-detail.css +1 -1
  76. package/dist/styles-areas/master-lock.css +108 -2
  77. package/dist/styles-areas/media-gallery.css +1 -1
  78. package/dist/styles-areas/media.css +1 -17
  79. package/dist/styles-areas/nav.css +1 -1
  80. package/dist/styles-areas/notifications.css +1 -1
  81. package/dist/styles-areas/overlays.css +1 -1
  82. package/dist/styles-areas/palette.css +1 -1
  83. package/dist/styles-areas/spreadsheet.css +1 -1
  84. package/dist/styles-areas/virtual.css +1 -1
  85. package/dist/styles-areas/wizard.css +1 -1
  86. package/dist/styles-areas/workbench.css +1 -1
  87. package/dist/styles-areas/workbook-viewer.css +1 -1
  88. package/dist/styles-utilities.css +0 -16
  89. package/dist/testing/_noBun.d.ts +2 -0
  90. package/dist/testing/_noBun.d.ts.map +1 -0
  91. package/dist/testing/_noBun.js +10 -0
  92. package/dist/testing/_noBun.js.map +1 -0
  93. package/package.json +54 -20
  94. package/scripts/checkAreaStyles.spec.ts +71 -0
  95. package/scripts/checkAreaStyles.ts +150 -0
  96. package/scripts/guardrailsEnforce.spec.ts +43 -0
  97. package/scripts/guardrailsEnforce.ts +46 -1
  98. package/scripts/styleAreas.ts +1 -2
  99. package/src/barrelsReachNoOptionalPeer.spec.ts +6 -3
  100. package/src/bundlerOnlyEntries.spec.ts +101 -0
  101. package/src/declaredImports.spec.ts +4 -0
  102. package/src/optionalPeersTable.spec.ts +127 -0
  103. package/src/react/chat/index.ts +8 -0
  104. package/src/react/chat/usePresence.spec.tsx +138 -0
  105. package/src/react/chat/usePresence.ts +185 -0
  106. package/src/react/components/SecretInput.tsx +12 -1
  107. package/src/react/components/TooltipImpl.tsx +54 -5
  108. package/src/react/emoji/EmojiPicker.tsx +70 -19
  109. package/src/react/index.ts +8 -0
  110. package/src/react/lib/form.ts +5 -48
  111. package/src/react/lib/zodResolver.spec.ts +82 -0
  112. package/src/react/lib/zodResolver.ts +136 -0
  113. package/src/react/master-lock/MasterLockDialog.tsx +13 -9
  114. package/src/react/master-lock/MasterLockProvider.spec.tsx +17 -0
  115. package/src/react/master-lock/MasterLockProvider.tsx +16 -19
  116. package/src/react/media/MediaUploadControl.tsx +8 -29
  117. package/src/react/media/fileUploadClient.ts +7 -17
  118. package/src/react/media/hlsSource.ts +4 -1
  119. package/src/react/media/index.ts +0 -16
  120. package/src/react/sessionAuth.ts +2 -3
  121. package/src/scriptsResolve.spec.ts +66 -0
  122. package/src/styles-areas/activity-bar.css +1 -1
  123. package/src/styles-areas/analytics.css +1 -1
  124. package/src/styles-areas/auth.css +1 -1
  125. package/src/styles-areas/charts.css +1 -1
  126. package/src/styles-areas/chat.css +1 -1
  127. package/src/styles-areas/core.css +1 -1
  128. package/src/styles-areas/dashboard-grid.css +1 -1
  129. package/src/styles-areas/data-table.css +1 -1
  130. package/src/styles-areas/deep-link.css +1 -1
  131. package/src/styles-areas/disk-usage.css +1 -1
  132. package/src/styles-areas/emoji.css +1 -1
  133. package/src/styles-areas/fields.css +1 -1
  134. package/src/styles-areas/file-tree.css +1 -1
  135. package/src/styles-areas/keep-awake.css +1 -1
  136. package/src/styles-areas/layout-engine.css +1 -1
  137. package/src/styles-areas/markdown.css +1 -1
  138. package/src/styles-areas/master-detail.css +1 -1
  139. package/src/styles-areas/master-lock.css +108 -2
  140. package/src/styles-areas/media-gallery.css +1 -1
  141. package/src/styles-areas/media.css +1 -17
  142. package/src/styles-areas/nav.css +1 -1
  143. package/src/styles-areas/notifications.css +1 -1
  144. package/src/styles-areas/overlays.css +1 -1
  145. package/src/styles-areas/palette.css +1 -1
  146. package/src/styles-areas/spreadsheet.css +1 -1
  147. package/src/styles-areas/virtual.css +1 -1
  148. package/src/styles-areas/wizard.css +1 -1
  149. package/src/styles-areas/workbench.css +1 -1
  150. package/src/styles-areas/workbook-viewer.css +1 -1
  151. package/src/styles-utilities.css +0 -16
  152. package/src/testing/_noBun.ts +12 -0
  153. package/src/unpeeredStaysOffTheBarrel.spec.ts +99 -0
  154. package/src/verifyGraph.spec.ts +11 -0
  155. package/dist/react/media/PrivateIndexView.d.ts +0 -6
  156. package/dist/react/media/PrivateIndexView.d.ts.map +0 -1
  157. package/dist/react/media/PrivateIndexView.js +0 -44
  158. package/dist/react/media/PrivateIndexView.js.map +0 -1
  159. package/dist/react/media/PrivateModeBadge.d.ts +0 -17
  160. package/dist/react/media/PrivateModeBadge.d.ts.map +0 -1
  161. package/dist/react/media/PrivateModeBadge.js +0 -24
  162. package/dist/react/media/PrivateModeBadge.js.map +0 -1
  163. package/dist/react/media/privateClient.d.ts +0 -34
  164. package/dist/react/media/privateClient.d.ts.map +0 -1
  165. package/dist/react/media/privateClient.js +0 -36
  166. package/dist/react/media/privateClient.js.map +0 -1
  167. package/dist/react/media/usePrivateUnlock.d.ts +0 -25
  168. package/dist/react/media/usePrivateUnlock.d.ts.map +0 -1
  169. package/dist/react/media/usePrivateUnlock.js +0 -68
  170. package/dist/react/media/usePrivateUnlock.js.map +0 -1
  171. package/src/react/media/PrivateIndexView.tsx +0 -113
  172. package/src/react/media/PrivateModeBadge.tsx +0 -50
  173. package/src/react/media/privateClient.spec.ts +0 -113
  174. package/src/react/media/privateClient.ts +0 -82
  175. package/src/react/media/usePrivateUnlock.ts +0 -132
@@ -0,0 +1,82 @@
1
+ /**
2
+ * 🔴 `./zodResolver.ts` must answer EXACTLY what `@hookform/resolvers/zod` answers — it replaced
3
+ * that package in 5.0.1 so that no consumer has to install it (see the module header for why
4
+ * every way of depending on it broke somebody). `@hookform/resolvers` stays a devDependency for
5
+ * one reason: to be the oracle here. Each case runs both resolvers over one schema and one input
6
+ * and compares the whole result, `ref`s and all.
7
+ */
8
+ import { describe, expect, test } from 'bun:test';
9
+ import { zodResolver as upstream } from '@hookform/resolvers/zod';
10
+ import type { FieldValues, ResolverOptions } from 'react-hook-form';
11
+ import { z } from 'zod';
12
+ import { zodResolver } from './zodResolver.js';
13
+
14
+ const options = (over: Partial<ResolverOptions<FieldValues>> = {}): ResolverOptions<FieldValues> =>
15
+ ({ fields: {}, shouldUseNativeValidation: false, ...over }) as ResolverOptions<FieldValues>;
16
+
17
+ async function both(schema: z.ZodType, values: FieldValues, opts = options()) {
18
+ const ours = await zodResolver(schema)(values, undefined, opts);
19
+ const theirs = await (upstream as unknown as (s: z.ZodType) => typeof zodResolver extends (s: z.ZodType) => infer R ? R : never)(schema)(values, undefined, opts);
20
+ return { ours, theirs };
21
+ }
22
+
23
+ const account = z.object({
24
+ name: z.string().min(2, 'too short'),
25
+ email: z.email('not an email'),
26
+ age: z.number().int().min(18, 'adults only'),
27
+ address: z.object({ city: z.string().min(1, 'city required'), zip: z.string().regex(/^\d{5}$/, 'five digits') }),
28
+ tags: z.array(z.string().min(1, 'empty tag')).min(1, 'at least one tag'),
29
+ contact: z.union([z.object({ kind: z.literal('phone'), phone: z.string().min(7, 'phone too short') }), z.object({ kind: z.literal('mail'), mail: z.email('bad mail') })]),
30
+ });
31
+
32
+ describe('zodResolver matches @hookform/resolvers/zod', () => {
33
+ test('valid input: parsed output as values, no errors', async () => {
34
+ const schema = z.object({ n: z.coerce.number(), s: z.string().trim() });
35
+ const { ours, theirs } = await both(schema, { n: '42', s: ' x ' });
36
+ expect(ours).toEqual(theirs);
37
+ expect(ours.values).toEqual({ n: 42, s: 'x' });
38
+ });
39
+
40
+ test('flat errors', async () => {
41
+ const { ours, theirs } = await both(account, { name: 'a', email: 'nope', age: 12, address: { city: '', zip: '1' }, tags: [], contact: { kind: 'phone', phone: '1' } });
42
+ expect(ours).toEqual(theirs);
43
+ expect(Object.keys(ours.errors).sort()).toEqual(['address', 'age', 'contact', 'email', 'name', 'tags']);
44
+ });
45
+
46
+ test('nested paths and array items nest, not dot-join', async () => {
47
+ const { ours, theirs } = await both(account, { name: 'ok', email: 'a@b.co', age: 30, address: { city: 'x', zip: 'abc' }, tags: ['a', ''], contact: { kind: 'mail', mail: 'x' } });
48
+ expect(ours).toEqual(theirs);
49
+ expect((ours.errors as Record<string, Record<string, unknown>>).address?.zip).toBeDefined();
50
+ });
51
+
52
+ test('criteriaMode "all" collects every failing rule per field', async () => {
53
+ const schema = z.object({ pw: z.string().min(8, 'eight').regex(/\d/, 'a digit').regex(/[A-Z]/, 'a capital') });
54
+ const { ours, theirs } = await both(schema, { pw: 'abc' }, options({ criteriaMode: 'all' }));
55
+ expect(ours).toEqual(theirs);
56
+ expect(Object.keys((ours.errors as Record<string, { types: object }>).pw?.types ?? {}).length).toBeGreaterThan(0);
57
+ });
58
+
59
+ test('a field-array ROOT error lands on `.root` when its items are registered', async () => {
60
+ const schema = z.object({ items: z.array(z.object({ v: z.string() })).min(2, 'two please') });
61
+ const opts = options({ names: ['items.0.v'] });
62
+ const { ours, theirs } = await both(schema, { items: [{ v: 'a' }] }, opts);
63
+ expect(ours).toEqual(theirs);
64
+ expect((ours.errors as Record<string, { root?: unknown }>).items?.root).toBeDefined();
65
+ });
66
+
67
+ test('a field ref is carried onto its error, as react-hook-form focuses it', async () => {
68
+ const ref = { name: 'name' };
69
+ const opts = options({ fields: { name: { ref, name: 'name' } } as unknown as ResolverOptions<FieldValues>['fields'] });
70
+ const { ours, theirs } = await both(account.pick({ name: true }), { name: '' }, opts);
71
+ expect(ours).toEqual(theirs);
72
+ expect((ours.errors as Record<string, { ref?: unknown }>).name?.ref).toBe(ref);
73
+ });
74
+
75
+ test('a non-zod throw from the schema is rethrown, by both', async () => {
76
+ const schema = z.string().transform(() => {
77
+ throw new TypeError('boom');
78
+ });
79
+ await expect(zodResolver(schema)('x' as unknown as FieldValues, undefined, options())).rejects.toThrow('boom');
80
+ await expect((upstream as unknown as (s: z.ZodType) => ReturnType<typeof zodResolver>)(schema)('x' as unknown as FieldValues, undefined, options())).rejects.toThrow('boom');
81
+ });
82
+ });
@@ -0,0 +1,136 @@
1
+ import { appendErrors, type FieldError, type FieldErrors, type FieldValues, get, type Resolver, type ResolverOptions, set } from 'react-hook-form';
2
+ import type { z } from 'zod';
3
+
4
+ /**
5
+ * 🔴 react-hook-form's zod resolver, owned here instead of imported from `@hookform/resolvers`
6
+ * (cursedbelt 5.0.1). What that package cost, and why neither way of depending on it worked:
7
+ *
8
+ * · As an optional PEER, `npm install cursedbelt` failed ERESOLVE (task 2087): npm checks an
9
+ * optional peer's own peer ranges, and `@hookform/resolvers@5` declares ~25 of them.
10
+ * · As `unpeered` (5.0.0), every consumer's clean-install `vite build` failed:
11
+ * `Rollup failed to resolve import "@hookform/resolvers/zod"`. Vite stubs a missing import
12
+ * only when it is a DECLARED optional peer, and a `import()` with a literal specifier is
13
+ * still resolved at build time. Primary checkouts hid it because their `node_modules` still
14
+ * held the 4.x copy.
15
+ * · A variable specifier (`import(/* @vite-ignore *\/ spec)`) builds, and then fails in the
16
+ * BROWSER for every app that does install the package — a bare specifier has nothing to
17
+ * resolve it at run time.
18
+ *
19
+ * So this is the zod-4 half of `@hookform/resolvers/zod` 5.9.1 and its `toNestErrors` /
20
+ * `validateFieldsNatively` (MIT, © 2019-present Beier (Bill) Luo), reduced to the one schema
21
+ * library this package peers on (`zod ^4.4.0`) and the one call shape {@link makeFormConfig}
22
+ * makes. `react-hook-form` is already a dependency, and supplies `get`, `set` and
23
+ * `appendErrors`. `zodResolver.spec.ts` pins the behaviours that matter: nested paths, the
24
+ * closest union branch, `criteriaMode: 'all'`, a field-array root error, and parsed output.
25
+ */
26
+
27
+ type Issue = z.core.$ZodIssue;
28
+
29
+ /** zod 4 marks its errors with a trait, not a prototype chain a bundler could duplicate. */
30
+ function isZodError(error: unknown): error is { issues: Issue[] } {
31
+ return !!(error as { _zod?: { traits?: Set<string> } } | null)?._zod?.traits?.has('$ZodError');
32
+ }
33
+
34
+ function parseIssues(input: Issue[], validateAllFieldCriteria: boolean): Record<string, FieldError> {
35
+ const issues = [...input];
36
+ // A null prototype, so a field named `toString` or `constructor` is not already "set".
37
+ const errors: Record<string, FieldError> = Object.create(null);
38
+ while (issues.length > 0) {
39
+ const issue = issues[0] as Issue;
40
+ const { code, message, path } = issue;
41
+ const key = path.join('.');
42
+ if (!errors[key]) {
43
+ if (issue.code === 'invalid_union' && issue.errors.length > 0) {
44
+ // The branch that came CLOSEST to matching speaks for the union, not the first one.
45
+ const closest = issue.errors.reduce((best, branch) => (branch.length < best.length ? branch : best));
46
+ const first = closest[0];
47
+ errors[key] = { message: first?.message ?? message, type: first?.code ?? code };
48
+ } else {
49
+ errors[key] = { message, type: code };
50
+ }
51
+ }
52
+ if (issue.code === 'invalid_union') {
53
+ for (const branch of issue.errors) for (const nested of branch) issues.push({ ...nested, path: [...issue.path, ...nested.path] } as Issue);
54
+ }
55
+ if (validateAllFieldCriteria) {
56
+ const types = (errors[key] as FieldError).types;
57
+ const messages = types?.[issue.code];
58
+ errors[key] = appendErrors(
59
+ key,
60
+ validateAllFieldCriteria,
61
+ errors as Parameters<typeof appendErrors>[2],
62
+ code,
63
+ messages ? ([] as string[]).concat(messages as string[], issue.message) : issue.message,
64
+ ) as FieldError;
65
+ }
66
+ issues.shift();
67
+ }
68
+ return errors;
69
+ }
70
+
71
+ type Fields = ResolverOptions<FieldValues>['fields'];
72
+ type NativeRef = { reportValidity?: () => void; setCustomValidity?: (m: string) => void };
73
+
74
+ function reportNative(ref: unknown, name: string, errors: FieldErrors): void {
75
+ const el = ref as NativeRef | undefined;
76
+ if (el && typeof el.reportValidity === 'function' && typeof el.setCustomValidity === 'function') {
77
+ const error = get(errors, name) as FieldError | undefined;
78
+ el.setCustomValidity(error?.message || '');
79
+ el.reportValidity();
80
+ }
81
+ }
82
+
83
+ function validateFieldsNatively(errors: FieldErrors, fields: Fields): void {
84
+ for (const name in fields) {
85
+ const field = fields[name] as { ref?: unknown; refs?: unknown[] } | undefined;
86
+ if (field?.ref && typeof (field.ref as NativeRef).reportValidity === 'function') reportNative(field.ref, name, errors);
87
+ else for (const ref of field?.refs ?? []) reportNative(ref, name, errors);
88
+ }
89
+ }
90
+
91
+ /** `a[0].b` and `a.0.b` are one path. */
92
+ const dotted = (name: string): string => name.replace(/\[(\d+)]/g, '.$1').replace(/[[\]]/g, '');
93
+
94
+ /** Is `name` the ROOT of a field array — some registered name is `name.<index>…`? */
95
+ function isFieldArrayRoot(names: readonly string[], name: string): boolean {
96
+ const escaped = dotted(name).replace(/[.*+?^${}()|\\]/g, '\\$&');
97
+ return names.some((n) => new RegExp(`^${escaped}\\.\\d+`).test(dotted(n)));
98
+ }
99
+
100
+ function toNestErrors<T extends FieldValues>(flat: Record<string, FieldError>, options: ResolverOptions<T>): FieldErrors<T> {
101
+ if (options.shouldUseNativeValidation) validateFieldsNatively(flat as FieldErrors, options.fields);
102
+ const nested: Record<string, unknown> = {};
103
+ for (const path in flat) {
104
+ const field = get(options.fields, path) as { ref?: unknown; refs?: unknown[] } | undefined;
105
+ const error = Object.assign(flat[path] || {}, { ref: field?.refs ? field.refs[0] : field?.ref });
106
+ if (isFieldArrayRoot((options.names as string[] | undefined) ?? Object.keys(flat), path)) {
107
+ const existing = Object.assign({}, get(nested, path));
108
+ set(existing, 'root', error);
109
+ set(nested, path, existing);
110
+ } else {
111
+ set(nested, path, error);
112
+ }
113
+ }
114
+ return nested as FieldErrors<T>;
115
+ }
116
+
117
+ /**
118
+ * A react-hook-form {@link Resolver} for a zod 4 schema: `parseAsync`, parsed output as the
119
+ * form's values on success, react-hook-form's nested error shape on a `ZodError`. Anything
120
+ * else a schema throws is not a validation result and is rethrown.
121
+ */
122
+ export function zodResolver<TFieldValues extends FieldValues>(schema: z.ZodType): Resolver<TFieldValues> {
123
+ return async (values, _context, options) => {
124
+ try {
125
+ const data = await schema.parseAsync(values);
126
+ if (options.shouldUseNativeValidation) validateFieldsNatively({}, options.fields);
127
+ return { errors: {}, values: data as TFieldValues };
128
+ } catch (error) {
129
+ if (!isZodError(error)) throw error;
130
+ return {
131
+ values: {},
132
+ errors: toNestErrors(parseIssues(error.issues, !options.shouldUseNativeValidation && options.criteriaMode === 'all'), options),
133
+ };
134
+ }
135
+ };
136
+ }
@@ -28,6 +28,7 @@ import { Button } from "../components/Button.js";
28
28
  import { FieldLabel } from "../components/FieldLabel.js";
29
29
  import { Input } from "../components/Input.js";
30
30
  import { Modal } from "../components/Modal.js";
31
+ import { SecretInput } from "../components/SecretInput.js";
31
32
 
32
33
  export interface MasterLockDialogProps {
33
34
  open: boolean;
@@ -201,39 +202,42 @@ export function MasterLockDialog({
201
202
  <label htmlFor="ml-current">
202
203
  <FieldLabel>Current master password</FieldLabel>
203
204
  </label>
204
- <Input
205
+ <SecretInput
205
206
  id="ml-current"
206
- type="password"
207
+ label="current master password"
207
208
  autoComplete="current-password"
209
+ showCopy={false}
208
210
  value={current}
209
211
  disabled={busy}
210
- onChange={(event) => setCurrent(event.target.value)}
212
+ onChange={setCurrent}
211
213
  />
212
214
  </div>
213
215
  <div className="flex flex-col gap-1.5">
214
216
  <label htmlFor="ml-next">
215
217
  <FieldLabel>New master password</FieldLabel>
216
218
  </label>
217
- <Input
219
+ <SecretInput
218
220
  id="ml-next"
219
- type="password"
221
+ label="new master password"
220
222
  autoComplete="new-password"
223
+ showCopy={false}
221
224
  value={next}
222
225
  disabled={busy}
223
- onChange={(event) => setNext(event.target.value)}
226
+ onChange={setNext}
224
227
  />
225
228
  </div>
226
229
  <div className="flex flex-col gap-1.5">
227
230
  <label htmlFor="ml-confirm">
228
231
  <FieldLabel>Repeat the new one</FieldLabel>
229
232
  </label>
230
- <Input
233
+ <SecretInput
231
234
  id="ml-confirm"
232
- type="password"
235
+ label="repeated new master password"
233
236
  autoComplete="new-password"
237
+ showCopy={false}
234
238
  value={confirm}
235
239
  disabled={busy}
236
- onChange={(event) => setConfirm(event.target.value)}
240
+ onChange={setConfirm}
237
241
  onEnter={() => void changePassword()}
238
242
  />
239
243
  </div>
@@ -126,3 +126,20 @@ describe("🔴 the rotation never mints a KDF", () => {
126
126
  expect(code).toContain("kdf: MasterLockKdfParams;");
127
127
  });
128
128
  });
129
+
130
+ describe("the account-menu entries (task 106)", () => {
131
+ test("🔴 'Master passwords…' is a LINK to the accounts page, from the wire, not a string here", async () => {
132
+ const { MasterLockMenuItems } = await import("./MasterLockProvider.js");
133
+ const { findByText, getByText } = render(
134
+ <MasterLockProvider onLocked={() => {}}>
135
+ <MasterLockMenuItems itemClassName="sam-item" />
136
+ </MasterLockProvider>,
137
+ );
138
+ const link = await findByText("Master passwords…");
139
+ expect(link.tagName).toBe("A");
140
+ expect(link.getAttribute("href")).toBe(MASTER_LOCK_PATHS.accounts);
141
+ expect(link.className).toBe("sam-item");
142
+ // The idle timeout lives only in the dialog, so its entry stays.
143
+ expect(getByText("Lock settings…").tagName).toBe("BUTTON");
144
+ });
145
+ });
@@ -237,31 +237,25 @@ export interface MasterLockMenuItemsProps {
237
237
  }
238
238
 
239
239
  /**
240
- * The two entries for the account menu — *"a new option in the user dropdown … which is
240
+ * The entries for the account menu — *"a new option in the user dropdown … which is
241
241
  * located near the sign out and switch user options"*. The owner chose that placement
242
242
  * deliberately: *"this placement will help make sure it isn't confused with the private
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
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.
247
+ * ── The second entry is a LINK to the accounts page (task 106, 2026-09-23) ─────────────────
248
+ * `cursedbelt-server` serves `GET /__lock/accounts` from the same guard that serves the lock
249
+ * page: server-rendered, listing the accounts and adding, renaming and rotating them. So the
250
+ * entry is an `<a href>`, not a button — the target is a same-origin page, so there is nothing
251
+ * for this bundle to open and nothing for it to keep in step — and the path is
252
+ * `MASTER_LOCK_PATHS.accounts` from `cursedbelt-core`'s wire, never a string here, because a
253
+ * second copy of the wire in the design system is what `wire.ts`'s header exists to prevent.
256
254
  *
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.
255
+ * The THIRD entry keeps the dialog, because the dialog also owns the idle timeout, which the
256
+ * accounts page does not. (History: until 2026-09-17 the dialog minted a fresh `kdf` salt on
257
+ * every rotation and the server refused it `409 stale-client`; it derives under the app's
258
+ * existing descriptor now, so both surfaces rotate correctly.)
265
259
  */
266
260
  export function MasterLockMenuItems({ close, itemClassName }: MasterLockMenuItemsProps) {
267
261
  const lock = useMasterLock();
@@ -283,6 +277,9 @@ export function MasterLockMenuItems({ close, itemClassName }: MasterLockMenuItem
283
277
  >
284
278
  🔒 Lock this site
285
279
  </button>
280
+ <a href={MASTER_LOCK_PATHS.accounts} className={cls} onClick={() => close?.()}>
281
+ Master passwords…
282
+ </a>
286
283
  <button
287
284
  // guardrails-ignore no-raw-action-button: see the sibling above — it must match
288
285
  // the host menu's own rows, not this design system's buttons.
@@ -293,7 +290,7 @@ export function MasterLockMenuItems({ close, itemClassName }: MasterLockMenuItem
293
290
  lock.openSettings();
294
291
  }}
295
292
  >
296
- Master password…
293
+ Lock settings…
297
294
  </button>
298
295
  </>
299
296
  );
@@ -1,7 +1,6 @@
1
1
  import { Upload } from 'lucide-react';
2
2
  import { type ReactNode, useMemo, useRef, useState } from 'react';
3
3
  import { Button } from '../components/Button.js';
4
- import { Checkbox } from '../components/Checkbox.js';
5
4
  import { Input } from '../components/Input.js';
6
5
  import { cn } from '../lib/cn.js';
7
6
  import { createFileUploadClient } from './fileUploadClient.js';
@@ -12,8 +11,8 @@ import { createFileUploadClient } from './fileUploadClient.js';
12
11
  // no ENGINE-model consumer, but three shipping plugins render it
13
12
  // (`plugin-photo-gallery`, `plugin-video-gallery`, `plugin-media-gallery`), so
14
13
  // deleting it would break them. It speaks the legacy chunked `/_files` protocol
15
- // (`createFileUploadClient` + `createFilesStack`), including the tag list and the
16
- // X-Unlock-Token private mode that protocol supports.
14
+ // (`createFileUploadClient` + `createFilesStack`), including the tag list. (Its private
15
+ // mode went with the server's private-media module — task 108.)
17
16
  //
18
17
  // The ENGINE model uploads through `POST /api/asset/vault/upload` — raw bytes,
19
18
  // `x-cc-filename`, checksum computed while streaming, content-addressed dedupe —
@@ -25,19 +24,16 @@ import { createFileUploadClient } from './fileUploadClient.js';
25
24
  //
26
25
  // ── Media upload control ─────────────────────────────────────────────────────────
27
26
  // The one "⬆ Upload" affordance media galleries share: a disclosure button opening a
28
- // compact form — comma-separated tags, a Private toggle (only while an unlock token is
29
- // present), and a file picker — feeding the shared upload client (chunked-aware, private
30
- // via X-Unlock-Token). Consumers own only WHERE it sits and what happens after.
27
+ // compact form — comma-separated tags and a file picker — feeding the shared upload client
28
+ // (chunked-aware). Consumers own only WHERE it sits and what happens after.
31
29
 
32
30
  export interface MediaUploadControlProps {
33
31
  /** File-picker accept filter, e.g. "video/*" or "image/*". */
34
32
  accept: string;
35
33
  /** Mount prefix of the host's file routes (createFilesStack filesBasePath). */
36
34
  basePath: string;
37
- /** Unlock token while private mode is on — reveals the Private toggle. */
38
- privateToken?: string | null;
39
- /** After every successful batch: `wasPrivate` says which list to refresh. */
40
- onUploaded: (result: { wasPrivate: boolean }) => void;
35
+ /** After every successful batch. */
36
+ onUploaded: () => void;
41
37
  label?: ReactNode;
42
38
  className?: string;
43
39
  }
@@ -45,14 +41,12 @@ export interface MediaUploadControlProps {
45
41
  export function MediaUploadControl({
46
42
  accept,
47
43
  basePath,
48
- privateToken = null,
49
44
  onUploaded,
50
45
  label = 'Upload',
51
46
  className,
52
47
  }: MediaUploadControlProps) {
53
48
  const [open, setOpen] = useState(false);
54
49
  const [tags, setTags] = useState('');
55
- const [asPrivate, setAsPrivate] = useState(false);
56
50
  const [busy, setBusy] = useState(false);
57
51
  const [error, setError] = useState<string | null>(null);
58
52
  const inputRef = useRef<HTMLInputElement>(null);
@@ -62,19 +56,13 @@ export function MediaUploadControl({
62
56
  const uploadFiles = async (list: FileList) => {
63
57
  setBusy(true);
64
58
  setError(null);
65
- const wantPrivate = asPrivate && !!privateToken;
66
59
  const parsedTags = tags
67
60
  .split(',')
68
61
  .map((t) => t.trim())
69
62
  .filter(Boolean);
70
63
  try {
71
64
  for (const file of Array.from(list)) {
72
- const res = wantPrivate
73
- ? await client.uploadPrivate(file, {
74
- tags: parsedTags,
75
- unlockToken: privateToken,
76
- })
77
- : await client.uploadAuto(file, { tags: parsedTags });
65
+ const res = await client.uploadAuto(file, { tags: parsedTags });
78
66
  if (!res.ok) {
79
67
  setError(res.error);
80
68
  return;
@@ -82,8 +70,7 @@ export function MediaUploadControl({
82
70
  }
83
71
  setOpen(false);
84
72
  setTags('');
85
- setAsPrivate(false);
86
- onUploaded({ wasPrivate: wantPrivate });
73
+ onUploaded();
87
74
  } finally {
88
75
  setBusy(false);
89
76
  }
@@ -113,14 +100,6 @@ export function MediaUploadControl({
113
100
  className='max-w-[200px]'
114
101
  aria-label='Upload tags'
115
102
  />
116
- {privateToken ? (
117
- <Checkbox
118
- checked={asPrivate}
119
- onCheckedChange={(checked) => setAsPrivate(checked === true)}
120
- disabled={busy}
121
- label='Private'
122
- />
123
- ) : null}
124
103
  <Button
125
104
  size='sm'
126
105
  disabled={busy}
@@ -4,8 +4,13 @@ import { sessionAuthHeaders } from '../sessionAuth.js';
4
4
  // ── File-upload client (hook-free) ───────────────────────────────────────────────
5
5
  // The one client for mountFileRoutes' upload edge, shared by every consumer (the files
6
6
  // plugin, gallery upload buttons, the video editor's save-back) so the header protocol
7
- // (X-Filename / X-Tags / X-Unlock-Token) and the chunked-vs-streamed decision live in
8
- // exactly one place. Hook-free like privateClient: plugins hold no upload state.
7
+ // (X-Filename / X-Tags) and the chunked-vs-streamed decision live in exactly one place.
8
+ // Hook-free: plugins hold no upload state.
9
+ //
10
+ // 🔴 There is no private upload any more (task 108, 2026-09-23). `uploadPrivate` PUT to
11
+ // `/_files/upload-private`, a route `cursedbelt-server` deleted with its private-media module
12
+ // (task 00-urgent-24), so the method was a 404 waiting for a caller. It went, with the rest of
13
+ // the private-media client tier, rather than staying as a method that cannot succeed.
9
14
 
10
15
  /** The FileRecord subset consumers act on after an upload (full shape: server files/types). */
11
16
  export type UploadedFile = {
@@ -35,12 +40,6 @@ export interface FileUploadClientConfig {
35
40
  export interface FileUploadClient {
36
41
  /** Streamed single-PUT public upload. */
37
42
  upload(file: File | Blob, opts?: { name?: string; tags?: string[] }): Promise<UploadOutcome>;
38
- /** Private (encrypted) upload — pass the unlock token of the already-unlocked session, or a
39
- * password for a deniable set; neither ⇒ the standard server-key set. */
40
- uploadPrivate(
41
- file: File | Blob,
42
- opts?: { name?: string; tags?: string[]; unlockToken?: string | null; password?: string },
43
- ): Promise<UploadOutcome>;
44
43
  /** Overwrite an existing file's bytes in place (same id; tags/collections survive). */
45
44
  replace(
46
45
  fileId: string,
@@ -163,15 +162,6 @@ export function createFileUploadClient(cfg: FileUploadClientConfig = {}): FileUp
163
162
  return {
164
163
  upload,
165
164
 
166
- async uploadPrivate(file, opts) {
167
- const headers = commonHeaders(file, opts);
168
- if (opts?.unlockToken) headers['X-Unlock-Token'] = opts.unlockToken;
169
- else if (opts?.password) headers['X-Private-Password'] = opts.password;
170
- return outcome(
171
- await doFetch(`${base}/upload-private`, { method: 'PUT', headers, body: file }),
172
- );
173
- },
174
-
175
165
  async replace(fileId, file, opts) {
176
166
  return outcome(
177
167
  await doFetch(`${base}/replace/${fileId}`, {
@@ -339,7 +339,10 @@ export function attachHlsSource(
339
339
  // guidance: calling `recoverMediaError()` twice for the same stall recovers
340
340
  // nothing, and the audio-codec swap is what clears the case that keeps recurring.
341
341
  if (attempt === 0) hls.recoverMediaError();
342
- else hls.swapAudioCodec?.(), hls.recoverMediaError();
342
+ else {
343
+ hls.swapAudioCodec?.();
344
+ hls.recoverMediaError();
345
+ }
343
346
  return;
344
347
  }
345
348
  if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
@@ -145,15 +145,6 @@ export {
145
145
  probeMediaDurationFromUrl,
146
146
  useMediaDuration,
147
147
  } from './mediaProbe.js';
148
- export { PrivateIndexView } from './PrivateIndexView.js';
149
- export { PrivateModeBadge, type PrivateModeBadgeProps } from './PrivateModeBadge.js';
150
- export {
151
- type PrivateClientConfig,
152
- privateFileUrlWithToken,
153
- privateHlsUrlWithToken,
154
- revealPrivateWithPassword,
155
- revealPrivateWithToken,
156
- } from './privateClient.js';
157
148
  export type {
158
149
  ImageEditorMode,
159
150
  ImageEditorOperation,
@@ -168,13 +159,6 @@ export type {
168
159
  export { type UseAudioPeaksOptions, useAudioPeaks } from './useAudioPeaks.js';
169
160
  export { useIntersectionObserver } from './useIntersectionObserver.js';
170
161
  export { useObjectUrl } from './useObjectUrl.js';
171
- export {
172
- type PrivateIndexRow,
173
- type PrivateUnlock,
174
- type RevealedItem,
175
- type UsePrivateUnlockOptions,
176
- usePrivateUnlock,
177
- } from './usePrivateUnlock.js';
178
162
  // Attaching an adaptive ladder to a bare `<video>` — native HLS first, hls.js second, the
179
163
  // progressive remux always. Exported because three surfaces in the fleet drive a plain
180
164
  // element rather than Vidstack and each was carrying its own copy of the decision.
@@ -1,6 +1,6 @@
1
1
  // ── Session auth headers (hook-free) ────────────────────────────────────────
2
2
  // One source of truth for "turn the browser session into a Bearer header". Plugins
3
- // that call guarded endpoints (uploads, the private-media client, RPC over fetch)
3
+ // that call guarded endpoints (uploads, RPC over fetch)
4
4
  // all need the same header shape; before this they each re-rolled the cookie read.
5
5
  //
6
6
  // NOTE (genapp-34): a generated app's `session` cookie is now `HttpOnly`, so this
@@ -19,8 +19,7 @@
19
19
  *
20
20
  * Shaped as a plain function (not a hook) so it can be passed straight to helpers
21
21
  * that expect an `authHeaders: () => Record<string, string>` callback
22
- * (`revealPrivateWithToken`, `privateHlsUrlWithToken`, …) and spread into a
23
- * `fetch` init's headers.
22
+ * (`createFileUploadClient`'s, for one) and spread into a `fetch` init's headers.
24
23
  */
25
24
  export function sessionAuthHeaders(): Record<string, string> {
26
25
  const token =
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Every `bun run <x>` a script calls, and every command the app manifest names, is a script
3
+ * that EXISTS in this `package.json`.
4
+ *
5
+ * 🔴 Task 102, 2026-09-18: `done:serial` was `typecheck && lint && test && build`, and there was
6
+ * no `lint` script — so it had never once reached `test` or `build`, and the 31 lint errors a
7
+ * `lint` would have shown sat unseen. `format` was named in `cursed.app.extraCommands` too.
8
+ * Neither was in the gate, which is exactly why nobody noticed; this spec is in the gate.
9
+ */
10
+ import { describe, expect, it } from 'bun:test';
11
+ import pkg from '../package.json';
12
+
13
+ const scripts = (pkg as { scripts: Record<string, string> }).scripts;
14
+
15
+ /** The script names a command line invokes through `bun run` / `npm run`. */
16
+ export function calledScripts(command: string): string[] {
17
+ const out: string[] = [];
18
+ for (const m of command.matchAll(/\b(?:bun|npm|pnpm|yarn) run\s+([A-Za-z0-9:_./-]+)/g)) {
19
+ // A path (`bun run scripts/x.ts`) is a file, not a script name.
20
+ if (m[1] && !m[1].includes('/') && !/\.[cm]?[jt]sx?$/.test(m[1])) out.push(m[1]);
21
+ }
22
+ return out;
23
+ }
24
+
25
+ describe('package.json scripts resolve', () => {
26
+ it('reads `bun run <name>` and skips `bun run <file>`', () => {
27
+ expect(calledScripts('bun run typecheck && bun run lint && bun run scripts/gate.ts')).toEqual([
28
+ 'typecheck',
29
+ 'lint',
30
+ ]);
31
+ });
32
+
33
+ it('🔴 fails on a script that calls one that is not there — the task-102 shape', () => {
34
+ const fake = { 'done:serial': 'bun run typecheck && bun run lint', typecheck: 'tsc' };
35
+ const dangling = Object.values(fake)
36
+ .flatMap(calledScripts)
37
+ .filter((name) => !(name in fake));
38
+ expect(dangling).toEqual(['lint']);
39
+ });
40
+
41
+ it('every `bun run <script>` inside a script names a script that exists', () => {
42
+ const dangling: string[] = [];
43
+ for (const [name, command] of Object.entries(scripts)) {
44
+ for (const called of calledScripts(command)) {
45
+ if (!(called in scripts)) dangling.push(`${name} → ${called}`);
46
+ }
47
+ }
48
+ expect(dangling).toEqual([]);
49
+ });
50
+
51
+ it('every command the `cursed.app` manifest names is a script', () => {
52
+ const app = (pkg as { cursed?: { app?: Record<string, unknown> } }).cursed?.app ?? {};
53
+ const named: string[] = [];
54
+ for (const key of ['commands', 'extraCommands']) {
55
+ const block = app[key] as Record<string, string> | undefined;
56
+ if (block) named.push(...Object.values(block));
57
+ }
58
+ expect(named.length).toBeGreaterThan(0);
59
+ expect(named.filter((n) => !(n in scripts))).toEqual([]);
60
+ });
61
+
62
+ it('every gate stage is a script', () => {
63
+ const stages = (pkg as { gate?: { stages?: { script: string }[] } }).gate?.stages ?? [];
64
+ expect(stages.map((s) => s.script).filter((n) => !(n in scripts))).toEqual([]);
65
+ });
66
+ });
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
17
17
  * emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
18
- * when this file is stale. 72 candidate(s) scanned.
18
+ * when this file is stale. 71 candidate(s) scanned.
19
19
  *
20
20
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
21
21
  * `bun run build` runs. The map of what is in which area, and why the areas are