@estiva-app/ui 0.22.0 → 0.23.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/README.md +21 -0
- package/dist/Banner.d.ts +20 -2
- package/dist/Banner.d.ts.map +1 -1
- package/dist/ContainerHeader.d.ts +25 -0
- package/dist/ContainerHeader.d.ts.map +1 -0
- package/dist/EmptyState.d.ts +10 -1
- package/dist/EmptyState.d.ts.map +1 -1
- package/dist/gates/app-checks.d.ts.map +1 -1
- package/dist/gates/{chunk-ZGJ2J5NU.js → chunk-EA33NP5B.js} +275 -11
- package/dist/gates/chunk-EA33NP5B.js.map +7 -0
- package/dist/gates/{chunk-AUXD4GCY.js → chunk-GTQZEHPC.js} +15 -1
- package/dist/gates/chunk-GTQZEHPC.js.map +7 -0
- package/dist/gates/cli.js +1 -1
- package/dist/gates/create-app.d.ts +2 -0
- package/dist/gates/create-app.d.ts.map +1 -1
- package/dist/gates/create-app.js +1 -1
- package/dist/gates/index.js +25 -3
- package/dist/gates/index.js.map +2 -2
- package/dist/gates/status.d.ts +2 -0
- package/dist/gates/status.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +69 -48
- package/dist/index.js.map +4 -4
- package/dist/registry/app-DQI6WLHR.js +10 -0
- package/dist/registry/app.d.ts +21 -0
- package/dist/registry/app.d.ts.map +1 -0
- package/dist/registry/build-LGFCCOLR.js +27 -0
- package/dist/registry/build-LGFCCOLR.js.map +7 -0
- package/dist/registry/build.d.ts +68 -2
- package/dist/registry/build.d.ts.map +1 -1
- package/dist/registry/{chunk-MRSBS5OP.js → chunk-E4JNV7PC.js} +68 -12
- package/dist/registry/chunk-E4JNV7PC.js.map +7 -0
- package/dist/registry/{chunk-QDYGB3QN.js → chunk-NJN4MQAM.js} +60 -15
- package/dist/registry/chunk-NJN4MQAM.js.map +7 -0
- package/dist/registry/{chunk-IJNCYVH4.js → chunk-W2B2G7OE.js} +34 -9
- package/dist/registry/chunk-W2B2G7OE.js.map +7 -0
- package/dist/registry/chunk-WMFF3MPP.js +648 -0
- package/dist/registry/chunk-WMFF3MPP.js.map +7 -0
- package/dist/registry/cli.js +81 -26
- package/dist/registry/cli.js.map +3 -3
- package/dist/registry/find.d.ts +12 -1
- package/dist/registry/find.d.ts.map +1 -1
- package/dist/registry/index.d.ts +5 -2
- package/dist/registry/index.d.ts.map +1 -1
- package/dist/registry/index.js +11 -3
- package/dist/registry/schema.d.ts +83 -6
- package/dist/registry/schema.d.ts.map +1 -1
- package/package.json +2 -2
- package/registry.json +233 -88
- package/src/Banner.mdx +17 -4
- package/src/Banner.stories.tsx +22 -0
- package/src/Banner.test.tsx +35 -0
- package/src/Banner.tsx +34 -9
- package/src/ContainerHeader.mdx +56 -0
- package/src/ContainerHeader.stories.tsx +62 -0
- package/src/ContainerHeader.test.tsx +47 -0
- package/src/ContainerHeader.tsx +45 -0
- package/src/EmptyState.mdx +12 -0
- package/src/EmptyState.stories.tsx +6 -1
- package/src/EmptyState.test.tsx +18 -1
- package/src/EmptyState.tsx +14 -1
- package/src/gates/app-checks.ts +23 -1
- package/src/gates/create-app.test.ts +77 -6
- package/src/gates/create-app.ts +288 -15
- package/src/gates/status.ts +16 -0
- package/src/index.ts +1 -0
- package/src/registry/app.test.ts +562 -0
- package/src/registry/app.ts +854 -0
- package/src/registry/build.ts +114 -25
- package/src/registry/cli.ts +105 -42
- package/src/registry/find.ts +45 -8
- package/src/registry/index.ts +8 -1
- package/src/registry/registry.test.ts +5 -5
- package/src/registry/schema.ts +163 -12
- package/dist/gates/chunk-AUXD4GCY.js.map +0 -7
- package/dist/gates/chunk-ZGJ2J5NU.js.map +0 -7
- package/dist/registry/build-GOVLABI6.js +0 -13
- package/dist/registry/chunk-IJNCYVH4.js.map +0 -7
- package/dist/registry/chunk-MRSBS5OP.js.map +0 -7
- package/dist/registry/chunk-QDYGB3QN.js.map +0 -7
- /package/dist/registry/{build-GOVLABI6.js.map → app-DQI6WLHR.js.map} +0 -0
package/README.md
CHANGED
|
@@ -137,6 +137,27 @@ but this package. The frame is `AppShell` with a `Sidebar`, one theme
|
|
|
137
137
|
(`--theme`, default `light`) set once in `index.html`. Its README says what is
|
|
138
138
|
left to do by hand: register the app with Estiva ID, and protect `main`.
|
|
139
139
|
|
|
140
|
+
### The catalogue — `estiva-ui`
|
|
141
|
+
|
|
142
|
+
"Do we already have this?" has one answer: `estiva-ui find <what it does>`.
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
npx estiva-ui find a floating panel # the package's parts, and this app's own
|
|
146
|
+
npx estiva-ui check # every part says what it is for (CI's job gate)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
- **The package's catalogue** is `registry.json`, generated from the code,
|
|
150
|
+
committed and shipped: one entry per export, with its purpose, every prop it
|
|
151
|
+
declares, what it owns and where its page is.
|
|
152
|
+
- **An app's catalogue** is built from the app's own code each time it is read,
|
|
153
|
+
and never committed. Every part a `.tsx` file exports is listed with its
|
|
154
|
+
purpose (a one-line `/** … */` above it), where the app uses it, and its kind:
|
|
155
|
+
a pass-on of a package part, used in one place, used in several, a candidate to
|
|
156
|
+
move into the package, or used nowhere. `estiva-ui check` fails on a part with
|
|
157
|
+
no description. `--also [name=]<folder>` adds an app that sits beside this one.
|
|
158
|
+
- `@estiva-app/ui/registry` exports the builders and the search for a tool that
|
|
159
|
+
reads them itself.
|
|
160
|
+
|
|
140
161
|
## The rules
|
|
141
162
|
|
|
142
163
|
### Forking is allowed
|
package/dist/Banner.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
1
2
|
/**
|
|
2
3
|
* The strip under the header where the app says something happened —
|
|
3
4
|
* one line, gone when there is nothing to say (Ship's Banner,
|
|
@@ -9,6 +10,11 @@
|
|
|
9
10
|
* An `error` announces itself (`role="alert"`); the other tones are
|
|
10
11
|
* polite (`role="status"`).
|
|
11
12
|
*
|
|
13
|
+
* `icon` and `action` put a 16px icon before the line and one small muted
|
|
14
|
+
* Button after it (Katerina, 2026-09-18, UIG-13: Peek's composer strip became
|
|
15
|
+
* a Banner in the `info` tone). With either, the banner is one line and the
|
|
16
|
+
* text truncates.
|
|
17
|
+
*
|
|
12
18
|
* `onDismiss` adds an `✕` at the right-hand end (Katerina, D21,
|
|
13
19
|
* 2026-09-07). Without it the strip is exactly what it was — a banner an
|
|
14
20
|
* app removes by not rendering it. With it the row is 40px rather than
|
|
@@ -23,12 +29,24 @@
|
|
|
23
29
|
export type BannerTone = 'ok' | 'error' | 'info' | 'warning';
|
|
24
30
|
export interface BannerProps {
|
|
25
31
|
tone: BannerTone;
|
|
26
|
-
|
|
32
|
+
/** The one line. Text, or text with a name in `font-medium`. */
|
|
33
|
+
children: ReactNode;
|
|
34
|
+
/**
|
|
35
|
+
* A 16px icon before the line, in the tone's colour (Katerina, 2026-09-18:
|
|
36
|
+
* Peek's composer strip became a Banner). With an icon or an action the
|
|
37
|
+
* banner is one line, and the text truncates.
|
|
38
|
+
*/
|
|
39
|
+
icon?: ReactNode;
|
|
40
|
+
/** The one thing to do: the package's Button, muted and small, at the end of the line. */
|
|
41
|
+
action?: {
|
|
42
|
+
label: string;
|
|
43
|
+
onClick?: () => void;
|
|
44
|
+
};
|
|
27
45
|
/** Adds an `✕` at the right-hand end. Absent: the app removes the banner. */
|
|
28
46
|
onDismiss?: () => void;
|
|
29
47
|
/** The dismiss button's accessible name. Defaults to "Dismiss". */
|
|
30
48
|
dismissLabel?: string;
|
|
31
49
|
className?: string;
|
|
32
50
|
}
|
|
33
|
-
export declare function Banner({ tone, children, onDismiss, dismissLabel, className }: BannerProps): import("react").JSX.Element;
|
|
51
|
+
export declare function Banner({ tone, children, icon, action, onDismiss, dismissLabel, className }: BannerProps): import("react").JSX.Element;
|
|
34
52
|
//# sourceMappingURL=Banner.d.ts.map
|
package/dist/Banner.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Banner.d.ts","sourceRoot":"","sources":["../src/Banner.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"Banner.d.ts","sourceRoot":"","sources":["../src/Banner.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAMtC;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,MAAM,MAAM,UAAU,GAAG,IAAI,GAAG,OAAO,GAAG,MAAM,GAAG,SAAS,CAAA;AAE5D,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,UAAU,CAAA;IAChB,gEAAgE;IAChE,QAAQ,EAAE,SAAS,CAAA;IACnB;;;;OAIG;IACH,IAAI,CAAC,EAAE,SAAS,CAAA;IAChB,0FAA0F;IAC1F,MAAM,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;KAAE,CAAA;IAChD,6EAA6E;IAC7E,SAAS,CAAC,EAAE,MAAM,IAAI,CAAA;IACtB,mEAAmE;IACnE,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AASD,wBAAgB,MAAM,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,YAAwB,EAAE,SAAS,EAAE,EAAE,WAAW,+BA8BnH"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* The bar across the top of a column — a list, a thread, a side panel: its
|
|
4
|
+
* title, and the buttons that act on the whole column at the right edge.
|
|
5
|
+
* Peek's ContainerHeader, moved into the package exactly as it looks
|
|
6
|
+
* (Katerina, 2026-09-18, UIG-13).
|
|
7
|
+
*
|
|
8
|
+
* 48px tall, a hairline under it. A string title is one line in
|
|
9
|
+
* `body-2-strong` and keeps its width; anything else — an EditableText, a
|
|
10
|
+
* title over a caption — takes the room that is left and is drawn as given.
|
|
11
|
+
*
|
|
12
|
+
* Not a SectionHeader: that is the 32px row one section of a column starts
|
|
13
|
+
* with. This is the column's own bar, one per column.
|
|
14
|
+
*/
|
|
15
|
+
export interface ContainerHeaderProps {
|
|
16
|
+
/** A string keeps the one-line treatment; a node is drawn as given. */
|
|
17
|
+
title: ReactNode;
|
|
18
|
+
/** A chevron after the title, for a title that opens something. */
|
|
19
|
+
chevron?: boolean;
|
|
20
|
+
/** The column's own buttons, at the right edge: IconButtons with tooltips, 4px apart. */
|
|
21
|
+
actions?: ReactNode;
|
|
22
|
+
className?: string;
|
|
23
|
+
}
|
|
24
|
+
export declare function ContainerHeader({ title, chevron, actions, className }: ContainerHeaderProps): import("react").JSX.Element;
|
|
25
|
+
//# sourceMappingURL=ContainerHeader.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ContainerHeader.d.ts","sourceRoot":"","sources":["../src/ContainerHeader.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAItC;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,oBAAoB;IACnC,uEAAuE;IACvE,KAAK,EAAE,SAAS,CAAA;IAChB,mEAAmE;IACnE,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,yFAAyF;IACzF,OAAO,CAAC,EAAE,SAAS,CAAA;IACnB,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,wBAAgB,eAAe,CAAC,EAAE,KAAK,EAAE,OAAe,EAAE,OAAO,EAAE,SAAS,EAAE,EAAE,oBAAoB,+BAiBnG"}
|
package/dist/EmptyState.d.ts
CHANGED
|
@@ -25,7 +25,16 @@ export interface EmptyStateProps {
|
|
|
25
25
|
message: string;
|
|
26
26
|
/** `page` when the whole page is empty; `section` when one part of it is. */
|
|
27
27
|
scope?: 'page' | 'section';
|
|
28
|
+
/**
|
|
29
|
+
* The one thing to do about it — "New topic", "Try again" — drawn 16px under
|
|
30
|
+
* the line as the package's Button, outlined (Katerina, 2026-09-18). A `page`
|
|
31
|
+
* only: a section's empty line is one line among others, not a stage.
|
|
32
|
+
*/
|
|
33
|
+
action?: {
|
|
34
|
+
label: string;
|
|
35
|
+
onClick: () => void;
|
|
36
|
+
};
|
|
28
37
|
className?: string;
|
|
29
38
|
}
|
|
30
|
-
export declare function EmptyState({ icon, message, scope, className }: EmptyStateProps): import("react").JSX.Element;
|
|
39
|
+
export declare function EmptyState({ icon, message, scope, action, className }: EmptyStateProps): import("react").JSX.Element;
|
|
31
40
|
//# sourceMappingURL=EmptyState.d.ts.map
|
package/dist/EmptyState.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"EmptyState.d.ts","sourceRoot":"","sources":["../src/EmptyState.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;
|
|
1
|
+
{"version":3,"file":"EmptyState.d.ts","sourceRoot":"","sources":["../src/EmptyState.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAKtC;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,WAAW,eAAe;IAC9B,8EAA8E;IAC9E,IAAI,CAAC,EAAE,SAAS,CAAA;IAChB,OAAO,EAAE,MAAM,CAAA;IACf,6EAA6E;IAC7E,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IAC1B;;;;OAIG;IACH,MAAM,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,IAAI,CAAA;KAAE,CAAA;IAC/C,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,wBAAgB,UAAU,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,KAAc,EAAE,MAAM,EAAE,SAAS,EAAE,EAAE,eAAe,+BAgB/F"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"app-checks.d.ts","sourceRoot":"","sources":["../../src/gates/app-checks.ts"],"names":[],"mappings":"AACA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,KAAK,EAA0B,WAAW,EAAE,UAAU,EAAE,MAAM,UAAU,CAAA;AAE/E,MAAM,WAAW,eAAe;IAC9B,4EAA4E;IAC5E,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,2FAA2F;IAC3F,IAAI,EAAE,MAAM,CAAA;IACZ,8GAA8G;IAC9G,KAAK,CAAC,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,OAAO,CAAA;KAAE,CAAA;IACvC,+DAA+D;IAC/D,YAAY,CAAC,EAAE,MAAM,EAAE,CAAA;IACvB,wGAAwG;IACxG,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;CACnB;AAED,eAAO,MAAM,iBAAiB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,
|
|
1
|
+
{"version":3,"file":"app-checks.d.ts","sourceRoot":"","sources":["../../src/gates/app-checks.ts"],"names":[],"mappings":"AACA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,KAAK,EAA0B,WAAW,EAAE,UAAU,EAAE,MAAM,UAAU,CAAA;AAE/E,MAAM,WAAW,eAAe;IAC9B,4EAA4E;IAC5E,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,2FAA2F;IAC3F,IAAI,EAAE,MAAM,CAAA;IACZ,8GAA8G;IAC9G,KAAK,CAAC,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,OAAO,CAAA;KAAE,CAAA;IACvC,+DAA+D;IAC/D,YAAY,CAAC,EAAE,MAAM,EAAE,CAAA;IACvB,wGAAwG;IACxG,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;CACnB;AAED,eAAO,MAAM,iBAAiB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAqBpD,CAAA;AAaD,wBAAgB,SAAS,CAAC,CAAC,EAAE,WAAW,EAAE,EAAE,GAAS,EAAE,IAAI,EAAE,KAAsC,EAAE,YAA8D,EAAE,OAA8C,EAAE,EAAE,eAAe,GAAG,UAAU,EAAE,CAgLpP"}
|
|
@@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url";
|
|
|
8
8
|
import { APP_RULE_IDS } from "../eslint/index.js";
|
|
9
9
|
var here = dirname(fileURLToPath(import.meta.url));
|
|
10
10
|
var packageRoot = resolve(here, "..", "..");
|
|
11
|
+
var ASKED_OF_NPM = ["@estiva-app/identity", "@estiva-app/interop", "@estiva-app/platform", "@estiva-app/protocol", "eslint-plugin-react-hooks"];
|
|
11
12
|
function themes() {
|
|
12
13
|
const css = readFileSync(join(packageRoot, "tokens.css"), "utf8");
|
|
13
14
|
return ["light", ...new Set([...css.matchAll(/data-theme='([\w-]+)'/g)].map((m) => m[1]))];
|
|
@@ -38,12 +39,18 @@ function appFiles({ name, title = name, theme = "light", ui, versions = {} }) {
|
|
|
38
39
|
"lint:rules": "eslint --config eslint.gates.config.js .",
|
|
39
40
|
"postlint:rules": `estiva-gates count --repo ${name}`,
|
|
40
41
|
"gates:status": "estiva-gates status",
|
|
42
|
+
"ui:find": "estiva-ui find",
|
|
43
|
+
registry: "estiva-ui build",
|
|
44
|
+
"registry:check": "estiva-ui check",
|
|
41
45
|
test: "vitest run",
|
|
42
46
|
storybook: "storybook dev -p 6006",
|
|
43
47
|
"build-storybook": "storybook build"
|
|
44
48
|
},
|
|
45
49
|
dependencies: {
|
|
46
50
|
"@estiva-app/identity": own("@estiva-app/identity"),
|
|
51
|
+
"@estiva-app/interop": own("@estiva-app/interop"),
|
|
52
|
+
"@estiva-app/platform": own("@estiva-app/platform"),
|
|
53
|
+
"@estiva-app/protocol": own("@estiva-app/protocol"),
|
|
47
54
|
"@estiva-app/ui": ui ?? `^${pkg.version}`,
|
|
48
55
|
...deps(["@tabler/icons-react", "react", "react-dom"])
|
|
49
56
|
},
|
|
@@ -94,7 +101,9 @@ function appFiles({ name, title = name, theme = "light", ui, versions = {} }) {
|
|
|
94
101
|
`;
|
|
95
102
|
return {
|
|
96
103
|
"package.json": json(packageJson),
|
|
97
|
-
|
|
104
|
+
// registry.json: the app's catalogue, written only when `npm run registry` is asked
|
|
105
|
+
// to. It is built fresh from the code every time it is read, so it is never kept.
|
|
106
|
+
".gitignore": ["node_modules", "dist", "storybook-static", "*.local", "*.log", "*.tsbuildinfo", ".DS_Store", "registry.json", ""].join("\n"),
|
|
98
107
|
".env.example": `# Estiva ID, for signing in. Copy this file to .env.local and fill both in.
|
|
99
108
|
#
|
|
100
109
|
# Left empty, the app offers no sign-in at all and runs anonymous: it never
|
|
@@ -106,6 +115,16 @@ function appFiles({ name, title = name, theme = "light", ui, versions = {} }) {
|
|
|
106
115
|
# server; a local one (http://localhost:8787) in its database.
|
|
107
116
|
VITE_ESTIVA_ID_ORIGIN=
|
|
108
117
|
VITE_ESTIVA_ID_CLIENT_ID=${name}
|
|
118
|
+
|
|
119
|
+
# The relay: the workspace this app reads and writes.
|
|
120
|
+
#
|
|
121
|
+
# Left empty, the app runs alone. It opens no connection, and its home page says
|
|
122
|
+
# so. It also needs sign-in above: the relay refuses anything before sign-in, so
|
|
123
|
+
# with a relay and no sign-in there is still no connection. And Estiva ID must
|
|
124
|
+
# allow this app to sign in to that relay (the README says how).
|
|
125
|
+
#
|
|
126
|
+
# A relay running on this machine is http://localhost:3000.
|
|
127
|
+
VITE_RELAY_URL=
|
|
109
128
|
`,
|
|
110
129
|
"index.html": `<!doctype html>
|
|
111
130
|
<html lang="en"${themeAttr}>
|
|
@@ -312,6 +331,10 @@ jobs:
|
|
|
312
331
|
- run: npm ci
|
|
313
332
|
- name: Gate lint
|
|
314
333
|
run: npm run lint:rules
|
|
334
|
+
# Every part of the app says in one line what it is for, so the catalogue
|
|
335
|
+
# (\`npm run ui:find\`) can offer it before someone builds it again.
|
|
336
|
+
- name: Every part is described
|
|
337
|
+
run: npm run registry:check
|
|
315
338
|
`,
|
|
316
339
|
"src/index.css": `@import '@estiva-app/ui/tokens.css';
|
|
317
340
|
@import '@estiva-app/ui/base.css';
|
|
@@ -331,6 +354,7 @@ body,
|
|
|
331
354
|
interface ImportMetaEnv {
|
|
332
355
|
readonly VITE_ESTIVA_ID_ORIGIN?: string
|
|
333
356
|
readonly VITE_ESTIVA_ID_CLIENT_ID?: string
|
|
357
|
+
readonly VITE_RELAY_URL?: string
|
|
334
358
|
}
|
|
335
359
|
`,
|
|
336
360
|
"src/config.ts": `/** What the app is called on screen. */
|
|
@@ -345,6 +369,22 @@ export const ID_CONFIG: { base: string; clientId: string } | null =
|
|
|
345
369
|
import.meta.env.VITE_ESTIVA_ID_ORIGIN && import.meta.env.VITE_ESTIVA_ID_CLIENT_ID
|
|
346
370
|
? { base: import.meta.env.VITE_ESTIVA_ID_ORIGIN.replace(/\\/+$/, ''), clientId: import.meta.env.VITE_ESTIVA_ID_CLIENT_ID }
|
|
347
371
|
: null
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* The relay, the workspace this app reads and writes, or \`null\` when this build
|
|
375
|
+
* has none. Empty is a real mode too: the app runs alone and opens no connection,
|
|
376
|
+
* and a local build can never reach the real relay by accident. See .env.example.
|
|
377
|
+
*/
|
|
378
|
+
export const RELAY_URL: string | null = import.meta.env.VITE_RELAY_URL?.trim().replace(/\\/+$/, '') || null
|
|
379
|
+
|
|
380
|
+
/** The relay as a person would name it: host and port, no scheme. */
|
|
381
|
+
export function relayLabel(url: string): string {
|
|
382
|
+
try {
|
|
383
|
+
return new URL(url).host
|
|
384
|
+
} catch {
|
|
385
|
+
return url
|
|
386
|
+
}
|
|
387
|
+
}
|
|
348
388
|
`,
|
|
349
389
|
"src/auth/estivaId.ts": `import { createEstivaId, type ShellReason, type StoredToken } from '@estiva-app/identity'
|
|
350
390
|
import { ID_CONFIG } from '../config'
|
|
@@ -529,6 +569,131 @@ export function AuthShell({ state, onContinue }: AuthShellProps) {
|
|
|
529
569
|
</div>
|
|
530
570
|
)
|
|
531
571
|
}
|
|
572
|
+
`,
|
|
573
|
+
"src/relay/client.ts": `import { signViaEstivaId } from '@estiva-app/identity'
|
|
574
|
+
import { browserOnlineSource, createLiveClientHolder, type LiveClient } from '@estiva-app/platform'
|
|
575
|
+
import { currentToken } from '../auth/estivaId'
|
|
576
|
+
import { ID_CONFIG, RELAY_URL } from '../config'
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* The event kinds this app reads. They are the app's own decision, and the one
|
|
580
|
+
* blank a new app fills in: Ship reads seven (projects, issues, changes,
|
|
581
|
+
* messages, comments, deletions and edits), Peek its own. Until there are kinds
|
|
582
|
+
* here the app is connected and reads nothing, which is a correct state.
|
|
583
|
+
*/
|
|
584
|
+
export const KINDS: number[] = []
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* The tab's one relay client. \`@estiva-app/platform\` hands out a holder and
|
|
588
|
+
* keeps nothing, so this line is where "one connection per tab" lives. The relay
|
|
589
|
+
* signs a connection in once and caps subscriptions per connection, so a
|
|
590
|
+
* connection per component is wrong, not only wasteful; React's StrictMode,
|
|
591
|
+
* which mounts everything twice, is what would show it.
|
|
592
|
+
*
|
|
593
|
+
* Peek and Ship hold theirs the same way. Everything the client needs is handed
|
|
594
|
+
* in and nothing here reads the app's own data, so it could move into
|
|
595
|
+
* \`@estiva-app/platform\` as it is, if that is ever worth doing.
|
|
596
|
+
*/
|
|
597
|
+
const holder = createLiveClientHolder()
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* The client, or \`null\` when this build cannot have one: no relay is set, or
|
|
601
|
+
* there is no sign-in. The relay refuses anything before sign-in, so a socket
|
|
602
|
+
* without it would only connect, be refused and retry.
|
|
603
|
+
*/
|
|
604
|
+
export function relayClient(): LiveClient | null {
|
|
605
|
+
const base = ID_CONFIG?.base
|
|
606
|
+
if (!RELAY_URL || !base) return null
|
|
607
|
+
return holder.get({
|
|
608
|
+
relayUrl: RELAY_URL,
|
|
609
|
+
// Read on every connect, never kept: a token kept from the first connect
|
|
610
|
+
// outlives a silent renewal as a dead one, and the next reconnect fails.
|
|
611
|
+
getCredential: () => {
|
|
612
|
+
const token = currentToken()
|
|
613
|
+
return token ? { accessToken: token.accessToken, pubkey: token.pubkey } : null
|
|
614
|
+
},
|
|
615
|
+
// \`expectedPubkey\` is a check, not a request. Estiva ID signs as the token's
|
|
616
|
+
// owner whatever it is handed, so a mismatch comes back as a valid event
|
|
617
|
+
// signed by somebody else. Always pass it.
|
|
618
|
+
sign: (unsigned, token, expectedPubkey) => signViaEstivaId(unsigned, { base, token, expectedPubkey }),
|
|
619
|
+
online: typeof window === 'undefined' ? undefined : browserOnlineSource(window),
|
|
620
|
+
// Names this app in the relay's own logs.
|
|
621
|
+
subscriptionPrefix: '${name}-',
|
|
622
|
+
log: (message, detail) => console.debug('[relay]', message, detail ?? ''),
|
|
623
|
+
})
|
|
624
|
+
}
|
|
625
|
+
`,
|
|
626
|
+
"src/relay/useRelayState.ts": `import type { RelayState } from '@estiva-app/protocol'
|
|
627
|
+
import { useSyncExternalStore } from 'react'
|
|
628
|
+
import { relayClient } from './client'
|
|
629
|
+
|
|
630
|
+
const subscribe = (onChange: () => void): (() => void) => relayClient()?.onState(onChange) ?? (() => {})
|
|
631
|
+
const snapshot = (): RelayState | 'off' => relayClient()?.state() ?? 'off'
|
|
632
|
+
|
|
633
|
+
/** The connection's state, for a page to show: \`'off'\` when this build has no client. */
|
|
634
|
+
export function useRelayState(): RelayState | 'off' {
|
|
635
|
+
return useSyncExternalStore(subscribe, snapshot, () => 'off')
|
|
636
|
+
}
|
|
637
|
+
`,
|
|
638
|
+
"src/relay/client.test.ts": `import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* The tab's one relay connection. A fake socket stands in for the relay, so this
|
|
642
|
+
* runs with no network, and counts the sockets opened.
|
|
643
|
+
*/
|
|
644
|
+
class FakeSocket {
|
|
645
|
+
static opened = 0
|
|
646
|
+
onopen: (() => void) | null = null
|
|
647
|
+
onmessage: ((event: { data: unknown }) => void) | null = null
|
|
648
|
+
onclose: (() => void) | null = null
|
|
649
|
+
onerror: (() => void) | null = null
|
|
650
|
+
constructor() {
|
|
651
|
+
FakeSocket.opened += 1
|
|
652
|
+
}
|
|
653
|
+
send() {}
|
|
654
|
+
close() {}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
afterEach(() => {
|
|
658
|
+
vi.unstubAllEnvs()
|
|
659
|
+
vi.unstubAllGlobals()
|
|
660
|
+
vi.resetModules()
|
|
661
|
+
FakeSocket.opened = 0
|
|
662
|
+
})
|
|
663
|
+
|
|
664
|
+
describe('the relay client', () => {
|
|
665
|
+
it('opens no connection when no relay is set', async () => {
|
|
666
|
+
vi.stubEnv('VITE_RELAY_URL', '')
|
|
667
|
+
vi.stubEnv('VITE_ESTIVA_ID_ORIGIN', 'http://localhost:8787')
|
|
668
|
+
vi.stubEnv('VITE_ESTIVA_ID_CLIENT_ID', '${name}')
|
|
669
|
+
vi.stubGlobal('WebSocket', FakeSocket)
|
|
670
|
+
const { relayClient } = await import('./client')
|
|
671
|
+
expect(relayClient()).toBeNull()
|
|
672
|
+
expect(FakeSocket.opened).toBe(0)
|
|
673
|
+
})
|
|
674
|
+
|
|
675
|
+
it('opens no connection with a relay and no sign-in', async () => {
|
|
676
|
+
vi.stubEnv('VITE_RELAY_URL', 'http://localhost:3000')
|
|
677
|
+
vi.stubEnv('VITE_ESTIVA_ID_ORIGIN', '')
|
|
678
|
+
vi.stubGlobal('WebSocket', FakeSocket)
|
|
679
|
+
const { relayClient } = await import('./client')
|
|
680
|
+
expect(relayClient()).toBeNull()
|
|
681
|
+
expect(FakeSocket.opened).toBe(0)
|
|
682
|
+
})
|
|
683
|
+
|
|
684
|
+
it('holds one connection per tab, however often it is asked', async () => {
|
|
685
|
+
vi.stubEnv('VITE_RELAY_URL', 'http://localhost:3000')
|
|
686
|
+
vi.stubEnv('VITE_ESTIVA_ID_ORIGIN', 'http://localhost:8787')
|
|
687
|
+
vi.stubEnv('VITE_ESTIVA_ID_CLIENT_ID', '${name}')
|
|
688
|
+
vi.stubGlobal('WebSocket', FakeSocket)
|
|
689
|
+
const { relayClient } = await import('./client')
|
|
690
|
+
const first = relayClient()
|
|
691
|
+
expect(first).not.toBeNull()
|
|
692
|
+
expect(relayClient()).toBe(first)
|
|
693
|
+
expect(FakeSocket.opened).toBe(1)
|
|
694
|
+
first?.close()
|
|
695
|
+
})
|
|
696
|
+
})
|
|
532
697
|
`,
|
|
533
698
|
"src/main.tsx": `import { StrictMode } from 'react'
|
|
534
699
|
import { createRoot } from 'react-dom/client'
|
|
@@ -562,13 +727,15 @@ void (async () => {
|
|
|
562
727
|
import { IconHome } from '@tabler/icons-react'
|
|
563
728
|
import { useEffect, useState } from 'react'
|
|
564
729
|
import { beginSignOut, currentToken, whoAmI } from './auth/estivaId'
|
|
565
|
-
import { APP_TITLE, ID_CONFIG } from './config'
|
|
730
|
+
import { APP_TITLE, ID_CONFIG, RELAY_URL } from './config'
|
|
566
731
|
import { HomePage } from './pages/HomePage'
|
|
732
|
+
import { useRelayState } from './relay/useRelayState'
|
|
567
733
|
|
|
568
734
|
/** The frame: the package's AppShell with a sidebar, and the one page. */
|
|
569
735
|
export function App() {
|
|
570
736
|
const signedIn = currentToken() !== null
|
|
571
737
|
const [me, setMe] = useState<Identity>({})
|
|
738
|
+
const relayState = useRelayState()
|
|
572
739
|
|
|
573
740
|
useEffect(() => {
|
|
574
741
|
if (!signedIn) return
|
|
@@ -591,19 +758,53 @@ export function App() {
|
|
|
591
758
|
</Sidebar>
|
|
592
759
|
}
|
|
593
760
|
>
|
|
594
|
-
<HomePage />
|
|
761
|
+
<HomePage relay={RELAY_URL} state={relayState} name={me.name} />
|
|
595
762
|
</AppShell>
|
|
596
763
|
)
|
|
597
764
|
}
|
|
598
765
|
`,
|
|
599
|
-
"src/pages/HomePage.tsx": `import {
|
|
766
|
+
"src/pages/HomePage.tsx": `import type { RelayState } from '@estiva-app/protocol'
|
|
767
|
+
import { EmptyState } from '@estiva-app/ui'
|
|
768
|
+
import { relayLabel } from '../config'
|
|
769
|
+
|
|
770
|
+
export interface HomePageProps {
|
|
771
|
+
/** The relay this build is set to, or \`null\` when the app runs alone. */
|
|
772
|
+
relay: string | null
|
|
773
|
+
/** The connection's state: \`'off'\` when this build has no client. */
|
|
774
|
+
state: RelayState | 'off'
|
|
775
|
+
/** The signed-in person's name, once Estiva ID has said it. */
|
|
776
|
+
name?: string
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const SAYS: Record<RelayState, (host: string, name?: string) => string> = {
|
|
780
|
+
connecting: (host) => \`Connecting to \${host}\u2026\`,
|
|
781
|
+
authenticating: (host) => \`Signing in to \${host}\u2026\`,
|
|
782
|
+
live: (host, name) => (name ? \`Connected to \${host} as \${name}.\` : \`Connected to \${host}.\`),
|
|
783
|
+
reconnecting: (host) => \`Reconnecting to \${host}\u2026\`,
|
|
784
|
+
failed: (host) => \`Could not connect to \${host}.\`,
|
|
785
|
+
}
|
|
600
786
|
|
|
601
|
-
/**
|
|
602
|
-
|
|
787
|
+
/**
|
|
788
|
+
* The first page. What it becomes is this app's own work; until then it says
|
|
789
|
+
* whether the app is connected to the relay, and as whom.
|
|
790
|
+
*
|
|
791
|
+
* The empty state goes straight into the frame's \`main\`, with no box around it:
|
|
792
|
+
* \`main\` is a flex column, so the empty state takes the room left and centres
|
|
793
|
+
* in it both ways, as its own page says. A box around it would place it
|
|
794
|
+
* instead, and no gate reads a box. The connection is a quiet caption after it,
|
|
795
|
+
* at the foot of the page.
|
|
796
|
+
*/
|
|
797
|
+
export function HomePage({ relay, state, name }: HomePageProps) {
|
|
798
|
+
const line = !relay
|
|
799
|
+
? 'Running alone: no relay is set. Set VITE_RELAY_URL in .env.local to connect.'
|
|
800
|
+
: state === 'off'
|
|
801
|
+
? \`Not connected: \${relayLabel(relay)} needs sign-in, and this build has none.\`
|
|
802
|
+
: SAYS[state](relayLabel(relay), name)
|
|
603
803
|
return (
|
|
604
|
-
|
|
804
|
+
<>
|
|
605
805
|
<EmptyState message="Nothing here yet." />
|
|
606
|
-
|
|
806
|
+
<p className="px-6 pb-6 text-center text-caption text-text-muted">{line}</p>
|
|
807
|
+
</>
|
|
607
808
|
)
|
|
608
809
|
}
|
|
609
810
|
`,
|
|
@@ -614,12 +815,28 @@ const meta = {
|
|
|
614
815
|
title: 'Pages/Home',
|
|
615
816
|
component: HomePage,
|
|
616
817
|
parameters: { layout: 'fullscreen' },
|
|
818
|
+
// Stands in for the frame's main: a flex column the height of the screen,
|
|
819
|
+
// which is what the page is drawn into in the app.
|
|
820
|
+
decorators: [
|
|
821
|
+
(Story) => (
|
|
822
|
+
<div className="flex h-screen flex-col">
|
|
823
|
+
<Story />
|
|
824
|
+
</div>
|
|
825
|
+
),
|
|
826
|
+
],
|
|
617
827
|
} satisfies Meta<typeof HomePage>
|
|
618
828
|
|
|
619
829
|
export default meta
|
|
620
830
|
type Story = StoryObj<typeof meta>
|
|
621
831
|
|
|
622
|
-
|
|
832
|
+
/** No relay set: the app runs alone, the way it starts. */
|
|
833
|
+
export const RunningAlone: Story = { args: { relay: null, state: 'off' } }
|
|
834
|
+
|
|
835
|
+
/** A relay set, before it has signed the connection in. */
|
|
836
|
+
export const Connecting: Story = { args: { relay: 'http://localhost:3000', state: 'connecting' } }
|
|
837
|
+
|
|
838
|
+
/** Connected, as the person who signed in. */
|
|
839
|
+
export const Connected: Story = { args: { relay: 'http://localhost:3000', state: 'live', name: 'Alex Kim' } }
|
|
623
840
|
`,
|
|
624
841
|
"src/App.test.tsx": `import { render, screen } from '@testing-library/react'
|
|
625
842
|
import { describe, expect, it } from 'vitest'
|
|
@@ -631,6 +848,7 @@ describe('${title}', () => {
|
|
|
631
848
|
render(<App />)
|
|
632
849
|
expect(screen.getAllByText(APP_TITLE).length).toBeGreaterThan(0)
|
|
633
850
|
expect(screen.getByText('Nothing here yet.')).toBeTruthy()
|
|
851
|
+
expect(screen.getByText(/^Running alone/)).toBeTruthy()
|
|
634
852
|
expect(screen.getByRole('link', { name: 'Home' })).toBeTruthy()
|
|
635
853
|
})
|
|
636
854
|
})
|
|
@@ -674,6 +892,40 @@ With no settings it runs **anonymous**: no sign-in is offered, and nothing reach
|
|
|
674
892
|
the real Estiva ID. To sign in, copy \`.env.example\` to \`.env.local\` and fill it in.
|
|
675
893
|
The app must first be registered with that Estiva ID as its own app.
|
|
676
894
|
|
|
895
|
+
## The relay
|
|
896
|
+
|
|
897
|
+
${title} is connected to the relay, the workspace, from its first commit, as
|
|
898
|
+
whoever signed in. \`src/relay/client.ts\` holds the tab's one connection: ask it
|
|
899
|
+
for \`relayClient()\`, and never open a socket of your own. The home page shows the
|
|
900
|
+
connection's state.
|
|
901
|
+
|
|
902
|
+
With \`VITE_RELAY_URL\` empty it runs alone and opens no connection. The relay
|
|
903
|
+
also needs sign-in, so with a relay and no sign-in there is no connection either.
|
|
904
|
+
|
|
905
|
+
Three packages come with it: \`@estiva-app/protocol\` (the wire: events, ids,
|
|
906
|
+
signing, the relay clients), \`@estiva-app/platform\` (the one connection a tab
|
|
907
|
+
holds) and \`@estiva-app/interop\` (showing another app's objects, from the
|
|
908
|
+
manifest that app publishes).
|
|
909
|
+
|
|
910
|
+
## What you fill in
|
|
911
|
+
|
|
912
|
+
1. \`.env.local\`: Estiva ID and the relay (see \`.env.example\`).
|
|
913
|
+
2. \`KINDS\` in \`src/relay/client.ts\`: the event kinds ${title} reads. Until then
|
|
914
|
+
it is connected and reads nothing.
|
|
915
|
+
3. The product: its pages, and the fold that turns what arrives on the
|
|
916
|
+
connection into the app's state.
|
|
917
|
+
|
|
918
|
+
Before ${title} can sign in on the real Estiva ID it has to be registered there
|
|
919
|
+
as its own app, with:
|
|
920
|
+
|
|
921
|
+
- its exact redirect address
|
|
922
|
+
- every event kind it will sign, **including 22242**, the relay's sign-in handshake
|
|
923
|
+
- the relay's address, allowed for that handshake. Estiva ID signs a handshake
|
|
924
|
+
only for a relay both the app and the deployment allow.
|
|
925
|
+
|
|
926
|
+
Anything left out fails at the very last step: every screen looks right, and
|
|
927
|
+
nothing arrives.
|
|
928
|
+
|
|
677
929
|
## The checks
|
|
678
930
|
|
|
679
931
|
| command | what |
|
|
@@ -684,6 +936,8 @@ The app must first be registered with that Estiva ID as its own app.
|
|
|
684
936
|
| \`npm test\` | the tests |
|
|
685
937
|
| \`npm run build\` | the build |
|
|
686
938
|
| \`npm run gates:status\` | which gates are on, read from the code |
|
|
939
|
+
| \`npm run ui:find <words>\` | what the package and this app already have for it |
|
|
940
|
+
| \`npm run registry:check\` | every part says what it is for \u2014 CI's job \`gate\` |
|
|
687
941
|
| \`npm run storybook\` | the stories |
|
|
688
942
|
|
|
689
943
|
The gates are the package's, imported rather than copied, so a rule written later
|
|
@@ -712,6 +966,16 @@ only with its reason on the line above, \`// @estiva-escape: <reason>\`, never w
|
|
|
712
966
|
|
|
713
967
|
**Tokens only.** Colours, type, corners and shadows come from the package's preset.
|
|
714
968
|
|
|
969
|
+
**One relay connection per tab.** \`relayClient()\` in \`src/relay/client.ts\` is the
|
|
970
|
+
connection. Never open a socket and never make a second holder: the relay signs a
|
|
971
|
+
connection in once, and caps subscriptions per connection.
|
|
972
|
+
|
|
973
|
+
**Look before you build.** \`npm run ui:find <what it does>\` searches the package's
|
|
974
|
+
parts and this app's own. Use what it finds.
|
|
975
|
+
|
|
976
|
+
**Every part says what it is for.** A new part gets a one-line \`/** \u2026 */\` comment
|
|
977
|
+
directly above it. \`npm run registry:check\` and CI's job \`gate\` refuse a part without one.
|
|
978
|
+
|
|
715
979
|
**The count starts at zero and stays there** (\`.gates-count.json\`, \`docs/GATES-DEBT.md\`).
|
|
716
980
|
|
|
717
981
|
What each gate is and how it is wired: the package's README,
|
|
@@ -734,7 +998,7 @@ function createApp(options) {
|
|
|
734
998
|
const dir = resolve(options.parent ?? process.cwd(), options.name);
|
|
735
999
|
if (existsSync(dir)) throw new Error(`${dir} already exists: create-estiva-app never writes into a folder that is there`);
|
|
736
1000
|
const pkg = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
|
|
737
|
-
const missing =
|
|
1001
|
+
const missing = ASKED_OF_NPM.filter((n) => !pkg.devDependencies[n] && !options.versions?.[n]);
|
|
738
1002
|
const versions = { ...missing.length ? askNpm(missing) : {}, ...options.versions };
|
|
739
1003
|
const files = appFiles({ ...options, versions });
|
|
740
1004
|
for (const [rel, text] of Object.entries(files)) {
|
|
@@ -751,4 +1015,4 @@ export {
|
|
|
751
1015
|
askNpm,
|
|
752
1016
|
createApp
|
|
753
1017
|
};
|
|
754
|
-
//# sourceMappingURL=chunk-
|
|
1018
|
+
//# sourceMappingURL=chunk-EA33NP5B.js.map
|