diff --git a/.dockerignore b/.dockerignore index 2ed7684..b892e2b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,6 +2,9 @@ src/frontend/node_modules/ src/frontend/dist/ src/frontend/.vite/ +src/hub/node_modules/ +src/hub/dist/ +src/hub/.vite/ # Block compiled native Go binary targets and testing outputs src/Maestro diff --git a/Dockerfile b/Dockerfile index 00dcdf4..5a8091e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,6 +22,15 @@ RUN bun ci COPY src/frontend/ ./ RUN bun run build +# Stage 1b: Build the PUBLIC hub SPA bundle (separate bundle, served at /hub/ +# outside the owner gate; browser-edge contract §10.5). +FROM oven/bun:1.3.9-alpine AS hub-builder +WORKDIR /app +COPY src/hub/package.json src/hub/bun.lock ./ +RUN bun ci +COPY src/hub/ ./ +RUN bun run build + # Stage 2: Build the static Go binary FROM golang:1.26-alpine AS go-builder WORKDIR /app @@ -31,6 +40,8 @@ RUN go mod download COPY src/ . # Copy the built UI into the frontend folder so go:embed finds it COPY --from=frontend-builder /app/dist ./frontend/dist +# Copy the built hub SPA into hub/dist so go:embed all:hub/dist finds it +COPY --from=hub-builder /app/dist ./hub/dist # Build a statically linked binary, stripping debug symbols (-s -w) for smaller size RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o server . RUN apk add --no-cache ca-certificates diff --git a/Dockerfile.debug b/Dockerfile.debug index 23b5a44..f782111 100644 --- a/Dockerfile.debug +++ b/Dockerfile.debug @@ -26,6 +26,15 @@ RUN bun ci COPY src/frontend/ ./ RUN bun run build +# Stage 1b: Build the PUBLIC hub SPA bundle (separate bundle, served at /hub/ +# outside the owner gate; browser-edge contract §10.5). +FROM oven/bun:1.3.9-alpine AS hub-builder +WORKDIR /app +COPY src/hub/package.json src/hub/bun.lock ./ +RUN bun ci +COPY src/hub/ ./ +RUN bun run build + # Stage 2: Build the static Go binary FROM golang:1.26-alpine AS go-builder WORKDIR /app @@ -34,6 +43,8 @@ RUN go mod download COPY src/ . # Copy the built UI into the frontend folder so go:embed finds it COPY --from=frontend-builder /app/dist ./frontend/dist +# Copy the built hub SPA into hub/dist so go:embed all:hub/dist finds it +COPY --from=hub-builder /app/dist ./hub/dist RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /server . # Stage 3: Debug-final image (alpine base, not scratch, for shell access) diff --git a/src/hub/.gitignore b/src/hub/.gitignore new file mode 100644 index 0000000..d1fff26 --- /dev/null +++ b/src/hub/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist/assets/ +.vite/ diff --git a/src/hub/bun.lock b/src/hub/bun.lock new file mode 100644 index 0000000..bc1ccef --- /dev/null +++ b/src/hub/bun.lock @@ -0,0 +1,251 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "hub", + "dependencies": { + "preact": "^10.29.3", + }, + "devDependencies": { + "@preact/preset-vite": "^2.10.5", + "@types/node": "^24.13.2", + "typescript": "~6.0.2", + "vite": "^8.1.1", + }, + }, + }, + "packages": { + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], + + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], + + "@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="], + + "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], + + "@babel/helpers": ["@babel/helpers@7.29.10", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.8" } }, "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg=="], + + "@babel/parser": ["@babel/parser@7.29.9", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA=="], + + "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="], + + "@babel/plugin-transform-react-jsx": ["@babel/plugin-transform-react-jsx@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-module-imports": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-syntax-jsx": "^7.29.7", "@babel/types": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A=="], + + "@babel/plugin-transform-react-jsx-development": ["@babel/plugin-transform-react-jsx-development@7.29.7", "", { "dependencies": { "@babel/plugin-transform-react-jsx": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Xfy3UVMF04+ypnFbkhvfqtmvwfe92qwQdbGZVonhE+6v35GzlofmOnA1szaZqzb9xYWr0nl1e5EMmzi0DNON1g=="], + + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/traverse": ["@babel/traverse@7.29.10", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.9", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ=="], + + "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@oxc-project/types": ["@oxc-project/types@0.153.0", "", {}, "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw=="], + + "@preact/preset-vite": ["@preact/preset-vite@2.10.6", "", { "dependencies": { "@babel/plugin-transform-react-jsx": "^7.27.1", "@babel/plugin-transform-react-jsx-development": "^7.27.1", "@prefresh/vite": "^2.4.11", "@rollup/pluginutils": "^5.0.0", "babel-plugin-transform-hook-names": "^1.0.2", "debug": "^4.4.3", "magic-string": "^0.30.21", "picocolors": "^1.1.1", "vite-prerender-plugin": "^0.5.8", "zimmerframe": "^1.1.4" }, "peerDependencies": { "@babel/core": "7.x", "vite": "2.x || 3.x || 4.x || 5.x || 6.x || 7.x || 8.x" } }, "sha512-ZZ5RIT2ZVXg8UxRA8VZpoT8CdpDG7RFIqOT4bELqNBp85+HKvQBbgmh3gsoW1UOQXx26TLe+ZRNKI7q281Kckw=="], + + "@prefresh/babel-plugin": ["@prefresh/babel-plugin@0.5.4", "", { "peerDependencies": { "@babel/core": "^7.0.0 || ^8.0.0" } }, "sha512-GSwqHGVem7AjnOKAkmLNbAExqZZFddf9S3ySOwowGEsJR8vwNKJ7Z/262fov5wra8LkDVSmFw3CjMGRdLoCmRA=="], + + "@prefresh/core": ["@prefresh/core@1.5.11", "", { "peerDependencies": { "preact": "^10.0.0 || ^11.0.0-0" } }, "sha512-Ml00PP8jeHaADxfQqxUs4+M5JS+T5J26UnvPVfsrmlpGw2I3e9XtM0DMeuzkmvg+Tn7VdHSMpLBrDPG0iHoDqQ=="], + + "@prefresh/utils": ["@prefresh/utils@1.2.1", "", {}, "sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw=="], + + "@prefresh/vite": ["@prefresh/vite@2.4.12", "", { "dependencies": { "@babel/core": "^7.22.1", "@prefresh/babel-plugin": "^0.5.2", "@prefresh/core": "^1.5.0", "@prefresh/utils": "^1.2.0", "@rollup/pluginutils": "^4.2.1" }, "peerDependencies": { "preact": "^10.4.0 || ^11.0.0-0", "vite": ">=2.0.0" } }, "sha512-FY1fzXpUjiuosznMV0YM7XAOPZjB5FIdWS0W24+XnlxYkt9hNAwwsiKYn+cuTEoMtD/ZVazS5QVssBr9YhpCQA=="], + + "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.13", "", { "os": "android", "cpu": "arm" }, "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.13", "", { "os": "android", "cpu": "arm64" }, "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.13", "", { "os": "freebsd", "cpu": "x64" }, "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.13", "", { "os": "linux", "cpu": "arm" }, "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.13", "", { "os": "linux", "cpu": "ppc64" }, "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.13", "", { "os": "linux", "cpu": "s390x" }, "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.13", "", { "os": "linux", "cpu": "x64" }, "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.13", "", { "os": "linux", "cpu": "x64" }, "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.13", "", { "os": "none", "cpu": "arm64" }, "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.13", "", { "os": "win32", "cpu": "x64" }, "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + + "@rollup/pluginutils": ["@rollup/pluginutils@5.4.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/node": ["@types/node@24.19.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ=="], + + "babel-plugin-transform-hook-names": ["babel-plugin-transform-hook-names@1.0.2", "", { "peerDependencies": { "@babel/core": "^7.12.10" } }, "sha512-5gafyjyyBTTdX/tQQ0hRgu4AhNHG/hqWi0ZZmg2xvs2FgRkJXzDNKBZCyoYqgFkovfDrgM8OoKg8karoUvWeCw=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.11.28", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA=="], + + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + + "browserslist": ["browserslist@4.29.3", "", { "dependencies": { "baseline-browser-mapping": "^2.11.26", "caniuse-lite": "^1.0.30001813", "electron-to-chromium": "^1.5.439", "node-releases": "^2.0.57", "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001815", "", {}, "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], + + "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + + "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.452", "", {}, "sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg=="], + + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "kolorist": ["kolorist@1.8.0", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="], + + "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="], + + "node-html-parser": ["node-html-parser@6.1.13", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg=="], + + "node-releases": ["node-releases@2.0.58", "", {}, "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg=="], + + "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + + "postcss": ["postcss@8.5.29", "", { "dependencies": { "nanoid": "^3.3.19", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ=="], + + "preact": ["preact@10.29.8", "", { "peerDependencies": { "preact-render-to-string": ">=5" }, "optionalPeers": ["preact-render-to-string"] }, "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q=="], + + "rolldown": ["rolldown@1.2.13", "", { "dependencies": { "@oxc-project/types": "=0.153.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.13", "@rolldown/binding-android-arm64": "1.2.13", "@rolldown/binding-darwin-arm64": "1.2.13", "@rolldown/binding-darwin-x64": "1.2.13", "@rolldown/binding-freebsd-x64": "1.2.13", "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", "@rolldown/binding-linux-arm64-gnu": "1.2.13", "@rolldown/binding-linux-arm64-musl": "1.2.13", "@rolldown/binding-linux-ppc64-gnu": "1.2.13", "@rolldown/binding-linux-s390x-gnu": "1.2.13", "@rolldown/binding-linux-x64-gnu": "1.2.13", "@rolldown/binding-linux-x64-musl": "1.2.13", "@rolldown/binding-openharmony-arm64": "1.2.13", "@rolldown/binding-win32-arm64-msvc": "1.2.13", "@rolldown/binding-win32-x64-msvc": "1.2.13" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ=="], + + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "simple-code-frame": ["simple-code-frame@1.3.0", "", { "dependencies": { "kolorist": "^1.6.0" } }, "sha512-MB4pQmETUBlNs62BBeRjIFGeuy/x6gGKh7+eRUemn1rCFhqo7K+4slPqsyizCbcbYLnaYqaoZ2FWsZ/jN06D8w=="], + + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], + + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], + + "stack-trace": ["stack-trace@1.0.0", "", {}, "sha512-H6D7134xi6qONvh7ZHKgviXf+rd3vhGBSvebPZCaUkd8zvQ+7PtDw6CljPTe4cXWNf2IKZGNqw6VJXSb9IgBpA=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + + "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "update-browserslist-db": ["update-browserslist-db@1.3.4", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A=="], + + "vite": ["vite@8.3.4", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.29", "rolldown": "~1.2.12", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ=="], + + "vite-prerender-plugin": ["vite-prerender-plugin@0.5.14", "", { "dependencies": { "kolorist": "^1.8.0", "magic-string": "0.x >= 0.26.0", "node-html-parser": "^6.1.12", "simple-code-frame": "^1.3.0", "source-map": "^0.7.4", "stack-trace": "^1.0.0-pre2" }, "peerDependencies": { "vite": "5.x || 6.x || 7.x || 8.x" } }, "sha512-X2w9AS1OhwEUec9HSryGAmEnEQJGhW6eFoiCVJv8BfOTVpM8SOjk75cEw/IC28APDHZudeIRbP8Sp7wfABq+Aw=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "zimmerframe": ["zimmerframe@1.1.5", "", {}, "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA=="], + + "@prefresh/vite/@rollup/pluginutils": ["@rollup/pluginutils@4.2.1", "", { "dependencies": { "estree-walker": "^2.0.1", "picomatch": "^2.2.2" } }, "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ=="], + + "@prefresh/vite/@rollup/pluginutils/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + } +} diff --git a/src/hub/dist/favicon.svg b/src/hub/dist/favicon.svg new file mode 100644 index 0000000..7bb12d1 --- /dev/null +++ b/src/hub/dist/favicon.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + diff --git a/src/hub/dist/index.html b/src/hub/dist/index.html new file mode 100644 index 0000000..67e0135 --- /dev/null +++ b/src/hub/dist/index.html @@ -0,0 +1,32 @@ + + + + + + + + + + Hub + + + + +
+ + diff --git a/src/hub/index.html b/src/hub/index.html new file mode 100644 index 0000000..f87ac56 --- /dev/null +++ b/src/hub/index.html @@ -0,0 +1,31 @@ + + + + + + + + + + Hub + + +
+ + + diff --git a/src/hub/package.json b/src/hub/package.json new file mode 100644 index 0000000..d62a5ce --- /dev/null +++ b/src/hub/package.json @@ -0,0 +1,20 @@ +{ + "name": "hub", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "preact": "^10.29.3" + }, + "devDependencies": { + "@preact/preset-vite": "^2.10.5", + "@types/node": "^24.13.2", + "typescript": "~6.0.2", + "vite": "^8.1.1" + } +} diff --git a/src/hub/public/favicon.svg b/src/hub/public/favicon.svg new file mode 100644 index 0000000..7bb12d1 --- /dev/null +++ b/src/hub/public/favicon.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + diff --git a/src/hub/src/app.tsx b/src/hub/src/app.tsx new file mode 100644 index 0000000..08344d0 --- /dev/null +++ b/src/hub/src/app.tsx @@ -0,0 +1,132 @@ +/** + * Copyright (C) 2026 Dcntrlized + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import type { JSX } from 'preact'; +import { useEffect, useState } from 'preact/hooks'; +import { DataProvider, Shell, ThemeProvider, useData } from './components/shell'; +import { matchPath, replace, subscribe, href, type Route } from './lib/router'; +import { instanceDisplayName } from './lib/format'; +import { InstancesScreen } from './screens/instances'; +import { InstanceHomeScreen } from './screens/instance-home'; +import { ServicesScreen } from './screens/services'; +import { ServiceDetailScreen } from './screens/service-detail'; +import { TrustedScreen } from './screens/trusted'; +import { SettingsScreen } from './screens/settings'; +import { NotFoundScreen } from './screens/not-found'; + +export function App() { + return ( + + + + + + ); +} + +function RouterContent() { + const { profile } = useData(); + const [route, setRoute] = useState(() => matchPath(window.location.pathname)); + + useEffect(() => subscribe(() => setRoute(matchPath(window.location.pathname))), []); + + // Redirects (§3.1): bare root and bare /local replace (never a history entry). + // Targets must carry the frozen /hub/ mount prefix — a bare "/instances" + // would leave the hub bundle and land on the operator console's path space. + useEffect(() => { + if (route.name === 'redirect-root') { + replace(href('/instances')); + } else if (route.name === 'redirect-local') { + replace(href('/instances/local/services')); + } + }, [route]); + + // document.title per route (§3.1 column; WCAG 2.4.2). + useEffect(() => { + setDocumentTitle(route, profile); + }, [route, profile]); + + // Focus moves to the route h1 on client-side navigation (§3.2). + useEffect(() => { + if (route.name === 'redirect-root' || route.name === 'redirect-local') return; + const raf = requestAnimationFrame(() => { + const h1 = document.querySelector('main h1'); + if (h1 instanceof HTMLElement) h1.focus(); + }); + return () => cancelAnimationFrame(raf); + }, [route]); + + const screen = renderScreen(route); + return {screen}; +} + +function renderScreen(route: Route): JSX.Element { + switch (route.name) { + case 'redirect-root': + return ; + case 'redirect-local': + return ; + case 'instances': + return ; + case 'services': + return ; + case 'service-detail': + return ; + case 'trusted': + return ; + case 'settings': + return ; + default: + return ; + } +} + +function setDocumentTitle(route: Route, profile: ReturnType['profile']): void { + const instanceName = profile + ? instanceDisplayName(profile.instance.title, profile.instance.name, 'instance') + : 'instance'; + + switch (route.name) { + case 'instances': + document.title = 'Hub'; + break; + case 'services': + document.title = profile + ? `Services — ${instanceName} · Hub` + : 'Loading… · Hub'; + break; + case 'service-detail': { + const service = profile?.services?.find(s => s.service_name === route.serviceName); + const serviceName = service?.title || route.serviceName; + document.title = profile ? `${serviceName} — ${instanceName} · Hub` : 'Loading… · Hub'; + break; + } + case 'trusted': + document.title = profile + ? `Trusted partners — ${instanceName} · Hub` + : 'Loading… · Hub'; + break; + case 'settings': + document.title = 'Settings · Hub'; + break; + case 'not-found': + document.title = 'Page not found · Hub'; + break; + default: + document.title = 'Hub'; + } +} diff --git a/src/hub/src/components/details-dialog.tsx b/src/hub/src/components/details-dialog.tsx new file mode 100644 index 0000000..daed4a0 --- /dev/null +++ b/src/hub/src/components/details-dialog.tsx @@ -0,0 +1,129 @@ +/** + * Copyright (C) 2026 Dcntrlized + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import type { JSX } from 'preact'; +import { useEffect, useRef } from 'preact/hooks'; +import { CopyControl } from './ui'; + +/** + * Instance details dialog (§4.2): role=dialog aria-modal, focus trap, + * Escape + ✕ + backdrop close, focus returns to the invoker. Rows: Name / URL + * (window.location.origin) / Fingerprint (mono + Copy). + */ +export function DetailsDialog({ + open, + name, + fingerprint, + onClose, +}: { + open: boolean; + name: string; + fingerprint?: string; + onClose: () => void; +}) { + const dialogRef = useRef(null); + const origin = typeof window !== 'undefined' ? window.location.origin : ''; + + useEffect(() => { + if (!open) return; + const dialog = dialogRef.current; + if (!dialog) return; + // Focus the dialog on open. + dialog.focus(); + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + return; + } + if (e.key !== 'Tab') return; + // Simple focus trap: keep Tab cycling within the dialog. + const focusables = dialog.querySelectorAll( + 'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + if (focusables.length === 0) return; + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + }; + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, [open, onClose]); + + if (!open) return null; + + return ( +
) => { + if (e.target === e.currentTarget) onClose(); + }} + > + +
+ ); +} + +function CloseIcon() { + return ( + + + + ); +} diff --git a/src/hub/src/components/partner-row.tsx b/src/hub/src/components/partner-row.tsx new file mode 100644 index 0000000..7e26d32 --- /dev/null +++ b/src/hub/src/components/partner-row.tsx @@ -0,0 +1,142 @@ +/** + * Copyright (C) 2026 Dcntrlized + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import type { JSX } from 'preact'; +import { useId, useRef, useState } from 'preact/hooks'; +import { CopyControl, Chip } from './ui'; +import { PartnerStatus } from './status'; +import type { TrustedInstance } from '../lib/types'; + +let idCounter = 0; +const nextId = () => `partner-${++idCounter}`; + +/** + * A trusted-partner row (§4.5). Tapping opens an inline disclosure explaining + * browsers can't browse partners; Escape inside collapses and returns focus to + * the row button. No focus trap and no focus movement on open — the panel is + * adjacent DOM and reading order continues naturally. + */ +export function PartnerRow({ partner }: { partner: TrustedInstance }) { + const [open, setOpen] = useState(false); + const rowId = useId() || nextId(); + const panelId = `${rowId}-panel`; + const titleId = `${rowId}-title`; + const btnRef = useRef(null); + const name = partner.name || partner.url; + + const toggle = () => setOpen(o => !o); + const close = () => { + setOpen(false); + btnRef.current?.focus(); + }; + + const onKeyDown = (e: JSX.TargetedKeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + close(); + } + }; + + return ( +
+ + + {open ? ( +
+
+ Can't browse partners from the browser +
+
+ A browser page may only read the instance that serves it, so this + hub can't fetch {name}’s profile. Open its address directly, or use + the Hub app on a phone. +
+
{partner.url}
+
+ + +
+
+ ) : null} +
+ ); +} + +function LinkIcon() { + return ( + + + + + ); +} + +function ChevronIcon() { + return ( + + + + ); +} diff --git a/src/hub/src/components/service-card.tsx b/src/hub/src/components/service-card.tsx new file mode 100644 index 0000000..1c0bc13 --- /dev/null +++ b/src/hub/src/components/service-card.tsx @@ -0,0 +1,86 @@ +/** + * Copyright (C) 2026 Dcntrlized + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import type { PublicService } from '../lib/types'; +import { href } from '../lib/router'; +import { linkClick } from './shell'; +import { serviceTone, shortVersion } from '../lib/format'; +import { Chip, Dot } from './ui'; + +/** + * Read-only service card (§4.3). The card is a real to the detail route; + * the status dot is decorative (aria-hidden) — the status repeats as chip text + * and in an accessible description so it is never colour-only. + */ +export function ServiceCard({ service }: { service: PublicService }) { + const title = service.title || service.service_name; + const tone = serviceTone(service.status); + const isDb = service.capabilities?.includes('database') ?? false; + const detailHref = href(`/instances/local/services/${encodeURIComponent(service.service_name)}`); + + return ( + +
+ + {title} + +
+
{service.service_name}
+ {service.description ?
{service.description}
: null} +
+ {service.version ? ( + {`v${shortVersion(service.version)}`} + ) : null} + {isDb ? database : null} + {service.status} +
+
+ ); +} + +function BoxIcon() { + return ( + + + + + + ); +} + +function DbIcon() { + return ( + + + + + + ); +} diff --git a/src/hub/src/components/shell.tsx b/src/hub/src/components/shell.tsx new file mode 100644 index 0000000..d4a06a4 --- /dev/null +++ b/src/hub/src/components/shell.tsx @@ -0,0 +1,531 @@ +/** + * Copyright (C) 2026 Dcntrlized + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import type { JSX } from 'preact'; +import { createContext } from 'preact'; +import { + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'preact/hooks'; +import { fetchPublicProfile, type FetchResult } from '../lib/api'; +import { backOrReplace, href, push, type Route } from '../lib/router'; +import type { DataState, InstancePublicProfile, SectionState } from '../lib/types'; +import { IconButton } from './ui'; + +/* ------------------------------------------------------------------ */ +/* Theme */ +/* ------------------------------------------------------------------ */ + +export type Theme = 'light' | 'dark'; + +const ThemeCtx = createContext<{ theme: Theme; toggle: () => void }>({ + theme: 'light', + toggle: () => {}, +}); + +export function useTheme() { + return useContext(ThemeCtx); +} + +function effectiveTheme(): Theme { + try { + const stored = localStorage.getItem('settings.theme'); + if (stored === 'light' || stored === 'dark') return stored; + } catch { + /* ignore storage errors */ + } + return window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light'; +} + +export function ThemeProvider({ children }: { children: JSX.Element }) { + const [theme, setTheme] = useState(effectiveTheme); + + useEffect(() => { + document.documentElement.setAttribute('data-theme', theme); + try { + localStorage.setItem('settings.theme', theme); + } catch { + /* ignore storage errors */ + } + }, [theme]); + + const toggle = useCallback(() => { + setTheme(t => (t === 'dark' ? 'light' : 'dark')); + }, []); + + const value = useMemo(() => ({ theme, toggle }), [theme, toggle]); + return {children}; +} + +/* ------------------------------------------------------------------ */ +/* Data polling (§4.0) */ +/* ------------------------------------------------------------------ */ + +interface CachedProfile { + fetchedAt: number; + instance: InstancePublicProfile['instance']; + sections: { services: SectionState; trusted: SectionState }; +} + +const CACHE_KEY = 'hub.profile'; +const BACKOFF = [5000, 15000, 30000, 60000]; +const PROBE_TIMEOUT = 10000; + +const initialData: DataState = { + profile: null, + status: 'disconnected', + probing: false, + error: null, + lastProbedAt: null, + lastProbeFailed: false, + offline: false, + sections: null, +}; + +function readCache(): CachedProfile | null { + try { + const raw = sessionStorage.getItem(CACHE_KEY); + if (!raw) return null; + return JSON.parse(raw) as CachedProfile; + } catch { + return null; + } +} + +function writeCache(c: CachedProfile): void { + try { + sessionStorage.setItem(CACHE_KEY, JSON.stringify(c)); + } catch { + /* ignore */ + } +} + +interface DataApi extends DataState { + refresh: () => void; + announce: (msg: string) => void; + announcement: string; +} + +const DataCtx = createContext({ + ...initialData, + refresh: () => {}, + announce: () => {}, + announcement: '', +}); + +export function useData(): DataApi { + return useContext(DataCtx); +} + +export function DataProvider({ children }: { children: JSX.Element }) { + const [state, setState] = useState(() => { + const cached = readCache(); + if (!cached) return initialData; + return { + ...initialData, + profile: { + instance: cached.instance, + services: cached.sections.services.value as never, + trusted_instances: cached.sections.trusted.value as never, + }, + sections: cached.sections, + }; + }); + const [announcement, setAnnouncement] = useState(''); + + // Refs for the polling loop (avoid stale closures). + const inFlightRef = useRef(false); + const failuresRef = useRef(0); + const statusRef = useRef('disconnected'); + const timerRef = useRef(null); + const abortRef = useRef(null); + const profileRef = useRef(null); + const sectionsRef = useRef<{ services: SectionState; trusted: SectionState } | null>(null); + + const announce = useCallback((msg: string) => { + setAnnouncement(msg); + }, []); + + const clearTimer = useCallback(() => { + if (timerRef.current !== null) { + window.clearTimeout(timerRef.current); + timerRef.current = null; + } + }, []); + + const schedule = useCallback( + (delay: number) => { + clearTimer(); + timerRef.current = window.setTimeout(() => { + timerRef.current = null; + if (document.visibilityState === 'visible') { + void runOnce(); + } + }, delay); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [clearTimer], + ); + + const updateFromResult = useCallback( + (res: FetchResult) => { + const prevStatus = statusRef.current; + failuresRef.current = 0; + statusRef.current = 'connected'; + profileRef.current = res.profile; + sectionsRef.current = res.sections; + writeCache({ + fetchedAt: Date.now(), + instance: res.profile.instance, + sections: res.sections, + }); + setState(s => ({ + ...s, + profile: res.profile, + sections: res.sections, + status: 'connected', + error: null, + lastProbeFailed: false, + offline: false, + lastProbedAt: Date.now(), + probing: false, + })); + if (prevStatus !== 'connected') { + announce('Instance connected'); + } + }, + [announce], + ); + + const handleFailure = useCallback( + (message: string, offline: boolean) => { + const prevStatus = statusRef.current; + failuresRef.current += 1; + const f = failuresRef.current; + // Hysteresis: connected→flaky requires two consecutive failures. + if (prevStatus === 'connected' && f >= 2) { + statusRef.current = 'flaky'; + } + const newStatus = statusRef.current; + setState(s => ({ + ...s, + status: newStatus, + error: message, + lastProbeFailed: true, + offline, + lastProbedAt: Date.now(), + probing: false, + })); + if (prevStatus !== newStatus && !offline) { + announce(`Instance ${newStatus}`); + } + }, + [announce], + ); + + const runOnce = useCallback(async () => { + if (inFlightRef.current) return; + // Offline: don't burn a probe; report offline and wait for `online`. + if (typeof navigator !== 'undefined' && navigator.onLine === false) { + handleFailure("You're offline.", true); + schedule(5000); + return; + } + inFlightRef.current = true; + abortRef.current = new AbortController(); + setState(s => ({ ...s, probing: true })); + try { + const res = await fetchPublicProfile(window.location.origin, PROBE_TIMEOUT); + updateFromResult(res); + } catch (e) { + const msg = + e instanceof Error + ? taxonomyMessage(e.message) + : 'Instance unreachable — the instance is not answering.'; + handleFailure(msg, false); + } finally { + inFlightRef.current = false; + abortRef.current = null; + // Schedule the next probe per backoff. + const f = failuresRef.current; + const delay = f === 0 ? 5000 : BACKOFF[Math.min(f - 1, BACKOFF.length - 1)]; + schedule(delay); + } + }, [updateFromResult, handleFailure, schedule]); + + // Boot + events. + useEffect(() => { + void runOnce(); + const onFocus = () => { + if (document.visibilityState === 'visible') void runOnce(); + }; + const onVis = () => { + if (document.visibilityState === 'visible') { + void runOnce(); + } else { + clearTimer(); + } + }; + const onOnline = () => void runOnce(); + const onOffline = () => { + handleFailure("You're offline.", true); + clearTimer(); + }; + window.addEventListener('focus', onFocus); + document.addEventListener('visibilitychange', onVis); + window.addEventListener('online', onOnline); + window.addEventListener('offline', onOffline); + return () => { + window.removeEventListener('focus', onFocus); + document.removeEventListener('visibilitychange', onVis); + window.removeEventListener('online', onOnline); + window.removeEventListener('offline', onOffline); + clearTimer(); + }; + }, [runOnce, handleFailure, clearTimer]); + + // Manual refresh (shared in-flight guard with polling). + const refresh = useCallback(() => { + void runOnce(); + }, [runOnce]); + + const value = useMemo( + () => ({ + ...state, + refresh, + announce, + announcement, + }), + [state, refresh, announce, announcement], + ); + + return ( + + {children} +
+ {announcement} +
+
+ ); +} + +/** Map a raw fetch error message onto the taxonomy banner copy (§4.0). */ +function taxonomyMessage(raw: string): string { + if (raw.includes('has no /api/instance')) { + return 'This instance did not return a public profile.'; + } + if (raw.includes('HTTP 401') || raw.includes('HTTP 403')) { + return 'This profile is not reachable from your network.'; + } + if (raw.includes('HTTP ')) { + return 'That address is not a Dcntrlized instance.'; + } + return 'Instance unreachable — the instance is not answering.'; +} + +/** Derive the exact taxonomy banner copy from an error (for screens). */ +export function taxonomyCopy(msg: string | null, status?: number): string { + if (!msg) return 'Instance unreachable — the instance is not answering.'; + if (status === 401 || status === 403) { + return 'This profile is not reachable from your network.'; + } + return taxonomyMessage(msg); +} + +/* ------------------------------------------------------------------ */ +/* Shell layout */ +/* ------------------------------------------------------------------ */ + +export function Shell({ + route, + children, +}: { + route: Route; + children: JSX.Element; +}) { + const { probing, refresh } = useData(); + const inFlight = probing; + const isInstances = route.name === 'instances'; + + return ( +
+ + +
+
+ + Hub + +
+ {isInstances ? ( + <> + + + + push(href('/settings'))}> + + + + ) : null} +
+
+
{children}
+
+
+
+ ); +} + +export function linkClick(target: string) { + return (e: JSX.TargetedMouseEvent) => { + // Only intercept plain left-clicks (middle-click / ctrl+click stay native). + if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) { + return; + } + e.preventDefault(); + push(target); + }; +} + +/* ------------------------------------------------------------------ */ +/* Instance-scope header + tab strip */ +/* ------------------------------------------------------------------ */ + +export function TabStrip({ active }: { active: 'services' | 'trusted' }) { + const { refresh, probing } = useData(); + return ( + + ); +} + +export function BackChevron({ target, label }: { target: string; label: string }) { + // A real (middle-click/new-tab safe); the click handler applies the + // one back rule (§3.1.1) only for plain left-clicks. + return ( + ) => { + if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) { + return; + } + e.preventDefault(); + backOrReplace(target); + }} + > + + + ); +} + +export function InfoButton({ onClick, label = 'Instance details' }: { onClick: () => void; label?: string }) { + return ( + + + + ); +} + +function RefreshIcon() { + return ( + + + + + ); +} + +function SettingsIcon() { + return ( + + + + + ); +} + +function BackIcon() { + return ( + + + + ); +} + +function InfoIcon() { + return ( + + + + + + ); +} diff --git a/src/hub/src/components/status.tsx b/src/hub/src/components/status.tsx new file mode 100644 index 0000000..3ffaec5 --- /dev/null +++ b/src/hub/src/components/status.tsx @@ -0,0 +1,99 @@ +/** + * Copyright (C) 2026 Dcntrlized + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import type { InstanceStatus } from '../lib/types'; +import { + capitalize, + instanceTone, + partnerOk, + serviceTone, + type Tone, +} from '../lib/format'; +import { Dot } from './ui'; + +/** + * Status pill: `*-soft` fill + 6 px status dot + INK word (§6.2). The word is + * visible text (the accessible name); colour is never the only signal. + */ +export function StatusPill({ status }: { status: string }) { + const tone = serviceTone(status); + return ( + + + {capitalize(status)} + + ); +} + +/** Partner status: ok (online/authorized) or muted. */ +export function PartnerStatus({ status }: { status: string }) { + const ok = partnerOk(status); + return ( + + + {capitalize(status)} + + ); +} + +/** Instance connectivity indicator: wifi icon + status word + aria-label. */ +export function InstanceStatusBadge({ status }: { status: InstanceStatus }) { + const tone = instanceTone(status); + return ( + + + + {status} + + + ); +} + +function WifiIcon({ tone }: { tone: Tone }) { + const color = tone === 'ok' ? 'var(--dn-ok)' : tone === 'warn' ? 'var(--dn-warn)' : 'var(--dn-bad)'; + return ( + + ); +} diff --git a/src/hub/src/components/ui.tsx b/src/hub/src/components/ui.tsx new file mode 100644 index 0000000..315e74e --- /dev/null +++ b/src/hub/src/components/ui.tsx @@ -0,0 +1,195 @@ +/** + * Copyright (C) 2026 Dcntrlized + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import type { ComponentChildren, JSX } from 'preact'; +import { useCallback, useRef, useState } from 'preact/hooks'; +import type { Tone } from '../lib/format'; + +/** Decorative icon slot. */ +export type IconSlot = JSX.Element | null; + +export function Card({ className = '', children }: { className?: string; children: ComponentChildren }) { + return
{children}
; +} + +export function EmptyState({ + icon, + title, + subtitle, +}: { + icon?: IconSlot; + title: string; + subtitle?: string; +}) { + return ( +
+ {icon ?
{icon}
: null} +
{title}
+ {subtitle ?
{subtitle}
: null} +
+ ); +} + +export function LoadingView({ label = 'Loading…' }: { label?: string }) { + return ( +
+ + ); +} + +/** + * Error/offline banner. `details` (e.g. raw HTTP status) renders inside an + * optional
so transport detail never becomes the primary message. + */ +export function Banner({ + message, + onRetry, + offline = false, + details, + retryLabel = 'Retry', +}: { + message: string; + onRetry?: () => void; + offline?: boolean; + details?: string; + retryLabel?: string; +}) { + return ( +
+ + {onRetry ? ( + + ) : null} + {details ? ( +
+ + +
+ ) : null} +
+ ); +} + +export type DotTone = Tone | 'muted'; + +export function Dot({ tone }: { tone: DotTone }) { + return