@mohou/app-view 1.0.20

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 ADDED
@@ -0,0 +1,8 @@
1
+ ---
2
+ status: locked
3
+ updated: 2026-10-01
4
+ ---
5
+
6
+ Role: `definition`.
7
+
8
+ Shared views of an app. The panel and `@mohou/ui` both import them. Product behavior: [features.md](../../../docs/product/features.md).
@@ -0,0 +1,3 @@
1
+ /** Card rules for the panel sheet and every app sheet. Not scoped to the panel host. */
2
+ export declare const appCardCss: string;
3
+ //# sourceMappingURL=app-card-css.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"app-card-css.d.ts","sourceRoot":"","sources":["../../src/app-card-css.ts"],"names":[],"mappings":"AAGA,wFAAwF;AACxF,eAAO,MAAM,UAAU,QA8EX,CAAA"}
@@ -0,0 +1,22 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { AppListItem } from '@mohou/contract';
3
+ /** Card styles. The same six the panel library offers. */
4
+ export declare const appCardStyles: readonly ["glass", "stamp", "etch", "hero", "pulse", "list"];
5
+ export type AppCardStyle = (typeof appCardStyles)[number];
6
+ /** Fields only some styles read. Glass uses `featured` to span two columns. */
7
+ export interface AppCardExtra {
8
+ readonly featured?: boolean;
9
+ }
10
+ /**
11
+ * One app card. `onOpen` is the click. The card does not open the app itself.
12
+ * @param props - style, the list item, and the open mark
13
+ */
14
+ export declare function AppCard(props: {
15
+ readonly type: AppCardStyle;
16
+ readonly app: AppListItem;
17
+ readonly open?: boolean;
18
+ readonly openLabel?: string;
19
+ readonly extra?: AppCardExtra;
20
+ readonly onOpen: () => void;
21
+ }): ReactNode;
22
+ //# sourceMappingURL=app-card.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"app-card.d.ts","sourceRoot":"","sources":["../../src/app-card.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAiB,SAAS,EAAE,MAAM,OAAO,CAAA;AAErD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAA;AAElD,0DAA0D;AAC1D,eAAO,MAAM,aAAa,8DAA+D,CAAA;AAEzF,MAAM,MAAM,YAAY,GAAG,CAAC,OAAO,aAAa,CAAC,CAAC,MAAM,CAAC,CAAA;AAEzD,+EAA+E;AAC/E,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAA;CAC5B;AAUD;;;GAGG;AACH,wBAAgB,OAAO,CAAC,KAAK,EAAE;IAC7B,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAA;IAC3B,QAAQ,CAAC,GAAG,EAAE,WAAW,CAAA;IACzB,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAA;IACvB,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;IAC3B,QAAQ,CAAC,KAAK,CAAC,EAAE,YAAY,CAAA;IAC7B,QAAQ,CAAC,MAAM,EAAE,MAAM,IAAI,CAAA;CAC5B,GAAG,SAAS,CAqBZ"}
@@ -0,0 +1,5 @@
1
+ export { workbenchEntries } from '@mohou/contract';
2
+ export { appCardCss } from './app-card-css.ts';
3
+ export { AppCard, appCardStyles, type AppCardExtra, type AppCardStyle } from './app-card.tsx';
4
+ export { WorkbenchLibrary } from './library.tsx';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAA;AAClD,OAAO,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAA;AAC9C,OAAO,EAAE,OAAO,EAAE,aAAa,EAAE,KAAK,YAAY,EAAE,KAAK,YAAY,EAAE,MAAM,gBAAgB,CAAA;AAC7F,OAAO,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAA"}
@@ -0,0 +1,15 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { AppListItem } from '@mohou/contract';
3
+ import { type AppCardStyle } from './app-card.tsx';
4
+ /**
5
+ * The builtin library body. A workbench app does not render this.
6
+ * `openApp` asks the panel to add a tab. The component does not write policy.
7
+ */
8
+ export declare function WorkbenchLibrary(props: {
9
+ readonly apps: readonly AppListItem[];
10
+ readonly cardStyle: AppCardStyle;
11
+ readonly openLabel: string;
12
+ readonly openAppIds?: readonly string[];
13
+ openApp(appId: string, title?: string): void;
14
+ }): ReactNode;
15
+ //# sourceMappingURL=library.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"library.d.ts","sourceRoot":"","sources":["../../src/library.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AACtC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAA;AAElD,OAAO,EAAW,KAAK,YAAY,EAAE,MAAM,gBAAgB,CAAA;AAE3D;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE;IACtC,QAAQ,CAAC,IAAI,EAAE,SAAS,WAAW,EAAE,CAAA;IACrC,QAAQ,CAAC,SAAS,EAAE,YAAY,CAAA;IAChC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,UAAU,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IACvC,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAC7C,GAAG,SAAS,CAqBZ"}
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@mohou/app-view",
3
+ "version": "1.0.20",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": "^22.19.0 || >=24.0.0"
8
+ },
9
+ "publishConfig": {
10
+ "access": "public"
11
+ },
12
+ "files": [
13
+ "src",
14
+ "lib/types",
15
+ "README.md",
16
+ "!**/*.tsbuildinfo"
17
+ ],
18
+ "main": "./src/index.ts",
19
+ "types": "./lib/types/index.d.ts",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./lib/types/index.d.ts",
23
+ "default": "./src/index.ts"
24
+ },
25
+ "./css": {
26
+ "types": "./src/app-card-css.ts",
27
+ "default": "./src/app-card-css.ts"
28
+ },
29
+ "./package.json": "./package.json"
30
+ },
31
+ "dependencies": {
32
+ "react": "^19.2.0",
33
+ "@mohou/contract": "^1.0.20",
34
+ "@mohou/values": "^1.0.20"
35
+ },
36
+ "devDependencies": {
37
+ "@types/react": "^19.2.0",
38
+ "@types/react-dom": "^19.2.0",
39
+ "react-dom": "^19.2.0"
40
+ }
41
+ }
@@ -0,0 +1,83 @@
1
+ /** Mac Avenir Next + PingFang SC. Windows Bahnschrift + DengXian, then YaHei. */
2
+ const glassSans = '"Avenir Next",Bahnschrift,"Segoe UI","PingFang SC",DengXian,"Microsoft YaHei UI",sans-serif'
3
+
4
+ /** Card rules for the panel sheet and every app sheet. Not scoped to the panel host. */
5
+ export const appCardCss = [
6
+ '.mma-slot{display:grid;min-width:0;}',
7
+ `.mma-glass{display:grid;width:100%;height:100%;min-height:100%;box-sizing:border-box;border-radius:24px;border:1px solid color-mix(in oklch,var(--foreground) 8%,transparent);background:color-mix(in oklch,var(--card) 62%,transparent);padding:18px;text-align:left;font-family:${glassSans};backdrop-filter:blur(16px);transition:transform .4s cubic-bezier(.23,1,.32,1),background-color .4s,box-shadow .4s;}`,
8
+ '.mma-glass-body{display:flex;width:100%;height:100%;min-height:100%;flex-direction:column;justify-content:flex-start;gap:12px;}',
9
+ '.mma-feature .mma-glass{padding:24px;}',
10
+ '.mma-feature .mma-glass-body{justify-content:space-between;gap:15px;}',
11
+ '.mma-glass-name{margin:0;font-size:18px;font-weight:700;letter-spacing:-.02em;line-height:1.25;}',
12
+ '.mma-feature .mma-glass-name{font-size:24px;}',
13
+ '.mma-glass-copy{margin:6px 0 0;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;font-size:12px;font-weight:400;line-height:1.45;color:var(--muted-foreground);}',
14
+ '.mma-feature .mma-glass-copy{margin-top:8px;font-size:14px;line-height:1.625;}',
15
+ 'button[data-card="stamp"] .mma-copy,button[data-card="etch"] .mma-copy,button[data-card="pulse"] .mma-copy,button[data-card="hero"] .mma-copy{min-height:2.75em;}',
16
+ '.mma-glass:hover{transform:translateY(-4px);background:color-mix(in oklch,var(--card) 88%,transparent);box-shadow:0 20px 40px -16px color-mix(in oklch,var(--foreground) 16%,transparent);}',
17
+ '.mma-feature{grid-column:span 2;}',
18
+ '@container (max-width:680px){.mma-feature{grid-column:span 1;}}',
19
+ '.mma-mark{font-size:30px;font-weight:800;letter-spacing:-1px;line-height:1;background:linear-gradient(to bottom right,hsl(var(--h) 48% 46%),hsl(var(--h) 40% 38% / .35));-webkit-background-clip:text;background-clip:text;color:transparent;}',
20
+ '.mma-feature .mma-mark{font-size:48px;letter-spacing:-2px;}',
21
+ '.mma-tag{border-radius:6px;background:color-mix(in oklch,var(--foreground) 6%,transparent);padding:2px 8px;font-size:10px;font-weight:700;letter-spacing:.04em;color:var(--muted-foreground);}',
22
+ '[data-mode="dark"] .mma-glass{background:color-mix(in oklch,var(--foreground) 4%,transparent);border-color:color-mix(in oklch,var(--foreground) 12%,transparent);}',
23
+ '[data-mode="dark"] .mma-mark{background:linear-gradient(to bottom right,hsl(var(--h) 42% 74%),hsl(var(--h) 30% 52% / .28));-webkit-background-clip:text;background-clip:text;}',
24
+ `#mma-host[data-card="glass"] .mma-library{font-family:${glassSans};}`,
25
+ 'button[data-app-id]{transition:border-color .16s ease,box-shadow .16s ease,transform .16s ease,background-color .22s ease;}',
26
+ '@keyframes mma-hero-breath{0%,100%{transform:translate(-50%,-50%) scale(1);filter:blur(10px) brightness(1)}50%{transform:translate(-50%,-50%) scale(.86);filter:blur(12px) brightness(.75)}}',
27
+ 'button[data-app-id][data-card="hero"] .hero-chip{position:relative;z-index:1;display:inline-flex;align-items:center;justify-content:center;min-width:2.15rem;height:1.45rem;padding:0 .42rem;border-radius:.35rem;background:hsl(var(--h) 62% 46%);color:#fff;font-size:11px;font-weight:700;letter-spacing:.06em;line-height:1;}',
28
+ '[data-mode="dark"] button[data-app-id][data-card="hero"] .hero-chip{background:#fff;color:hsl(var(--h) 58% 36%);}',
29
+ 'button[data-app-id][data-card="hero"] .hero-mist{position:absolute;border-radius:50%;pointer-events:none;opacity:0;filter:blur(10px);transform:translate(-50%,-50%) scale(.5);transition:opacity 1.2s ease,transform 1.2s ease;}',
30
+ 'button[data-app-id][data-card="hero"] .hero-mist-a{left:74%;top:30%;width:84px;height:84px;background:radial-gradient(circle,hsl(var(--h) 72% 56% / .85),transparent 70%);}',
31
+ 'button[data-app-id][data-card="hero"] .hero-mist-b{left:30%;top:74%;width:56px;height:56px;background:radial-gradient(circle,hsl(var(--h) 64% 54% / .7),transparent 70%);}',
32
+ 'button[data-app-id][data-card="hero"] .hero-mist-c{left:26%;top:28%;width:48px;height:48px;background:radial-gradient(circle,hsl(var(--h) 56% 58% / .55),transparent 72%);}',
33
+ 'button[data-app-id][data-card="hero"]:hover{background:var(--card);border-color:hsl(var(--h) 58% 46% / .72);box-shadow:0 0 8px 0 hsl(var(--h) 62% 48% / .55);transform:translateY(-1px);}',
34
+ 'button[data-app-id][data-card="hero"]:hover .hero-mist{opacity:.7;transform:translate(-50%,-50%) scale(1);}',
35
+ '[data-mode="dark"] button[data-app-id][data-card="hero"] .hero-mist-a{background:radial-gradient(circle,hsl(var(--h) 58% 72% / .8),transparent 70%);}',
36
+ '[data-mode="dark"] button[data-app-id][data-card="hero"] .hero-mist-b{background:radial-gradient(circle,hsl(var(--h) 48% 70% / .65),transparent 70%);}',
37
+ '[data-mode="dark"] button[data-app-id][data-card="hero"] .hero-mist-c{background:radial-gradient(circle,hsl(var(--h) 42% 74% / .5),transparent 72%);}',
38
+ '[data-mode="dark"] button[data-app-id][data-card="hero"]:hover{border-color:hsl(var(--h) 40% 62% / .45);box-shadow:0 0 8px 0 hsl(var(--h) 50% 68% / .28);}',
39
+ '[data-mode="dark"] button[data-app-id][data-card="hero"]:hover .hero-mist{opacity:.62;}',
40
+ 'button[data-app-id][data-card="hero"]:hover .hero-mist-a{animation:mma-hero-breath 4.2s ease-in-out 1.2s infinite;}',
41
+ 'button[data-app-id][data-card="hero"]:hover .hero-mist-b{opacity:.55;animation:mma-hero-breath 5.4s ease-in-out 1.2s infinite;}',
42
+ 'button[data-app-id][data-card="hero"]:hover .hero-mist-c{opacity:.45;animation:mma-hero-breath 6.6s ease-in-out 1.2s infinite;}',
43
+ 'button[data-app-id][data-card="etch"]{border-width:2px;border-style:solid;border-top-color:var(--card);border-left-color:var(--card);border-right-color:hsl(var(--h) 65% 45%);border-bottom-color:hsl(var(--h) 65% 45%);transition:border-color .9s ease,box-shadow .9s ease,background-color .22s ease;}',
44
+ 'button[data-app-id][data-card="etch"] .etch-line{transition:opacity .55s ease;}',
45
+ 'button[data-app-id][data-card="etch"] .etch-fill{opacity:0;transition:opacity .55s ease;}',
46
+ 'button[data-app-id][data-card="etch"]:hover .etch-line{opacity:0;}',
47
+ 'button[data-app-id][data-card="etch"]:hover{border-color:hsl(var(--h) 65% 45%);background:var(--card);transform:none;box-shadow:hsl(var(--h) 70% 50% / .40) 5px 5px,hsl(var(--h) 70% 50% / .28) 10px 10px,hsl(var(--h) 70% 50% / .16) 15px 15px;}',
48
+ 'button[data-app-id][data-card="etch"]:hover .etch-fill{opacity:1;}',
49
+ 'button[data-app-id][data-card="stamp"]:hover{background:linear-gradient(hsl(var(--h) 30% 55% / .07),hsl(var(--h) 30% 55% / .07)),var(--card);border-color:var(--foreground);box-shadow:0 8px 22px var(--shadow);transform:translateY(-1px);}',
50
+ 'button[data-app-id][data-card="stamp"]:hover [data-monogram="stamp"]{background:var(--foreground);color:var(--card);transform:scale(1.05);}',
51
+ '@keyframes mma-list-spin{to{transform:rotate(360deg)}}',
52
+ 'button[data-app-id][data-card="list"] .list-backlight{position:absolute;top:0;right:0;z-index:0;width:46%;height:100%;background:linear-gradient(to left,hsl(var(--h) 58% 58% / .52),hsl(var(--h) 46% 56% / .2) 55%,transparent);opacity:0;pointer-events:none;transition:opacity .6s ease;}',
53
+ 'button[data-app-id][data-card="list"] .list-wash{position:absolute;inset:4px;z-index:1;border-radius:12px 64px 22px 12px;background:hsl(var(--h) 16% 46% / .06);backdrop-filter:blur(10px);pointer-events:none;transition:background-color .28s ease;}',
54
+ 'button[data-app-id][data-card="list"] .list-orb{position:absolute;top:50%;right:28px;width:76px;height:76px;margin-top:-38px;border-radius:50%;background:conic-gradient(from 90deg,transparent 0%,hsl(var(--h) 42% 58% / 0) 20%,hsl(var(--h) 48% 50% / .55) 50%,transparent 76%);filter:blur(8px);animation:mma-list-spin 16s linear infinite;pointer-events:none;}',
55
+ '@keyframes mma-list-nudge{0%{transform:translateX(0)}45%{transform:translateX(4px)}100%{transform:translateX(0)}}',
56
+ '@keyframes mma-list-mark{0%{letter-spacing:.04em;opacity:.8}40%{letter-spacing:.18em;opacity:1}100%{letter-spacing:.08em;opacity:1}}',
57
+ 'button[data-app-id][data-card="list"] .list-mark{display:block;font-size:13px;font-weight:700;line-height:1;letter-spacing:.04em;white-space:nowrap;color:var(--foreground);opacity:.8;}',
58
+ 'button[data-app-id][data-card="list"] .list-go svg{display:block;}',
59
+ 'button[data-app-id][data-card="list"]::after{content:"";position:absolute;inset:0;z-index:2;border-radius:inherit;padding:1px;background:linear-gradient(155deg,hsl(var(--h) 58% 46% / .9),hsl(var(--h) 42% 52% / .5) 40%,transparent 72%);-webkit-mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0);-webkit-mask-composite:xor;mask-composite:exclude;opacity:0;pointer-events:none;transition:opacity .28s ease;}',
60
+ 'button[data-app-id][data-card="list"]:hover{border-color:transparent;background:var(--card);}',
61
+ 'button[data-app-id][data-card="list"]:hover::after{opacity:1;}',
62
+ 'button[data-app-id][data-card="list"]:hover .list-backlight{opacity:1;}',
63
+ 'button[data-app-id][data-card="list"]:hover .list-wash{background:hsl(var(--h) 28% 48% / .12);}',
64
+ '[data-mode="dark"] button[data-app-id][data-card="list"] .list-backlight{background:linear-gradient(to left,hsl(var(--h) 28% 64% / .32),transparent 70%);}',
65
+ '[data-mode="dark"] button[data-app-id][data-card="list"] .list-orb{background:conic-gradient(from 90deg,transparent 0%,hsl(var(--h) 22% 62% / 0) 20%,hsl(var(--h) 18% 55% / .42) 50%,transparent 76%);}',
66
+ '[data-mode="dark"] button[data-app-id][data-card="list"]::after{background:linear-gradient(155deg,hsl(0 0% 100% / .9),hsl(var(--h) 35% 62% / .4) 36%,transparent 70%);}',
67
+ '[data-mode="dark"] button[data-app-id][data-card="list"]:hover .list-wash{background:hsl(var(--h) 16% 46% / .1);}',
68
+ 'button[data-app-id][data-card="list"]:hover .list-mark{color:hsl(var(--h) 42% 36%);animation:mma-list-mark .55s ease forwards;}',
69
+ '[data-mode="dark"] button[data-app-id][data-card="list"]:hover .list-mark{color:hsl(var(--h) 48% 74%);}',
70
+ 'button[data-app-id][data-card="list"]:hover .list-go{background:hsl(var(--h) 42% 48% / .2);}',
71
+ 'button[data-app-id][data-card="list"]:hover .list-go svg{animation:mma-list-nudge .45s ease;}',
72
+ '@keyframes mma-pulse-dot{0%,100%{background:hsl(var(--h) 60% 52% / 0)}50%{background:hsl(var(--h) 75% 48%)}}',
73
+ '@keyframes mma-pulse-glow{0%,100%{opacity:0}50%{opacity:1}}',
74
+ 'button[data-app-id][data-card="pulse"]{border:none;background:linear-gradient(hsl(0 0% 100% / .35),hsl(0 0% 0% / .03)),var(--card);box-shadow:1px 1px 2px -1px #fff inset,0 2px 1px #00000010,0 8px 8px #00000012;transition:transform .25s cubic-bezier(.25,1.5,.5,1),filter .3s ease;}',
75
+ 'button[data-app-id][data-card="pulse"] .pulse-mark{color:hsl(var(--h) 48% 38%);}',
76
+ '[data-mode="dark"] button[data-app-id][data-card="pulse"]{background:linear-gradient(hsl(var(--h) 22% 58% / .1),transparent 42%),var(--card);box-shadow:inset 0 1px 0 hsl(0 0% 100% / .07),0 10px 18px hsl(0 0% 0% / .4);}',
77
+ '[data-mode="dark"] button[data-app-id][data-card="pulse"] .pulse-mark{color:hsl(var(--h) 40% 74%);}',
78
+ 'button[data-app-id][data-card="pulse"] .pulse-dot{position:absolute;top:16px;right:14px;width:8px;height:8px;border-radius:50%;border:1px solid hsl(var(--h) 40% 55% / .55);animation:mma-pulse-dot 1.2s ease-in-out infinite;}',
79
+ 'button[data-app-id][data-card="pulse"] .pulse-dot::after{content:"";position:absolute;inset:-7px;border-radius:inherit;box-shadow:0 0 10px 2px hsl(var(--h) 80% 55% / .35),0 0 18px 6px hsl(var(--h) 80% 60% / .2);animation:mma-pulse-glow 1.2s ease-in-out infinite;}',
80
+ 'button[data-app-id][data-card="pulse"]:hover{transform:translateY(-2px);filter:drop-shadow(6px 0 6px hsl(var(--h) 70% 55% / .45));}',
81
+ 'button[data-app-id][data-card="pulse"]:hover .pulse-dot{animation:none;background:hsl(var(--h) 75% 48%);}',
82
+ 'button[data-app-id][data-card="pulse"]:hover .pulse-dot::after{animation:none;opacity:1;}',
83
+ ].join('\n')
@@ -0,0 +1,281 @@
1
+ import type { CSSProperties, ReactNode } from 'react'
2
+ import { assertNever } from '@mohou/values'
3
+ import type { AppListItem } from '@mohou/contract'
4
+
5
+ /** Card styles. The same six the panel library offers. */
6
+ export const appCardStyles = ['glass', 'stamp', 'etch', 'hero', 'pulse', 'list'] as const
7
+
8
+ export type AppCardStyle = (typeof appCardStyles)[number]
9
+
10
+ /** Fields only some styles read. Glass uses `featured` to span two columns. */
11
+ export interface AppCardExtra {
12
+ readonly featured?: boolean
13
+ }
14
+
15
+ interface DrawnCard {
16
+ readonly app: AppListItem
17
+ readonly hue: CSSProperties
18
+ readonly open: boolean
19
+ readonly openLabel?: string
20
+ readonly onOpen: () => void
21
+ }
22
+
23
+ /**
24
+ * One app card. `onOpen` is the click. The card does not open the app itself.
25
+ * @param props - style, the list item, and the open mark
26
+ */
27
+ export function AppCard(props: {
28
+ readonly type: AppCardStyle
29
+ readonly app: AppListItem
30
+ readonly open?: boolean
31
+ readonly openLabel?: string
32
+ readonly extra?: AppCardExtra
33
+ readonly onOpen: () => void
34
+ }): ReactNode {
35
+ const hue = { '--h': appCardHue(props.app.id) } as CSSProperties
36
+ const open = props.open === true
37
+ const label = props.openLabel === undefined ? {} : { openLabel: props.openLabel }
38
+ const drawn = { app: props.app, hue, open, ...label, onOpen: () => { props.onOpen() } }
39
+ switch (props.type) {
40
+ case 'pulse':
41
+ return <PulseCard app={props.app} hue={hue} onOpen={drawn.onOpen} />
42
+ case 'list':
43
+ return <ListCard {...drawn} />
44
+ case 'glass':
45
+ return <GlassCard {...drawn} featured={props.extra?.featured === true} />
46
+ case 'hero':
47
+ return <HeroCard {...drawn} />
48
+ case 'etch':
49
+ return <EtchCard {...drawn} />
50
+ case 'stamp':
51
+ return <StampCard {...drawn} />
52
+ default:
53
+ return assertNever(props.type, 'AppCard')
54
+ }
55
+ }
56
+
57
+ function appCardHue(id: string): number {
58
+ let hue = 0
59
+ for (let index = 0; index < id.length; index += 1) hue = (hue * 31 + id.charCodeAt(index)) % 360
60
+ return hue
61
+ }
62
+
63
+ function OpenMark(props: { readonly open: boolean; readonly openLabel: string | undefined }): ReactNode {
64
+ if (!props.open || props.openLabel === undefined) return null
65
+ return (
66
+ <span className="inline-flex items-center gap-1 text-[11px] font-semibold text-primary">
67
+ <i />
68
+ {props.openLabel}
69
+ </span>
70
+ )
71
+ }
72
+
73
+ function PulseCard(props: {
74
+ readonly app: AppListItem
75
+ readonly hue: CSSProperties
76
+ readonly onOpen: () => void
77
+ }): ReactNode {
78
+ return (
79
+ <button
80
+ type="button"
81
+ className="relative flex w-full flex-col items-start rounded-[22px] px-[15px] pt-4 pb-[13px] pr-8 text-left"
82
+ data-app-id={props.app.id}
83
+ data-card="pulse"
84
+ style={props.hue}
85
+ onClick={props.onOpen}
86
+ >
87
+ <span className="pulse-dot" aria-hidden />
88
+ <span className="pulse-mark relative z-[1] mb-2 text-[28px] leading-none font-extrabold tracking-wide">
89
+ {props.app.acronym}
90
+ </span>
91
+ <h3 className="relative z-[1] m-0 text-sm font-semibold tracking-tight">{props.app.name}</h3>
92
+ <p className="mma-copy relative z-[1] m-0 mt-1 line-clamp-2 text-xs leading-snug text-muted-foreground">
93
+ {props.app.description}
94
+ </p>
95
+ <span className="relative z-[1] mt-2 text-[11px] text-muted-foreground">{props.app.version}</span>
96
+ </button>
97
+ )
98
+ }
99
+
100
+ function ListCard(props: DrawnCard): ReactNode {
101
+ return (
102
+ <button
103
+ type="button"
104
+ className="relative flex h-24 w-full items-stretch gap-2 overflow-hidden rounded-2xl border border-foreground/10 bg-card p-2 text-left"
105
+ data-app-id={props.app.id}
106
+ data-card="list"
107
+ style={props.hue}
108
+ onClick={props.onOpen}
109
+ >
110
+ <span aria-hidden className="list-backlight" />
111
+ <span aria-hidden className="list-wash" />
112
+ <span aria-hidden className="list-orb" />
113
+ <span className="relative z-[1] flex min-w-0 flex-1 flex-col justify-between rounded-xl bg-card/50 px-3 py-2 backdrop-blur-md">
114
+ <span className="truncate text-sm font-semibold">{props.app.name}</span>
115
+ <span className="truncate text-xs text-muted-foreground">{props.app.description}</span>
116
+ <span className="text-[11px] text-muted-foreground">
117
+ {props.open ? <OpenMark open={props.open} openLabel={props.openLabel} /> : props.app.version}
118
+ </span>
119
+ </span>
120
+ <span className="list-rail relative z-[2] flex w-11 shrink-0 flex-col items-end justify-between py-0.5">
121
+ <span data-monogram="list" className="list-mark">{props.app.acronym}</span>
122
+ <span className="list-go flex size-7 items-center justify-center overflow-hidden rounded-full bg-foreground/10 text-foreground/70 backdrop-blur-md">
123
+ <svg className="size-3.5" viewBox="0 0 12 12" aria-hidden>
124
+ <path d="M4.646 2.146a.5.5 0 0 0 0 .708L7.793 6 4.646 9.146a.5.5 0 1 0 .708.708l3.5-3.5a.5.5 0 0 0 0-.708l-3.5-3.5a.5.5 0 0 0-.708 0z" fill="currentColor" />
125
+ </svg>
126
+ </span>
127
+ </span>
128
+ </button>
129
+ )
130
+ }
131
+
132
+ function GlassCard(props: DrawnCard & { readonly featured: boolean }): ReactNode {
133
+ const slot = props.featured ? 'mma-slot mma-feature' : 'mma-slot'
134
+ const tag = props.open && props.openLabel !== undefined ? props.openLabel : props.app.version
135
+ return (
136
+ <div className={slot}>
137
+ <button
138
+ type="button"
139
+ className="mma-glass"
140
+ data-app-id={props.app.id}
141
+ data-card="glass"
142
+ style={props.hue}
143
+ onClick={props.onOpen}
144
+ >
145
+ <span className="mma-glass-body">
146
+ <span className="flex w-full items-start justify-between gap-3">
147
+ <span data-monogram="glass" className="mma-mark">{props.app.acronym}</span>
148
+ <span className="mma-tag">{tag}</span>
149
+ </span>
150
+ <span className="block">
151
+ <h3 className="mma-glass-name">{props.app.name}</h3>
152
+ <p className="mma-glass-copy">{props.app.description}</p>
153
+ </span>
154
+ </span>
155
+ </button>
156
+ </div>
157
+ )
158
+ }
159
+
160
+ function HeroCard(props: DrawnCard): ReactNode {
161
+ return (
162
+ <button
163
+ type="button"
164
+ className="relative z-0 flex w-full flex-col items-start overflow-hidden rounded-[13px] border bg-card px-5 pt-4 pb-5 text-left"
165
+ data-app-id={props.app.id}
166
+ data-card="hero"
167
+ style={props.hue}
168
+ onClick={props.onOpen}
169
+ >
170
+ <span aria-hidden className="hero-mist hero-mist-a" />
171
+ <span aria-hidden className="hero-mist hero-mist-b" />
172
+ <span aria-hidden className="hero-mist hero-mist-c" />
173
+ <span className="relative z-[1] flex w-full items-center justify-between gap-3">
174
+ <span data-monogram="hero" className="hero-chip">{props.app.acronym}</span>
175
+ <span className="flex shrink-0 items-center gap-2 text-[13px] text-muted-foreground">
176
+ <span>{props.app.version}</span>
177
+ <OpenMark open={props.open} openLabel={props.openLabel} />
178
+ </span>
179
+ </span>
180
+ <h3 className="relative z-[1] m-0 mt-4 text-lg leading-snug font-semibold tracking-tight">{props.app.name}</h3>
181
+ <p className="mma-copy relative z-[1] m-0 mt-2 line-clamp-2 text-sm leading-snug text-muted-foreground">
182
+ {props.app.description}
183
+ </p>
184
+ </button>
185
+ )
186
+ }
187
+
188
+ function EtchCard(props: DrawnCard): ReactNode {
189
+ return (
190
+ <button
191
+ type="button"
192
+ className="relative flex w-full flex-col items-start overflow-hidden rounded-[13px] border bg-card px-[15px] pt-4 pb-[13px] text-left"
193
+ data-app-id={props.app.id}
194
+ data-card="etch"
195
+ style={props.hue}
196
+ onClick={props.onOpen}
197
+ >
198
+ <EtchMark text={props.app.acronym} />
199
+ <h3 className="relative z-[1] m-0 text-sm font-semibold tracking-tight">{props.app.name}</h3>
200
+ <p className="mma-copy relative z-[1] m-0 mt-1 line-clamp-2 text-xs leading-snug text-muted-foreground">
201
+ {props.app.description}
202
+ </p>
203
+ <span className="relative z-[1] mt-2 flex items-center gap-2 text-[11px] text-muted-foreground">
204
+ <span>{props.app.version}</span>
205
+ <OpenMark open={props.open} openLabel={props.openLabel} />
206
+ </span>
207
+ </button>
208
+ )
209
+ }
210
+
211
+ function StampCard(props: DrawnCard): ReactNode {
212
+ return (
213
+ <button
214
+ type="button"
215
+ className="relative flex w-full flex-col items-start overflow-hidden rounded-[13px] border bg-card px-[15px] pt-4 pb-[13px] text-left transition duration-200 ease-out hover:-translate-y-px hover:shadow-[0_8px_22px_var(--shadow)]"
216
+ data-app-id={props.app.id}
217
+ data-card="stamp"
218
+ style={props.hue}
219
+ onClick={props.onOpen}
220
+ >
221
+ <span
222
+ data-monogram="stamp"
223
+ className="absolute top-[13px] right-[13px] z-[1] flex size-11 shrink-0 items-center justify-center rounded-[9px] border-2 border-foreground text-sm font-extrabold tracking-wide text-foreground"
224
+ style={props.hue}
225
+ >
226
+ {props.app.acronym}
227
+ </span>
228
+ <h3 className="relative z-[1] m-0 pr-14 text-sm font-semibold tracking-tight">{props.app.name}</h3>
229
+ <p className="mma-copy relative z-[1] m-0 mt-1 line-clamp-2 pr-14 text-xs leading-snug text-muted-foreground">
230
+ {props.app.description}
231
+ </p>
232
+ <span className="relative z-[1] mt-2 flex items-center gap-2 text-[11px] text-muted-foreground">
233
+ <span>{props.app.version}</span>
234
+ <OpenMark open={props.open} openLabel={props.openLabel} />
235
+ </span>
236
+ </button>
237
+ )
238
+ }
239
+
240
+ function EtchMark(props: { readonly text: string }): ReactNode {
241
+ const width = Math.max(88, props.text.length * 36)
242
+ return (
243
+ <svg
244
+ data-monogram="etch"
245
+ viewBox={`0 0 ${width} 64`}
246
+ className="relative z-[1] mb-2.5 block h-[52px] overflow-visible"
247
+ style={{ width }}
248
+ aria-hidden
249
+ >
250
+ <text
251
+ className="etch-line"
252
+ x="2"
253
+ y="52"
254
+ fill="var(--card)"
255
+ stroke="hsl(var(--h) 65% 42%)"
256
+ strokeWidth="4.5"
257
+ paintOrder="stroke fill"
258
+ strokeLinejoin="round"
259
+ fontSize="46"
260
+ fontWeight="800"
261
+ letterSpacing="2"
262
+ fontFamily="ui-sans-serif, system-ui, sans-serif"
263
+ >
264
+ {props.text}
265
+ </text>
266
+ <text
267
+ className="etch-fill"
268
+ x="2"
269
+ y="52"
270
+ fill="hsl(var(--h) 65% 42%)"
271
+ stroke="none"
272
+ fontSize="46"
273
+ fontWeight="800"
274
+ letterSpacing="2"
275
+ fontFamily="ui-sans-serif, system-ui, sans-serif"
276
+ >
277
+ {props.text}
278
+ </text>
279
+ </svg>
280
+ )
281
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export { workbenchEntries } from '@mohou/contract'
2
+ export { appCardCss } from './app-card-css.ts'
3
+ export { AppCard, appCardStyles, type AppCardExtra, type AppCardStyle } from './app-card.tsx'
4
+ export { WorkbenchLibrary } from './library.tsx'
@@ -0,0 +1,37 @@
1
+ import type { ReactNode } from 'react'
2
+ import type { AppListItem } from '@mohou/contract'
3
+
4
+ import { AppCard, type AppCardStyle } from './app-card.tsx'
5
+
6
+ /**
7
+ * The builtin library body. A workbench app does not render this.
8
+ * `openApp` asks the panel to add a tab. The component does not write policy.
9
+ */
10
+ export function WorkbenchLibrary(props: {
11
+ readonly apps: readonly AppListItem[]
12
+ readonly cardStyle: AppCardStyle
13
+ readonly openLabel: string
14
+ readonly openAppIds?: readonly string[]
15
+ openApp(appId: string, title?: string): void
16
+ }): ReactNode {
17
+ const grid = props.cardStyle === 'list' ? 'flex flex-col gap-3' : 'mma-grid'
18
+ return (
19
+ <div className={grid}>
20
+ {props.apps.map((app, index) => {
21
+ const featured = props.cardStyle === 'glass' && index === 0
22
+ return (
23
+ <div key={app.id} className={featured ? 'mma-feature min-w-0' : 'min-w-0'}>
24
+ <AppCard
25
+ type={props.cardStyle}
26
+ app={app}
27
+ open={props.openAppIds?.includes(app.id) === true}
28
+ openLabel={props.openLabel}
29
+ extra={featured ? { featured: true } : {}}
30
+ onOpen={() => { props.openApp(app.id, app.name) }}
31
+ />
32
+ </div>
33
+ )
34
+ })}
35
+ </div>
36
+ )
37
+ }