@trypattern/sdk 0.0.0-stage → 0.1.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.
Files changed (247) hide show
  1. package/README.md +127 -2
  2. package/dist/access.d.ts +31 -0
  3. package/dist/access.js +72 -0
  4. package/dist/actions.d.ts +57 -0
  5. package/dist/actions.js +198 -0
  6. package/dist/api.d.ts +20 -0
  7. package/dist/api.js +34 -0
  8. package/dist/app.d.ts +106 -0
  9. package/dist/app.js +224 -0
  10. package/dist/appearance.d.ts +7 -0
  11. package/dist/appearance.js +2 -0
  12. package/dist/auth/sessionApi.d.ts +41 -0
  13. package/dist/auth/sessionApi.js +158 -0
  14. package/dist/auth/sessionStore.d.ts +20 -0
  15. package/dist/auth/sessionStore.js +40 -0
  16. package/dist/auth/user.d.ts +11 -0
  17. package/dist/auth/user.js +9 -0
  18. package/dist/builtins/Account.d.ts +25 -0
  19. package/dist/builtins/Account.js +15 -0
  20. package/dist/builtins/Palette.d.ts +29 -0
  21. package/dist/builtins/Palette.js +63 -0
  22. package/dist/builtins/SignIn.d.ts +28 -0
  23. package/dist/builtins/SignIn.js +60 -0
  24. package/dist/builtins/Toasts.d.ts +32 -0
  25. package/dist/builtins/Toasts.js +24 -0
  26. package/dist/builtins/actions.d.ts +2 -0
  27. package/dist/builtins/actions.js +45 -0
  28. package/dist/cache.d.ts +24 -0
  29. package/dist/cache.js +112 -0
  30. package/dist/cli/app.d.ts +2 -0
  31. package/dist/cli/app.js +1329 -0
  32. package/dist/cli/screenshot.d.ts +1 -0
  33. package/dist/cli/screenshot.js +96 -0
  34. package/dist/cli/tools.d.ts +12 -0
  35. package/dist/cli/tools.js +35 -0
  36. package/dist/components/AppFrame.d.ts +14 -0
  37. package/dist/components/AppFrame.js +64 -0
  38. package/dist/components/AppearancePicker.d.ts +1 -0
  39. package/dist/components/AppearancePicker.js +12 -0
  40. package/dist/components/Avatar.d.ts +9 -0
  41. package/dist/components/Avatar.js +63 -0
  42. package/dist/components/Badge.d.ts +11 -0
  43. package/dist/components/Badge.js +87 -0
  44. package/dist/components/Button.d.ts +23 -0
  45. package/dist/components/Button.js +249 -0
  46. package/dist/components/Card.d.ts +12 -0
  47. package/dist/components/Card.js +38 -0
  48. package/dist/components/CommandMenu.d.ts +23 -0
  49. package/dist/components/CommandMenu.js +214 -0
  50. package/dist/components/CommandPalette.d.ts +1 -0
  51. package/dist/components/CommandPalette.js +7 -0
  52. package/dist/components/Content.d.ts +7 -0
  53. package/dist/components/Content.js +24 -0
  54. package/dist/components/ContextMenu.d.ts +8 -0
  55. package/dist/components/ContextMenu.js +23 -0
  56. package/dist/components/Dialog.d.ts +11 -0
  57. package/dist/components/Dialog.js +81 -0
  58. package/dist/components/Divider.d.ts +6 -0
  59. package/dist/components/Divider.js +15 -0
  60. package/dist/components/EmptyState.d.ts +8 -0
  61. package/dist/components/EmptyState.js +24 -0
  62. package/dist/components/Form.d.ts +9 -0
  63. package/dist/components/Form.js +23 -0
  64. package/dist/components/Icon.d.ts +10 -0
  65. package/dist/components/Icon.js +22 -0
  66. package/dist/components/IconButton.d.ts +14 -0
  67. package/dist/components/IconButton.js +53 -0
  68. package/dist/components/Kbd.d.ts +6 -0
  69. package/dist/components/Kbd.js +67 -0
  70. package/dist/components/Link.d.ts +9 -0
  71. package/dist/components/Link.js +35 -0
  72. package/dist/components/ListRow.d.ts +12 -0
  73. package/dist/components/ListRow.js +62 -0
  74. package/dist/components/LoadingRows.d.ts +5 -0
  75. package/dist/components/LoadingRows.js +6 -0
  76. package/dist/components/Menu.d.ts +12 -0
  77. package/dist/components/Menu.js +33 -0
  78. package/dist/components/MenuItems.d.ts +21 -0
  79. package/dist/components/MenuItems.js +32 -0
  80. package/dist/components/Placement.d.ts +21 -0
  81. package/dist/components/Placement.js +1 -0
  82. package/dist/components/Popup.d.ts +8 -0
  83. package/dist/components/Popup.js +43 -0
  84. package/dist/components/PopupItem.d.ts +8 -0
  85. package/dist/components/PopupItem.js +51 -0
  86. package/dist/components/PropertyRow.d.ts +7 -0
  87. package/dist/components/PropertyRow.js +23 -0
  88. package/dist/components/Row.d.ts +16 -0
  89. package/dist/components/Row.js +27 -0
  90. package/dist/components/Screen.d.ts +13 -0
  91. package/dist/components/Screen.js +56 -0
  92. package/dist/components/Scrim.d.ts +8 -0
  93. package/dist/components/Scrim.js +40 -0
  94. package/dist/components/ScrollArea.d.ts +15 -0
  95. package/dist/components/ScrollArea.js +13 -0
  96. package/dist/components/SegmentedControl.d.ts +17 -0
  97. package/dist/components/SegmentedControl.js +68 -0
  98. package/dist/components/Select.d.ts +17 -0
  99. package/dist/components/Select.js +107 -0
  100. package/dist/components/SettingsRow.d.ts +8 -0
  101. package/dist/components/SettingsRow.js +32 -0
  102. package/dist/components/Sidebar.d.ts +20 -0
  103. package/dist/components/Sidebar.js +138 -0
  104. package/dist/components/SignInScreen.d.ts +1 -0
  105. package/dist/components/SignInScreen.js +40 -0
  106. package/dist/components/Size.d.ts +1 -0
  107. package/dist/components/Size.js +1 -0
  108. package/dist/components/Skeleton.d.ts +8 -0
  109. package/dist/components/Skeleton.js +48 -0
  110. package/dist/components/Spinner.d.ts +6 -0
  111. package/dist/components/Spinner.js +54 -0
  112. package/dist/components/Stack.d.ts +14 -0
  113. package/dist/components/Stack.js +19 -0
  114. package/dist/components/Switch.d.ts +10 -0
  115. package/dist/components/Switch.js +71 -0
  116. package/dist/components/Text.d.ts +21 -0
  117. package/dist/components/Text.js +77 -0
  118. package/dist/components/TextField.d.ts +25 -0
  119. package/dist/components/TextField.js +68 -0
  120. package/dist/components/Toaster.d.ts +1 -0
  121. package/dist/components/Toaster.js +83 -0
  122. package/dist/components/Tooltip.d.ts +8 -0
  123. package/dist/components/Tooltip.js +43 -0
  124. package/dist/components/controlLook.d.ts +60 -0
  125. package/dist/components/controlLook.js +67 -0
  126. package/dist/components/frame.d.ts +4 -0
  127. package/dist/components/frame.js +2 -0
  128. package/dist/components/icons.d.ts +58 -0
  129. package/dist/components/icons.js +105 -0
  130. package/dist/components/interaction.d.ts +12 -0
  131. package/dist/components/interaction.js +22 -0
  132. package/dist/components/spacing.d.ts +118 -0
  133. package/dist/components/spacing.js +46 -0
  134. package/dist/components/tones.d.ts +28 -0
  135. package/dist/components/tones.js +12 -0
  136. package/dist/entityStore.d.ts +46 -0
  137. package/dist/entityStore.js +160 -0
  138. package/dist/flows/accessFlows.d.ts +9 -0
  139. package/dist/flows/accessFlows.js +151 -0
  140. package/dist/flows/browser.d.ts +32 -0
  141. package/dist/flows/browser.js +164 -0
  142. package/dist/flows/contactSheet.d.ts +1 -0
  143. package/dist/flows/contactSheet.js +63 -0
  144. package/dist/flows/flowBrowser.d.ts +23 -0
  145. package/dist/flows/flowBrowser.js +21 -0
  146. package/dist/flows/flowSetup.d.ts +3 -0
  147. package/dist/flows/flowSetup.js +35 -0
  148. package/dist/flows/flows.d.ts +41 -0
  149. package/dist/flows/flows.js +148 -0
  150. package/dist/flows/screenshotName.d.ts +1 -0
  151. package/dist/flows/screenshotName.js +6 -0
  152. package/dist/gate.d.ts +8 -0
  153. package/dist/gate.js +52 -0
  154. package/dist/hooks/appContext.d.ts +14 -0
  155. package/dist/hooks/appContext.js +5 -0
  156. package/dist/hooks/appHooks.d.ts +21 -0
  157. package/dist/hooks/appHooks.js +24 -0
  158. package/dist/hooks/useAnimatesOnChange.d.ts +1 -0
  159. package/dist/hooks/useAnimatesOnChange.js +14 -0
  160. package/dist/hooks/useStoreState.d.ts +6 -0
  161. package/dist/hooks/useStoreState.js +5 -0
  162. package/dist/index.d.ts +40 -0
  163. package/dist/index.js +19 -0
  164. package/dist/memoryApi.d.ts +19 -0
  165. package/dist/memoryApi.js +42 -0
  166. package/dist/model.d.ts +94 -0
  167. package/dist/model.js +243 -0
  168. package/dist/page.d.ts +11 -0
  169. package/dist/page.js +9 -0
  170. package/dist/platform.d.ts +1 -0
  171. package/dist/platform.js +5 -0
  172. package/dist/react.d.ts +46 -0
  173. package/dist/react.js +38 -0
  174. package/dist/registered.d.ts +54 -0
  175. package/dist/registered.js +17 -0
  176. package/dist/routes.d.ts +38 -0
  177. package/dist/routes.js +21 -0
  178. package/dist/rules/checkAllFiles.d.ts +1 -0
  179. package/dist/rules/checkAllFiles.js +741 -0
  180. package/dist/rules/checkApiRouting.d.ts +1 -0
  181. package/dist/rules/checkApiRouting.js +112 -0
  182. package/dist/rules/lintPlugin.d.ts +252 -0
  183. package/dist/rules/lintPlugin.js +1157 -0
  184. package/dist/rules/messages.d.ts +48 -0
  185. package/dist/rules/messages.js +48 -0
  186. package/dist/rules/themeTokens.d.ts +6 -0
  187. package/dist/rules/themeTokens.js +64 -0
  188. package/dist/schema.d.ts +2 -0
  189. package/dist/schema.js +1 -0
  190. package/dist/server/auth.d.ts +115 -0
  191. package/dist/server/auth.js +131 -0
  192. package/dist/server/authService.d.ts +9 -0
  193. package/dist/server/authService.js +50 -0
  194. package/dist/server/createServer.d.ts +357 -0
  195. package/dist/server/createServer.js +62 -0
  196. package/dist/server/env.d.ts +15 -0
  197. package/dist/server/env.js +1 -0
  198. package/dist/server/index.d.ts +8 -0
  199. package/dist/server/index.js +5 -0
  200. package/dist/server/session.d.ts +28 -0
  201. package/dist/server/session.js +54 -0
  202. package/dist/server/sessions.d.ts +92 -0
  203. package/dist/server/sessions.js +47 -0
  204. package/dist/server/signInWire.d.ts +52 -0
  205. package/dist/server/signInWire.js +22 -0
  206. package/dist/server/tables.d.ts +242 -0
  207. package/dist/server/tables.js +246 -0
  208. package/dist/server/users.d.ts +189 -0
  209. package/dist/server/users.js +49 -0
  210. package/dist/shortcuts.d.ts +3 -0
  211. package/dist/shortcuts.js +40 -0
  212. package/dist/start.d.ts +20 -0
  213. package/dist/start.js +121 -0
  214. package/dist/state.d.ts +14 -0
  215. package/dist/state.js +17 -0
  216. package/dist/store.d.ts +43 -0
  217. package/dist/store.js +198 -0
  218. package/dist/subscribers.d.ts +5 -0
  219. package/dist/subscribers.js +13 -0
  220. package/dist/table.d.ts +143 -0
  221. package/dist/table.js +91 -0
  222. package/dist/tables.d.ts +77 -0
  223. package/dist/tables.js +216 -0
  224. package/dist/theme/reset.css +84 -0
  225. package/dist/theme/scales.d.ts +76 -0
  226. package/dist/theme/scales.js +82 -0
  227. package/dist/theme/tokens.stylex.d.ts +218 -0
  228. package/dist/theme/tokens.stylex.js +233 -0
  229. package/dist/vite/commandBridge.d.ts +2 -0
  230. package/dist/vite/commandBridge.js +46 -0
  231. package/dist/vite/designManifest.d.ts +25 -0
  232. package/dist/vite/designManifest.js +191 -0
  233. package/dist/vite/localWorker.d.ts +4 -0
  234. package/dist/vite/localWorker.js +59 -0
  235. package/dist/vite/rootAlias.d.ts +2 -0
  236. package/dist/vite/rootAlias.js +15 -0
  237. package/dist/vite/vite.d.ts +7 -0
  238. package/dist/vite/vite.js +88 -0
  239. package/dist/wire.d.ts +7 -0
  240. package/dist/wire.js +1 -0
  241. package/package.json +99 -3
  242. package/presets/lintPlugin.js +7 -0
  243. package/presets/oxlint.json +167 -0
  244. package/presets/register.d.ts +19 -0
  245. package/presets/server.d.ts +2 -0
  246. package/presets/tsconfig.app.json +29 -0
  247. package/presets/tsconfig.server.json +27 -0
@@ -0,0 +1,28 @@
1
+ import * as stylex from '@stylexjs/stylex';
2
+ export type Tone = 'inherit' | 'primary' | 'secondary' | 'tertiary' | 'brand' | 'danger' | 'positive' | 'warning';
3
+ export declare const tones: Readonly<{
4
+ readonly inherit: Readonly<{
5
+ readonly color: stylex.StyleXClassNameFor<"color", "inherit">;
6
+ }>;
7
+ readonly primary: Readonly<{
8
+ readonly color: stylex.StyleXClassNameFor<"color", string>;
9
+ }>;
10
+ readonly secondary: Readonly<{
11
+ readonly color: stylex.StyleXClassNameFor<"color", string>;
12
+ }>;
13
+ readonly tertiary: Readonly<{
14
+ readonly color: stylex.StyleXClassNameFor<"color", string>;
15
+ }>;
16
+ readonly brand: Readonly<{
17
+ readonly color: stylex.StyleXClassNameFor<"color", string>;
18
+ }>;
19
+ readonly danger: Readonly<{
20
+ readonly color: stylex.StyleXClassNameFor<"color", string>;
21
+ }>;
22
+ readonly positive: Readonly<{
23
+ readonly color: stylex.StyleXClassNameFor<"color", string>;
24
+ }>;
25
+ readonly warning: Readonly<{
26
+ readonly color: stylex.StyleXClassNameFor<"color", string>;
27
+ }>;
28
+ }>;
@@ -0,0 +1,12 @@
1
+ import * as stylex from '@stylexjs/stylex';
2
+ import { colors } from "../theme/tokens.stylex.js";
3
+ export const tones = stylex.create({
4
+ inherit: { color: 'inherit' },
5
+ primary: { color: colors.primaryForeground },
6
+ secondary: { color: colors.secondaryForeground },
7
+ tertiary: { color: colors.tertiaryForeground },
8
+ brand: { color: colors.brandForeground },
9
+ danger: { color: colors.dangerForeground },
10
+ positive: { color: colors.positiveForeground },
11
+ warning: { color: colors.warningForeground }
12
+ });
@@ -0,0 +1,46 @@
1
+ import type { WriteOutcome } from './access.ts';
2
+ import type { Cached } from './cache.ts';
3
+ import { StoreBase } from './store.ts';
4
+ import type { Writes } from './table.ts';
5
+ export type Entity = {
6
+ id: string;
7
+ };
8
+ export type EntityApi<T extends Entity> = {
9
+ list: () => Promise<readonly T[]>;
10
+ create: (entity: T) => Promise<T>;
11
+ update: (entity: T) => Promise<T>;
12
+ remove: (id: string) => Promise<void>;
13
+ };
14
+ export type EntityCall = keyof EntityApi<Entity>;
15
+ export declare const entityCalls: readonly EntityCall[];
16
+ export type PendingWrite<T extends Entity> = {
17
+ kind: 'create' | 'update';
18
+ id: string;
19
+ row: T;
20
+ } | {
21
+ kind: 'remove';
22
+ id: string;
23
+ };
24
+ export type EntitySnapshot<T extends Entity> = {
25
+ status: 'idle' | 'loading' | 'ready' | 'failed';
26
+ items: readonly T[];
27
+ pending: readonly PendingWrite<T>[];
28
+ };
29
+ export type EntityDeps<T extends Entity> = {
30
+ api: EntityApi<T>;
31
+ cache: Cached<readonly T[]>;
32
+ writes: Writes;
33
+ };
34
+ export declare class WriteRefused extends Error {
35
+ readonly outcome: Exclude<WriteOutcome, 'saved' | 'failed'>;
36
+ constructor(outcome: Exclude<WriteOutcome, 'saved' | 'failed'>);
37
+ }
38
+ export declare class EntityStore<T extends Entity> extends StoreBase<EntitySnapshot<T>, EntityDeps<T>> {
39
+ #private;
40
+ protected initialState(): EntitySnapshot<T>;
41
+ load(): Promise<void>;
42
+ create(row: T): Promise<WriteOutcome>;
43
+ update(row: T): Promise<WriteOutcome>;
44
+ remove(id: string): Promise<WriteOutcome>;
45
+ forget(): void;
46
+ }
@@ -0,0 +1,160 @@
1
+ import { StoreBase } from "./store.js";
2
+ export const entityCalls = [
3
+ 'list',
4
+ 'create',
5
+ 'update',
6
+ 'remove'
7
+ ];
8
+ export class WriteRefused extends Error {
9
+ outcome;
10
+ constructor(outcome) {
11
+ super(`The server refused the write: ${outcome}`);
12
+ this.outcome = outcome;
13
+ }
14
+ }
15
+ function apply(items, write) {
16
+ if (write.kind === 'remove') {
17
+ return items.filter((item) => item.id !== write.id);
18
+ }
19
+ const { row } = write;
20
+ return items.some((item) => item.id === row.id)
21
+ ? items.map((item) => (item.id === row.id ? row : item))
22
+ : [...items, row];
23
+ }
24
+ function outcomeOf(failure) {
25
+ return failure instanceof WriteRefused ? failure.outcome : 'failed';
26
+ }
27
+ function isSameJson(a, b) {
28
+ if (Object.is(a, b))
29
+ return true;
30
+ if (typeof a !== 'object' || typeof b !== 'object' || !a || !b)
31
+ return false;
32
+ if (Array.isArray(a) !== Array.isArray(b))
33
+ return false;
34
+ const aKeys = Object.keys(a);
35
+ const bKeys = Object.keys(b);
36
+ return (aKeys.length === bKeys.length &&
37
+ aKeys.every((key) => isSameJson(Reflect.get(a, key), Reflect.get(b, key))));
38
+ }
39
+ function sameElements(a, b) {
40
+ return a.length === b.length && a.every((item, index) => item === b[index]);
41
+ }
42
+ function share(previous, next) {
43
+ const before = new Map(previous.map((item) => [item.id, item]));
44
+ const shared = next.map((item) => {
45
+ const kept = before.get(item.id);
46
+ return kept !== undefined && isSameJson(kept, item) ? kept : item;
47
+ });
48
+ return sameElements(previous, shared) ? previous : shared;
49
+ }
50
+ export class EntityStore extends StoreBase {
51
+ #server = this.state.items;
52
+ #queue = [];
53
+ #completedWrites = 0;
54
+ #generation = 0;
55
+ #loading;
56
+ initialState() {
57
+ const cached = this.deps.cache.read();
58
+ return {
59
+ status: cached === undefined ? 'idle' : 'ready',
60
+ items: cached ?? [],
61
+ pending: []
62
+ };
63
+ }
64
+ load() {
65
+ this.#loading ??= this.#fetch().finally(() => {
66
+ this.#loading = undefined;
67
+ });
68
+ return this.#loading;
69
+ }
70
+ create(row) {
71
+ return this.#write({ kind: 'create', id: row.id, row }, async () => ({
72
+ kind: 'create',
73
+ id: row.id,
74
+ row: await this.deps.api.create(row)
75
+ }));
76
+ }
77
+ update(row) {
78
+ return this.#write({ kind: 'update', id: row.id, row }, async () => ({
79
+ kind: 'update',
80
+ id: row.id,
81
+ row: await this.deps.api.update(row)
82
+ }));
83
+ }
84
+ remove(id) {
85
+ return this.#write({ kind: 'remove', id }, async () => {
86
+ await this.deps.api.remove(id);
87
+ return { kind: 'remove', id };
88
+ });
89
+ }
90
+ forget() {
91
+ this.#generation += 1;
92
+ this.#server = [];
93
+ this.#queue = [];
94
+ this.#loading = undefined;
95
+ this.set({ status: 'idle', items: [], pending: [] });
96
+ }
97
+ async #fetch() {
98
+ if (this.state.status !== 'ready')
99
+ this.set({ status: 'loading' });
100
+ const writesBefore = this.#completedWrites;
101
+ const generation = this.#generation;
102
+ try {
103
+ const items = await this.deps.api.list();
104
+ if (generation !== this.#generation)
105
+ return;
106
+ if (writesBefore !== this.#completedWrites)
107
+ return this.#fetch();
108
+ this.#server = items;
109
+ this.deps.cache.write(items);
110
+ this.#publish({ status: 'ready' });
111
+ }
112
+ catch {
113
+ if (generation !== this.#generation)
114
+ return;
115
+ if (this.state.status === 'loading')
116
+ this.set({ status: 'failed' });
117
+ }
118
+ }
119
+ async #write(write, send) {
120
+ const generation = this.#generation;
121
+ this.#queue = [...this.#queue, write];
122
+ this.#publish();
123
+ try {
124
+ const confirmed = await send();
125
+ if (generation !== this.#generation)
126
+ return 'failed';
127
+ this.#saved(confirmed);
128
+ return 'saved';
129
+ }
130
+ catch (failure) {
131
+ const outcome = outcomeOf(failure);
132
+ if (outcome === 'missing' && generation === this.#generation) {
133
+ this.#saved({ kind: 'remove', id: write.id });
134
+ }
135
+ return outcome;
136
+ }
137
+ finally {
138
+ if (generation === this.#generation) {
139
+ this.#queue = this.#queue.filter((queued) => queued !== write);
140
+ this.#publish();
141
+ }
142
+ }
143
+ }
144
+ #saved(write) {
145
+ this.#server = apply(this.#server, write);
146
+ this.#completedWrites += 1;
147
+ this.deps.cache.write(this.#server);
148
+ }
149
+ #publish(patch = {}) {
150
+ const { items, pending } = this.state;
151
+ const shown = this.deps.writes === 'optimistic'
152
+ ? this.#queue.reduce(apply, this.#server)
153
+ : this.#server;
154
+ this.set({
155
+ ...patch,
156
+ items: share(items, shown),
157
+ pending: sameElements(pending, this.#queue) ? pending : this.#queue
158
+ });
159
+ }
160
+ }
@@ -0,0 +1,9 @@
1
+ import type { AppClass, RunningApp } from '../app.ts';
2
+ import type { Tables } from '../table.ts';
3
+ type AnyApp = RunningApp<unknown, {
4
+ readonly path: string;
5
+ }>;
6
+ export declare function accessFlows<Group extends Tables>({ App }: {
7
+ App: AppClass<Group, AnyApp>;
8
+ }): void;
9
+ export {};
@@ -0,0 +1,151 @@
1
+ import { createMemoryRouter } from 'react-router';
2
+ import { expect, inject, test } from 'vitest';
3
+ import { memoryApi } from "../api.js";
4
+ import { Cache } from "../cache.js";
5
+ import { Gate } from "../gate.js";
6
+ import { routeObjects } from "../routes.js";
7
+ import { ownerIdOf, seededRows } from "../table.js";
8
+ import { publicStoreName } from "../tables.js";
9
+ const STRANGER = 'stranger@access.test';
10
+ function seesAll(level) {
11
+ return level === 'anyone' || level === 'signedIn';
12
+ }
13
+ function strangerOutcome(table, operation) {
14
+ const level = table.access[operation];
15
+ if (level === 'admin' || level === 'server')
16
+ return 'forbidden';
17
+ if (!seesAll(table.access.read))
18
+ return 'missing';
19
+ return level === 'owner' ? 'forbidden' : 'saved';
20
+ }
21
+ function creatorOf(table, admin) {
22
+ const level = table.access.create;
23
+ if (level === 'anyone' || level === 'signedIn')
24
+ return STRANGER;
25
+ return level === 'admin' ? admin : undefined;
26
+ }
27
+ function fieldsOf(table, row) {
28
+ return Object.fromEntries(Object.keys(table.columns).map((key) => [key, row[key]]));
29
+ }
30
+ function idsOf(rows) {
31
+ return rows.map((row) => row.id);
32
+ }
33
+ export function accessFlows({ App }) {
34
+ if (inject('patternVisualFlows') !== undefined)
35
+ return;
36
+ const { tables, routes } = App.definition;
37
+ const seed = (inject('patternSeed') ?? {});
38
+ const admins = inject('patternAdmins') ?? [];
39
+ const [admin] = admins;
40
+ async function started(signedInAs) {
41
+ const gate = new Gate();
42
+ const app = new App(createMemoryRouter(routeObjects(routes), { initialEntries: ['/'] }), {
43
+ api: memoryApi(tables, seed, { gate, signedInAs, admins }),
44
+ cache: Cache.memory(),
45
+ gate
46
+ });
47
+ await app.whenIdle();
48
+ return app;
49
+ }
50
+ function storeOf(app, name) {
51
+ const stores = Reflect.get(app, 'stores');
52
+ return Reflect.get(Object(stores), name);
53
+ }
54
+ async function visibleIds(app, name) {
55
+ await app.whenIdle();
56
+ const snapshot = Reflect.get(Object(app.getSnapshot()), 'stores');
57
+ const state = Reflect.get(Object(snapshot), name);
58
+ const items = Reflect.get(Object(state), 'items');
59
+ return {
60
+ status: Reflect.get(Object(state), 'status'),
61
+ ids: Array.isArray(items)
62
+ ? items.map((item) => Reflect.get(Object(item), 'id'))
63
+ : []
64
+ };
65
+ }
66
+ for (const table of Object.values(tables)) {
67
+ const name = table.name;
68
+ const rows = seededRows(seed, name);
69
+ const read = table.access.read;
70
+ const owners = [
71
+ ...new Set(rows.flatMap((row) => (row.ownerId ? [row.ownerId] : [])))
72
+ ];
73
+ const ownerEmails = new Map((Reflect.get(seed, name) ?? [])
74
+ .flatMap((row) => typeof row['owner'] === 'string' ? [row['owner'].toLowerCase()] : [])
75
+ .map((email) => [ownerIdOf(email), email]));
76
+ if (read !== 'server') {
77
+ test(`Access: ${name}, signed out, ${read === 'anyone' ? 'every row is visible' : 'nothing loads'}`, async () => {
78
+ const seen = await visibleIds(await started(null), name);
79
+ expect(seen).toEqual(read === 'anyone'
80
+ ? { status: 'ready', ids: idsOf(rows) }
81
+ : { status: 'idle', ids: [] });
82
+ });
83
+ test(`Access: ${name}, a signed-in stranger ${seesAll(read) ? 'sees every row' : "sees none of other people's rows"}`, async () => {
84
+ const seen = await visibleIds(await started(STRANGER), name);
85
+ expect(seen.ids).toEqual(seesAll(read) ? idsOf(rows) : []);
86
+ });
87
+ if (read === 'owner') {
88
+ for (const ownerId of owners) {
89
+ const email = ownerEmails.get(ownerId) ?? ownerId;
90
+ test(`Access: ${name}, ${email} sees only their own rows`, async () => {
91
+ const seen = await visibleIds(await started(email), name);
92
+ expect(seen.ids).toEqual(idsOf(rows.filter((row) => row.ownerId === ownerId)));
93
+ });
94
+ }
95
+ }
96
+ if (admin !== undefined) {
97
+ test(`Access: ${name}, admin ${admin} sees every row`, async () => {
98
+ const seen = await visibleIds(await started(admin), name);
99
+ expect(seen.ids).toEqual(idsOf(rows));
100
+ });
101
+ }
102
+ const [first] = rows;
103
+ if (first !== undefined) {
104
+ for (const operation of ['update', 'delete']) {
105
+ const expected = strangerOutcome(table, operation);
106
+ const verb = operation === 'update' ? 'change' : 'delete';
107
+ test(`Access: ${name}, a signed-in stranger ${expected === 'saved' ? 'can' : 'cannot'} ${verb} someone else's row`, async () => {
108
+ const app = await started(STRANGER);
109
+ const store = storeOf(app, name);
110
+ const before = await visibleIds(app, name);
111
+ const outcome = operation === 'update'
112
+ ? await store.update(first)
113
+ : await store.remove(first.id);
114
+ expect(outcome).toBe(expected);
115
+ if (expected !== 'saved') {
116
+ expect(await visibleIds(app, name)).toEqual(before);
117
+ }
118
+ });
119
+ }
120
+ }
121
+ }
122
+ if (table.public.length > 0) {
123
+ const view = publicStoreName(name);
124
+ test(`Access: ${name}, signed out, ${view} shows only ${table.public.join(', ')}`, async () => {
125
+ const app = await started(null);
126
+ const snapshot = Reflect.get(Object(app.getSnapshot()), 'stores');
127
+ const state = Reflect.get(Object(snapshot), view);
128
+ expect(state).toEqual({
129
+ status: 'ready',
130
+ items: rows.map((row) => Object.fromEntries(['id', ...table.public].map((key) => [key, row[key] ?? null])))
131
+ });
132
+ });
133
+ }
134
+ const [duplicated] = rows;
135
+ const creator = creatorOf(table, admin);
136
+ if (table.unique.length > 0 &&
137
+ duplicated !== undefined &&
138
+ creator !== undefined &&
139
+ read !== 'server') {
140
+ for (const columns of table.unique) {
141
+ test(`Access: ${name}, a second row with the same ${columns.join(', ')} is refused`, async () => {
142
+ const app = await started(creator);
143
+ const before = await visibleIds(app, name);
144
+ const outcome = await storeOf(app, name).create(fieldsOf(table, duplicated));
145
+ expect(outcome).toBe('conflict');
146
+ expect(await visibleIds(app, name)).toEqual(before);
147
+ });
148
+ }
149
+ }
150
+ }
151
+ }
@@ -0,0 +1,32 @@
1
+ import type { Browser, BrowserServer, Page } from 'playwright-core';
2
+ export declare const viewports: {
3
+ readonly desktop: {
4
+ readonly width: 1440;
5
+ readonly height: 900;
6
+ readonly deviceScaleFactor: 1;
7
+ };
8
+ readonly compact: {
9
+ readonly width: 390;
10
+ readonly height: 844;
11
+ readonly deviceScaleFactor: 2;
12
+ };
13
+ };
14
+ export type ViewportName = keyof typeof viewports;
15
+ export declare const viewportNames: readonly ViewportName[];
16
+ export declare function launchChrome(): Promise<Browser>;
17
+ export declare function launchChromeServer(): Promise<BrowserServer>;
18
+ export declare function connectChrome(wsEndpoint: string): Promise<Browser>;
19
+ export type AppPage = {
20
+ page: Page;
21
+ problems: string[];
22
+ send: (command: unknown) => Promise<unknown>;
23
+ dispatch: (command: unknown) => Promise<void>;
24
+ snapshot: () => Promise<unknown>;
25
+ hold: (call: string) => Promise<void>;
26
+ release: (call: string) => Promise<void>;
27
+ reload: (path: string) => Promise<void>;
28
+ screenshot: (file: string) => Promise<void>;
29
+ captureAt: (viewport: ViewportName) => Promise<string>;
30
+ close: () => Promise<void>;
31
+ };
32
+ export declare function openApp(browser: Browser, baseUrl: string, path: string, viewport: ViewportName, signedInAs: string | null, seed?: object, admins?: readonly string[]): Promise<AppPage>;
@@ -0,0 +1,164 @@
1
+ import { chromium } from 'playwright-core';
2
+ export const viewports = {
3
+ desktop: { width: 1440, height: 900, deviceScaleFactor: 1 },
4
+ compact: { width: 390, height: 844, deviceScaleFactor: 2 }
5
+ };
6
+ export const viewportNames = ['desktop', 'compact'];
7
+ const QUIET_NETWORK_MS = 250;
8
+ const SETTLE_TIMEOUT_MS = 10_000;
9
+ const HOOK_TIMEOUT_MS = 10_000;
10
+ function chromeProblem(error) {
11
+ const reason = (error instanceof Error ? error.message : String(error))
12
+ .split('\n')
13
+ .find((line) => line.trim() !== '');
14
+ return new Error(`Could not launch Google Chrome (${reason ?? 'unknown error'}). Screenshots drive your installed Google Chrome; no browser is bundled. Install it from https://www.google.com/chrome/ (or update it), then rerun.`);
15
+ }
16
+ export async function launchChrome() {
17
+ try {
18
+ return await chromium.launch({ channel: 'chrome' });
19
+ }
20
+ catch (error) {
21
+ throw chromeProblem(error);
22
+ }
23
+ }
24
+ export async function launchChromeServer() {
25
+ try {
26
+ return await chromium.launchServer({ channel: 'chrome' });
27
+ }
28
+ catch (error) {
29
+ throw chromeProblem(error);
30
+ }
31
+ }
32
+ export function connectChrome(wsEndpoint) {
33
+ return chromium.connect(wsEndpoint);
34
+ }
35
+ function sleep(ms) {
36
+ return new Promise((done) => setTimeout(done, ms));
37
+ }
38
+ function watchNetwork(page) {
39
+ let inFlight = 0;
40
+ let lastActivity = Date.now();
41
+ const started = () => {
42
+ inFlight += 1;
43
+ lastActivity = Date.now();
44
+ };
45
+ const ended = () => {
46
+ inFlight = Math.max(0, inFlight - 1);
47
+ lastActivity = Date.now();
48
+ };
49
+ page.on('request', started);
50
+ page.on('requestfinished', ended);
51
+ page.on('requestfailed', ended);
52
+ return async () => {
53
+ const deadline = Date.now() + SETTLE_TIMEOUT_MS;
54
+ while (Date.now() < deadline) {
55
+ if (inFlight === 0 && Date.now() - lastActivity >= QUIET_NETWORK_MS) {
56
+ return;
57
+ }
58
+ await sleep(25);
59
+ }
60
+ };
61
+ }
62
+ function recordProblems(page, problems) {
63
+ const record = (problem) => {
64
+ if (!problems.includes(problem))
65
+ problems.push(problem);
66
+ };
67
+ page.on('console', (message) => {
68
+ if (message.type() === 'error') {
69
+ record(`console error: ${message.text()}`);
70
+ }
71
+ });
72
+ page.on('pageerror', (error) => {
73
+ record(`uncaught page error: ${error.message}`);
74
+ });
75
+ page.on('requestfailed', (request) => {
76
+ record(`request failed: ${request.method()} ${request.url()} (${request.failure()?.errorText ?? 'unknown'})`);
77
+ });
78
+ page.on('response', (response) => {
79
+ if (response.status() >= 400) {
80
+ record(`request failed: ${response.request().method()} ${response.url()} (HTTP ${response.status()})`);
81
+ }
82
+ });
83
+ }
84
+ export async function openApp(browser, baseUrl, path, viewport, signedInAs, seed, admins = []) {
85
+ const { width, height, deviceScaleFactor } = viewports[viewport];
86
+ const context = await browser.newContext({
87
+ viewport: { width, height },
88
+ deviceScaleFactor,
89
+ reducedMotion: 'reduce',
90
+ colorScheme: 'light'
91
+ });
92
+ if (signedInAs !== null) {
93
+ const signIn = await context.request.post(new URL('/api/auth/dev-sign-in', baseUrl).href, { data: { email: signedInAs } });
94
+ if (!signIn.ok()) {
95
+ throw new Error(`Dev sign-in as ${signedInAs} failed (HTTP ${signIn.status()}): ${await signIn.text()}`);
96
+ }
97
+ }
98
+ if (seed !== undefined) {
99
+ await context.addInitScript((value) => {
100
+ Object.assign(globalThis, {
101
+ patternSeed: value.seed,
102
+ patternAdmins: value.admins
103
+ });
104
+ }, { seed, admins });
105
+ }
106
+ const page = await context.newPage();
107
+ const problems = [];
108
+ recordProblems(page, problems);
109
+ const networkIdle = watchNetwork(page);
110
+ const settle = async () => {
111
+ await networkIdle();
112
+ await page.evaluate(async () => {
113
+ const globals = globalThis;
114
+ await globals.pattern.snapshot();
115
+ await globals.document.fonts.ready;
116
+ await new Promise((done) => globals.requestAnimationFrame(() => globals.requestAnimationFrame(done)));
117
+ });
118
+ };
119
+ const load = async (target) => {
120
+ await page.goto(new URL(target, baseUrl).href);
121
+ await page.waitForFunction(() => 'pattern' in globalThis, undefined, {
122
+ timeout: HOOK_TIMEOUT_MS
123
+ });
124
+ await settle();
125
+ };
126
+ const snapshot = () => page.evaluate(() => globalThis.pattern.snapshot());
127
+ const dispatch = async (command) => {
128
+ const reply = await page.evaluate((sent) => globalThis.pattern.send(sent), command);
129
+ if ('error' in reply)
130
+ throw new Error(reply.error);
131
+ };
132
+ await load(path);
133
+ let devTools;
134
+ return {
135
+ page,
136
+ problems,
137
+ send: async (command) => {
138
+ await dispatch(command);
139
+ return snapshot();
140
+ },
141
+ dispatch,
142
+ snapshot,
143
+ hold: (call) => page.evaluate((held) => globalThis.pattern.hold(held), call),
144
+ release: (call) => page.evaluate((released) => globalThis.pattern.release(released), call),
145
+ reload: load,
146
+ screenshot: async (file) => {
147
+ await settle();
148
+ await page.screenshot({ path: file, animations: 'disabled' });
149
+ },
150
+ captureAt: async (next) => {
151
+ devTools ??= await context.newCDPSession(page);
152
+ await devTools.send('Emulation.setDeviceMetricsOverride', {
153
+ ...viewports[next],
154
+ mobile: false
155
+ });
156
+ await settle();
157
+ const { data } = await devTools.send('Page.captureScreenshot', {
158
+ format: 'png'
159
+ });
160
+ return data;
161
+ },
162
+ close: () => context.close()
163
+ };
164
+ }
@@ -0,0 +1 @@
1
+ export declare function writeContactSheet(wsEndpoint: string, directory: string, file: string): Promise<void>;
@@ -0,0 +1,63 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { connectChrome, viewportNames, viewports } from "./browser.js";
4
+ const COLUMNS = 2;
5
+ const IMAGE_HEIGHT_PX = 400;
6
+ function sheetHtml(directory, names) {
7
+ const sizes = viewportNames
8
+ .map((v) => `${v} ${viewports[v].width}×${viewports[v].height}`)
9
+ .join(' · ');
10
+ const figures = names.map((name) => {
11
+ const shots = viewportNames.flatMap((viewport) => {
12
+ const path = join(directory, `${name}-${viewport}.png`);
13
+ if (!existsSync(path))
14
+ return [];
15
+ const png = readFileSync(path);
16
+ return `<div class="shot"><img src="data:image/png;base64,${png.toString('base64')}"><span>${viewport}</span></div>`;
17
+ });
18
+ return `<figure><figcaption>${name}</figcaption><div class="shots">${shots.join('')}</div></figure>`;
19
+ });
20
+ return `<!doctype html>
21
+ <html><head><style>
22
+ * { box-sizing: border-box; margin: 0; }
23
+ body { background: #e8e8ec; font: 500 22px/1.3 -apple-system, system-ui, sans-serif; color: #202020; }
24
+ main { display: inline-grid; grid-template-columns: repeat(${COLUMNS}, auto); gap: 40px 48px; padding: 40px; }
25
+ header { grid-column: 1 / -1; display: flex; gap: 24px; align-items: baseline; }
26
+ header h1 { font-size: 30px; font-weight: 650; }
27
+ header p { color: #646464; }
28
+ figure { display: flex; flex-direction: column; gap: 12px; }
29
+ figcaption { font-size: 26px; font-weight: 650; }
30
+ .shots { display: flex; gap: 16px; align-items: flex-start; }
31
+ .shot { display: flex; flex-direction: column; gap: 6px; color: #646464; font-size: 18px; }
32
+ img { height: ${IMAGE_HEIGHT_PX}px; border-radius: 6px; box-shadow: 0 0 0 1px rgb(0 0 0 / 0.12), 0 4px 16px rgb(0 0 0 / 0.08); }
33
+ </style></head><body><main>
34
+ <header><h1>Flow screenshots</h1><p>${names.length} captures · ${sizes}</p></header>
35
+ ${figures.join('\n')}
36
+ </main></body></html>`;
37
+ }
38
+ export async function writeContactSheet(wsEndpoint, directory, file) {
39
+ const suffix = new RegExp(`-(${viewportNames.join('|')})\\.png$`);
40
+ const names = [
41
+ ...new Set(readdirSync(directory)
42
+ .filter((entry) => suffix.test(entry))
43
+ .map((entry) => entry.replace(suffix, '')))
44
+ ].sort();
45
+ if (names.length === 0)
46
+ return;
47
+ const browser = await connectChrome(wsEndpoint);
48
+ try {
49
+ const page = await browser.newPage({ deviceScaleFactor: 1 });
50
+ await page.setContent(sheetHtml(directory, names));
51
+ const box = await page.locator('main').boundingBox();
52
+ if (box !== null) {
53
+ await page.setViewportSize({
54
+ width: Math.ceil(box.width),
55
+ height: Math.ceil(box.height)
56
+ });
57
+ }
58
+ await page.screenshot({ path: file, fullPage: true });
59
+ }
60
+ finally {
61
+ await browser.close();
62
+ }
63
+ }