Restructure plugin API, implement basic minimal shell prototype
diff --git a/packages/plugins/.gitignore b/packages/plugins/.gitignore
deleted file mode 100644
index 206df3e..0000000
--- a/packages/plugins/.gitignore
+++ /dev/null
@@ -1,3 +0,0 @@
-lib
-node_modules
-*.tsbuildinfo
\ No newline at end of file
diff --git a/packages/plugins/package.json b/packages/plugins/package.json
deleted file mode 100644
index 3406ac4..0000000
--- a/packages/plugins/package.json
+++ /dev/null
@@ -1,15 +0,0 @@
-{
- "name": "iris-plugins",
- "private": true,
- "license": "LGPL-2.1-only",
- "author": "jottocraft",
- "scripts": {
- "build": "tsc --build",
- "clean": "rimraf lib",
- "start": "tsc --build --watch",
- "validate": "tsc --noEmit"
- },
- "dependencies": {
- "@jottocraft/iris-sdk": "workspace:*"
- }
-}
diff --git a/packages/plugins/src/index.ts b/packages/plugins/src/index.ts
deleted file mode 100644
index 7f535b5..0000000
--- a/packages/plugins/src/index.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-// Iris
-// Copyright (c) jottocraft 2025
-
-export const DUMMY_EXPORT = 1;
\ No newline at end of file
diff --git a/packages/plugins/tsconfig.json b/packages/plugins/tsconfig.json
deleted file mode 100644
index 599fdc0..0000000
--- a/packages/plugins/tsconfig.json
+++ /dev/null
@@ -1,113 +0,0 @@
-{
- "include": [
- "src"
- ],
- "compilerOptions": {
- /* Visit https://aka.ms/tsconfig to read more about this file */
-
- /* Projects */
- "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
- "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
- // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
- // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
- // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
- // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
-
- /* Language and Environment */
- "target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
- // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
- // "jsx": "preserve", /* Specify what JSX code is generated. */
- // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
- // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
- // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
- // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
- // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
- // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
- // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
- // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
- // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
-
- /* Modules */
- "module": "commonjs", /* Specify what module code is generated. */
- "rootDir": "src", /* Specify the root folder within your source files. */
- "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
- // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
- "paths": { /* Specify a set of entries that re-map imports to additional lookup locations. */
- "~/*": ["./src/*"]
- },
- // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
- // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
- // "types": [], /* Specify type package names to be included without being referenced in a source file. */
- // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
- // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
- // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
- // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
- // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
- // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
- // "resolveJsonModule": true, /* Enable importing .json files. */
- // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
- // "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
-
- /* JavaScript Support */
- // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
- // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
- // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
-
- /* Emit */
- "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
- "declarationMap": true, /* Create sourcemaps for d.ts files. */
- // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
- "sourceMap": true, /* Create source map files for emitted JavaScript files. */
- // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
- // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
- "outDir": "lib", /* Specify an output folder for all emitted files. */
- // "removeComments": true, /* Disable emitting comments. */
- // "noEmit": true, /* Disable emitting files from a compilation. */
- // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
- // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
- // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
- // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
- // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
- // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
- // "newLine": "crlf", /* Set the newline character for emitting files. */
- // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
- // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
- // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
- // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
- // "declarationDir": "./", /* Specify the output directory for generated declaration files. */
-
- /* Interop Constraints */
- "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
- // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
- // "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
- // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
- "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
- // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
- "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
-
- /* Type Checking */
- "strict": true, /* Enable all strict type-checking options. */
- // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
- // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
- // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
- // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
- // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
- // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
- // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
- // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
- // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
- // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
- // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
- // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
- // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
- // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
- // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
- // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
- // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
- // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
-
- /* Completeness */
- // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
- "skipLibCheck": true /* Skip type checking all .d.ts files. */
- }
-}
\ No newline at end of file
diff --git a/packages/sdk/package.json b/packages/sdk/package.json
index 4303336..e77cbdc 100644
--- a/packages/sdk/package.json
+++ b/packages/sdk/package.json
@@ -18,9 +18,11 @@
"README.md"
],
"scripts": {
- "build": "tsc --build",
+ "build": "tsc --build && tsc-alias",
"clean": "rimraf lib",
- "start": "tsc --build --watch",
- "validate": "tsc --noEmit"
+ "start": "run-p dev:*",
+ "validate": "tsc --noEmit",
+ "dev:tsc": "tsc --build --watch",
+ "dev:tsc-alias": "tsc-alias --watch"
}
}
diff --git a/packages/sdk/src/conduit.ts b/packages/sdk/src/conduit.ts
new file mode 100644
index 0000000..ac735b2
--- /dev/null
+++ b/packages/sdk/src/conduit.ts
@@ -0,0 +1,218 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+export type IrisPluginConfigConduit = {
+ type: "conduit";
+ id: string;
+ baseUrl: string;
+};
+
+export type IrisPluginCtxConduit<PluginType extends IrisPluginConduit> = {
+ type: "conduit";
+ plugin: PluginType;
+ client: import("@jottocraft/yggdrasil").YggdrasilConduitApiClient<IrisPluginConduitConduitServerTypes<PluginType>>;
+};
+
+export type IrisPluginConduitConduitServerTypes<PluginType extends IrisPluginConduit> = {
+ client: {
+ plugin: () => Promise<PluginType>;
+ panels: {
+ [Property in keyof PluginType["panels"]]: (args: IrisPluginConduitPanelArgs<PluginType["panels"][Property]["requiredArgs"]>) => Promise<IrisPluginConduitPanelLayout<PluginType>>;
+ };
+ forms: {
+ [Property in keyof PluginType["forms"]]: (data: IrisPluginConduitFieldData<PluginType["forms"][Property]["data"]>) => Promise<string>;
+ };
+ };
+ apiCtx: {
+ jwt: string;
+ };
+ conduitCtx: {
+ jwt: string;
+ };
+};
+
+export type IrisPluginConduit = {
+ displayName: string;
+ icon: string;
+ panels: {
+ [id: string]: IrisPluginConduitPanel;
+ };
+ forms: {
+ [id: string]: IrisPluginConduitForm;
+ };
+ navigation: {
+ panel: IrisPluginConduitPanelRef<IrisPluginConduit>;
+ displayName: string;
+ icon?: string;
+ }[];
+};
+
+export type IrisPluginConduitPanel = {
+ requiredArgs: string[];
+};
+
+export type IrisPluginConduitPanelArgs<RequiredArgsType extends string[]> = {
+ [Property in RequiredArgsType[number]]: string;
+} & {
+ [arg: string]: string;
+};
+
+export type IrisPluginConduitForm = {
+ displayName: string;
+ icon?: string;
+ data?: IrisPluginConduitField;
+};
+
+export type IrisPluginConduitField = {
+ displayName?: string;
+ icon?: string;
+ helpText?: string;
+ optional?: boolean;
+} & ({
+ type: "string" | "number" | "date" | "datetime" | "boolean";
+} | {
+ type: "dropdown";
+ options: { label: string; icon?: string; value: string; }[];
+} | {
+ type: "array";
+ of: IrisPluginConduitField;
+} | {
+ type: "object";
+ struct: {
+ [key: string]: IrisPluginConduitField;
+ };
+});
+
+export type _IrisPluginConduitFieldDeepOptional<FieldSpec extends IrisPluginConduitField | undefined> = FieldSpec extends { type: "string" | "date" | "datetime" | "number" | "boolean" | "dropdown" } ? (
+ Omit<FieldSpec, "optional"> & { optional: true; }
+) : FieldSpec extends { type: "array" } ? (
+ Omit<FieldSpec, "of"> & { of: _IrisPluginConduitFieldDeepOptional<FieldSpec["of"]>; }
+) : FieldSpec extends { type: "object" } ? (
+ Omit<FieldSpec, "struct"> & {
+ struct: {
+ [Property in keyof FieldSpec["struct"]]: _IrisPluginConduitFieldDeepOptional<FieldSpec["struct"][Property]>;
+ };
+ }
+) : FieldSpec extends undefined ? (
+ undefined
+) : never;
+
+// https://github.com/microsoft/TypeScript/issues/12400#issuecomment-758523767
+
+export type _IrisUKeysOfType<T, SelectedType> = {
+ [key in keyof T]: SelectedType extends T[key] ? key : never
+}[keyof T];
+
+export type _IrisUOptional<T> = Partial<Pick<T, _IrisUKeysOfType<T, undefined>>>;
+
+export type _IrisURequired<T> = Omit<T, _IrisUKeysOfType<T, undefined>>;
+
+export type _IrisUOptionalUndefined<T> = _IrisUOptional<T> & _IrisURequired<T>;
+
+export type _IrisPluginConduitFieldDataCore<FieldSpec extends IrisPluginConduitField | undefined> = FieldSpec extends { type: "string" | "date" | "datetime" } ? (
+ string
+) : FieldSpec extends { type: "number" } ? (
+ number
+) : FieldSpec extends { type: "boolean" } ? (
+ boolean
+) : FieldSpec extends { type: "dropdown" } ? (
+ FieldSpec["options"][number]
+) : FieldSpec extends { type: "array" } ? (
+ IrisPluginConduitFieldData<FieldSpec["of"]>[]
+) : FieldSpec extends { type: "object" } ? (
+ _IrisUOptionalUndefined<{
+ [Property in keyof FieldSpec["struct"]]: IrisPluginConduitFieldData<FieldSpec["struct"][Property]>;
+ }>
+) : FieldSpec extends undefined ? (
+ undefined
+) : never;
+
+export type IrisPluginConduitFieldData<FieldSpec extends IrisPluginConduitField | undefined> = FieldSpec extends { optional: true; } ? (
+ _IrisPluginConduitFieldDataCore<FieldSpec> | undefined
+) : (
+ _IrisPluginConduitFieldDataCore<FieldSpec>
+ );
+
+export type IrisPluginConduitPanelLayout<PluginType extends IrisPluginConduit> = {
+ displayName: string;
+ icon?: string;
+ forms?: IrisPluginConduitFormRef<PluginType>[];
+ main?: IrisPluginConduitPanelLayoutField[];
+ sections?: IrisPluginConduitPanelLayoutSection<PluginType>[];
+};
+
+export type IrisPluginConduitPanelLayoutSection<PluginType extends IrisPluginConduit> = IrisPluginConduitPanelLayoutSectionProperties<PluginType> | IrisPluginConduitPanelLayoutSectionTable<PluginType> | IrisPluginConduitPanelLayoutSectionRef<PluginType>;
+
+export type IrisPluginConduitPanelLayoutSectionCommon = {
+ id: string;
+ displayName: string;
+ icon?: string;
+};
+
+export type IrisPluginConduitPanelLayoutSectionProperties<PluginType extends IrisPluginConduit> = IrisPluginConduitPanelLayoutSectionCommon & {
+ type: "properties";
+ properties: IrisPluginConduitPanelLayoutField[];
+};
+
+export type IrisPluginConduitPanelLayoutSectionTable<PluginType extends IrisPluginConduit> = IrisPluginConduitPanelLayoutSectionCommon & {
+ type: "table";
+ columns: {
+ key: string;
+ displayName: string;
+ icon?: string;
+ }[];
+ rows: {
+ vals: {
+ [key: string]: string;
+ };
+ links?: {
+ [key: string]: IrisPluginConduitPanelRef<PluginType>;
+ };
+ }[];
+ firstRow: number;
+ totalRows: number;
+};
+
+export type IrisPluginConduitPanelLayoutSectionRef<PluginType extends IrisPluginConduit> = IrisPluginConduitPanelLayoutSectionCommon & {
+ type: "ref";
+ panel: IrisPluginConduitPanelRef<PluginType>;
+};
+
+export type IrisPluginConduitPanelRef<PluginType extends IrisPluginConduit> = {
+ id: string;
+ args?: unknown;
+};
+
+export type IrisPluginConduitFormRef<PluginType extends IrisPluginConduit> = {
+ id: string;
+ data?: unknown;
+};
+
+export type IrisPluginConduitPanelLayoutField = {
+ displayName: string;
+ icon?: string;
+ value: string;
+};
+
+/**
+ * Helper utility for implementing an Iris Conduit server with strong type safety.
+ */
+export function createIrisConduitServerImpl<PluginType extends IrisPluginConduit>(): {
+ panelRef: <PanelId extends keyof PluginType["panels"]>(id: PanelId, args?: Partial<IrisPluginConduitPanelArgs<PluginType["panels"][PanelId]["requiredArgs"]>>) => IrisPluginConduitPanelRef<PluginType>;
+ formRef: <FormId extends keyof PluginType["forms"]>(id: FormId, args?: IrisPluginConduitFieldData<_IrisPluginConduitFieldDeepOptional<PluginType["forms"][FormId]["data"]>>) => IrisPluginConduitFormRef<PluginType>;
+} {
+ return {
+ panelRef(id, args) {
+ return {
+ id: id as string,
+ args
+ }
+ },
+ formRef(id, data) {
+ return {
+ id: id as string,
+ data
+ }
+ }
+ };
+}
\ No newline at end of file
diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts
index 7f535b5..1750cff 100644
--- a/packages/sdk/src/index.ts
+++ b/packages/sdk/src/index.ts
@@ -1,4 +1,5 @@
// Iris
// Copyright (c) jottocraft 2025
-export const DUMMY_EXPORT = 1;
\ No newline at end of file
+export * from "~/conduit";
+export * from "~/site";
\ No newline at end of file
diff --git a/packages/sdk/src/site.ts b/packages/sdk/src/site.ts
new file mode 100644
index 0000000..c545a3d
--- /dev/null
+++ b/packages/sdk/src/site.ts
@@ -0,0 +1,42 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import { IrisPluginConduit, IrisPluginConfigConduit, IrisPluginCtxConduit } from "~/conduit";
+
+export type IrisSiteConfig = {
+ brand: {
+ name: string;
+ logo?: {
+ dark: string;
+ light: string;
+ };
+ };
+ auth: IrisAuthConfig;
+ plugins: IrisPluginConfig[];
+};
+
+// Auth:
+
+export type IrisAuthConfig = IrisAuthConfigOidc;
+
+export type IrisAuthConfigOidc = {
+ type: "oidc";
+ method: "implicit";
+ iss: string;
+ authUrl: string;
+ clientId: string;
+ scope?: string;
+};
+
+export type IrisAuthCtx = {
+ jwt: string;
+ claims: import("jose").JWTPayload;
+};
+
+// Plugins:
+
+export type IrisPluginConfig = IrisPluginConfigConduit;
+
+export type IrisPluginCtx = {
+ [id: string]: IrisPluginCtxConduit<IrisPluginConduit>;
+};
\ No newline at end of file
diff --git a/packages/server/.gitignore b/packages/server/.gitignore
deleted file mode 100644
index 1b0d95d..0000000
--- a/packages/server/.gitignore
+++ /dev/null
@@ -1,4 +0,0 @@
-node_modules
-build
-.wrangler
-*.tsbuildinfo
\ No newline at end of file
diff --git a/packages/server/package.json b/packages/server/package.json
deleted file mode 100644
index 522b845..0000000
--- a/packages/server/package.json
+++ /dev/null
@@ -1,17 +0,0 @@
-{
- "name": "iris-server",
- "private": true,
- "license": "LGPL-2.1-only",
- "author": "jottocraft",
- "scripts": {
- "build": "wrangler deploy --dry-run --outdir build",
- "clean": "rimraf build",
- "deploy": "wrangler deploy",
- "start": "wrangler dev --local --ip 127.0.0.1 --port 2500 --inspector-port 2501",
- "validate": "tsc --noEmit"
- },
- "dependencies": {
- "@jottocraft/iris-sdk": "workspace:*",
- "iris-plugins": "workspace:*"
- }
-}
diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts
deleted file mode 100644
index c1698e8..0000000
--- a/packages/server/src/index.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-// Iris
-// Copyright (c) jottocraft 2025
-
-import { YggdrasilRouter } from "@jottocraft/yggdrasil";
-
-import { IrisServer } from "~/server";
-
-export default {
- async fetch(request: Request, env: IrisServerEnv, ctx: ExecutionContext): Promise<Response> {
- return IrisServer.handleRequest(request, async (ygg) => {
- return new Response("Hello world!", { status: 200 });
- }, {
- ctx: {
- env,
- exec: ctx
- }
- }, {
- // TODO: Put these in environment variables
- baseURL: "https://iris.hq.jottocraft.com/api",
- debug: true,
- generateHtmlStatusPage: async (ygg, e) => {
- return new Response([
- "Error " + e.status,
- e.statusCode,
- e.statusMessage,
- "",
- "",
- "",
- "",
- "Iris",
- "Copyright (c) jottocraft 2025"
- ].join("\n"), {
- status: 200
- });
- }
- });
- }
-}
\ No newline at end of file
diff --git a/packages/server/src/iris-server.d.ts b/packages/server/src/iris-server.d.ts
deleted file mode 100644
index 2e859ba..0000000
--- a/packages/server/src/iris-server.d.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-// Iris
-// Copyright (c) jottocraft 2025
-
-declare type IrisServerEnv = {
-
-};
-
-declare type IrisServerContext = {
- env: IrisServerEnv;
- exec: ExecutionContext;
-};
-
-declare type IrisServerAppTypes = {
- ctx: IrisServerContext;
-};
\ No newline at end of file
diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts
deleted file mode 100644
index f52d620..0000000
--- a/packages/server/src/server.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-// Iris
-// Copyright (c) jottocraft 2025
-
-import { YggdrasilServer } from "@jottocraft/yggdrasil";
-
-export const IrisServer = new YggdrasilServer<IrisServerAppTypes>();
\ No newline at end of file
diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json
deleted file mode 100644
index 141ac4e..0000000
--- a/packages/server/tsconfig.json
+++ /dev/null
@@ -1,107 +0,0 @@
-{
- "include": [
- "src"
- ],
- "compilerOptions": {
- /* Visit https://aka.ms/tsconfig to read more about this file */
- /* Projects */
- // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
- // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
- // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
- // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
- // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
- // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
- /* Language and Environment */
- "target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
- "lib": [
- "ESNext"
- ], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
- // "jsx": "react-jsx", /* Specify what JSX code is generated. */
- // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
- // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
- // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
- // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
- // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
- // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
- // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
- // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
- // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
- /* Modules */
- // "module": "commonjs", /* Specify what module code is generated. */
- "rootDir": "src",
- "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
- // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
- "paths": {
- "~/*": [
- "./src/*"
- ]
- },
- // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
- // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
- "types": [
- "node",
- "@cloudflare/workers-types"
- ], /* Specify type package names to be included without being referenced in a source file. */
- // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
- // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
- "resolveJsonModule": true, /* Enable importing .json files. */
- // "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
- /* JavaScript Support */
- "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
- // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
- // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
- /* Emit */
- "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
- "declarationMap": true, /* Create sourcemaps for d.ts files. */
- // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
- "sourceMap": true, /* Create source map files for emitted JavaScript files. */
- // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
- // "outDir": "./", /* Specify an output folder for all emitted files. */
- // "removeComments": true, /* Disable emitting comments. */
- "noEmit": true, /* Disable emitting files from a compilation. */
- // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
- // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
- // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
- // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
- // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
- // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
- // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
- // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
- // "newLine": "crlf", /* Set the newline character for emitting files. */
- // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
- // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
- // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
- // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
- // "declarationDir": "./", /* Specify the output directory for generated declaration files. */
- // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
- /* Interop Constraints */
- // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
- // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
- "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
- // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
- "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
- /* Type Checking */
- "strict": true, /* Enable all strict type-checking options. */
- // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
- // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
- // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
- // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
- // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
- // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
- // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
- // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
- "noUnusedLocals": false, /* Enable error reporting when local variables aren't read. */
- "noUnusedParameters": false, /* Raise an error when a function parameter isn't read. */
- // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
- // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
- // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
- // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
- // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
- // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
- // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
- // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
- /* Completeness */
- "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
- "skipLibCheck": true /* Skip type checking all .d.ts files. */
- }
-}
\ No newline at end of file
diff --git a/packages/server/wrangler.toml b/packages/server/wrangler.toml
deleted file mode 100644
index 32dbdf4..0000000
--- a/packages/server/wrangler.toml
+++ /dev/null
@@ -1,7 +0,0 @@
-name = "iris-server"
-main = "src/index.ts"
-compatibility_date = "2025-07-12"
-compatibility_flags = [ "nodejs_compat" ]
-
-account_id = "17174d3fc7098a664b04fb3c4c4ec65c"
-workers_dev = false
\ No newline at end of file
diff --git a/packages/webui/.gitignore b/packages/webui/.gitignore
index 36cd0db..7bd7f37 100644
--- a/packages/webui/.gitignore
+++ b/packages/webui/.gitignore
@@ -11,3 +11,4 @@
.wrangler
.dev.vars*
+src/IRIS_SITE.ts
\ No newline at end of file
diff --git a/packages/webui/package.json b/packages/webui/package.json
index b7f06b6..74f6b9e 100644
--- a/packages/webui/package.json
+++ b/packages/webui/package.json
@@ -13,7 +13,6 @@
"validate": "tsc --noEmit"
},
"dependencies": {
- "@jottocraft/iris-sdk": "workspace:*",
- "iris-plugins": "workspace:*"
+ "@jottocraft/iris-sdk": "workspace:*"
}
}
diff --git a/packages/webui/src/app/components/Form/Field.tsx b/packages/webui/src/app/components/Form/Field.tsx
new file mode 100644
index 0000000..122654c
--- /dev/null
+++ b/packages/webui/src/app/components/Form/Field.tsx
@@ -0,0 +1,162 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import { Button, Dropdown, DropdownItem, Icon, Switch, TextInput } from "@ffxp/react-dom";
+import type { IrisPluginConduitField } from "@jottocraft/iris-sdk";
+
+export function createDefaultFieldData(spec: IrisPluginConduitField, refData?: any, forceRequired?: boolean) {
+ if (spec.optional && !forceRequired) return undefined;
+
+ if (spec.type === "string") {
+ return refData ?? "";
+ } else if (spec.type === "number") {
+ return refData ?? 0;
+ } else if (spec.type === "date") {
+ return refData ?? new Date().toISOString().split("T")[0];
+ } else if (spec.type === "datetime") {
+ return refData ?? new Date().toISOString();
+ } else if (spec.type === "boolean") {
+ return refData ?? false;
+ } else if (spec.type === "dropdown") {
+ return refData ?? spec.options[0].value;
+ } else if (spec.type === "array") {
+ return refData ? refData.map((refItem: any) => createDefaultFieldData(spec.of, refItem)) : [];
+ } else if (spec.type === "object") {
+ const defaultData: any = {};
+ for (const key in spec.struct) {
+ defaultData[key] = createDefaultFieldData(spec.struct[key], refData ? refData[key] : undefined);
+ }
+ return defaultData;
+ }
+}
+
+export default function Field({ path, data, refData, spec, patchFormData, controls }: { path: string; data: any; refData: any; spec: IrisPluginConduitField; patchFormData: (path: string, value: unknown) => void; controls?: React.ReactNode; }) {
+ let fieldInput: React.ReactNode = null;
+
+ if ((refData !== undefined) && (spec.type !== "object")) return null;
+
+ if (data === undefined) {
+ fieldInput = (
+ <Button onClick={() => patchFormData(path, createDefaultFieldData(spec, undefined, true))} iconLeading="add">Set value</Button>
+ );
+ } else if ((spec.type === "string") || (spec.type === "number") || (spec.type === "date") || (spec.type === "datetime")) {
+ fieldInput = (
+ <TextInput
+ onChange={e => {
+ if (spec.type === "string") {
+ patchFormData(path, e.target.value);
+ } else if (spec.type === "number") {
+ patchFormData(path, Number(e.target.value));
+ } else if (spec.type === "date") {
+ patchFormData(path, e.target.value);
+ } else if (spec.type === "datetime") {
+ patchFormData(path, e.target.value + "Z");
+ }
+ }}
+ value={spec.type === "datetime" ? new Date(data).toISOString().split(":").slice(0, -1).join(":") : data}
+ type={spec.type === "string" ? (
+ "text"
+ ) : spec.type === "number" ? (
+ "number"
+ ) : spec.type === "date" ? (
+ "date"
+ ) : spec.type === "datetime" ? (
+ "datetime-local"
+ ) : undefined}
+ />
+ );
+ } else if (spec.type === "boolean") {
+ fieldInput = (
+ <Switch onClick={() => patchFormData(path, !data)} active={data} />
+ );
+ } else if (spec.type === "dropdown") {
+ const selectedOption = spec.options.find(option => option.value === data)!;
+ fieldInput = (
+ <Dropdown menu={(
+ <>
+ {spec.options.map(option => (
+ <DropdownItem onClick={() => patchFormData(path, option.value)} icon={option.icon}>{option.label}</DropdownItem>
+ ))}
+ </>
+ )}>
+ <Button variant="outline" iconLeading={selectedOption.icon} iconTrailing="arrow_drop_down">{selectedOption.label}</Button>
+ </Dropdown>
+ );
+ } else if (spec.type === "array") {
+ fieldInput = (
+ <div className="space-y-6 border border-ui-20 p-4 roundness-xl">
+ {data.map((value: any, i: number) => {
+ return (
+ <Field controls={(
+ <div className="space-x-2">
+ <Button onClick={() => {
+ if (i === 0) return;
+ const newValue = [...data];
+ newValue.splice(i, 1);
+ newValue.splice(i - 1, 0, value);
+ patchFormData(path, newValue);
+ }} iconLeading="arrow_upward" size="small" />
+ <Button onClick={() => {
+ if (i === (data.length - 1)) return;
+ const newValue = [...data];
+ newValue.splice(i, 1);
+ newValue.splice(i + 1, 0, value);
+ patchFormData(path, newValue);
+ }} iconLeading="arrow_downward" size="small" />
+ <Button onClick={() => {
+ const newValue = [...data];
+ newValue.splice(i, 1);
+ patchFormData(path, newValue);
+ }} iconLeading="delete" size="small" />
+ </div>
+ )} path={`${path}[${i}]`} data={value} refData={refData !== undefined ? refData[i] : undefined} spec={spec.of} patchFormData={patchFormData} />
+ );
+ })}
+ <Button iconLeading="add" onClick={() => patchFormData(path, [...data, createDefaultFieldData(spec.of)])}>Add item</Button>
+ </div>
+ );
+ } else if (spec.type === "object") {
+ fieldInput = (
+ <div className="space-y-6 border border-ui-20 p-4 roundness-xl">
+ {Object.keys(spec.struct).map(key => {
+ return (
+ <Field path={`${path}.${key}`} data={data[key]} refData={refData !== undefined ? refData[key] : undefined} spec={spec.struct[key]} patchFormData={patchFormData} />
+ );
+ })}
+ </div>
+ );
+ } else {
+ fieldInput = (
+ <div>
+ <p>Unrecognized field type {spec.type}</p>
+ </div>
+ );
+ }
+
+ return (
+ <div className="space-y-2">
+ {spec.icon || spec.displayName || spec.optional || controls ? (
+ <div className="flex flex-row items-center justify-between">
+ <div className="flex flex-row items-center space-x-2">
+ {spec.icon ? <Icon className="text-lg">{spec.icon}</Icon> : null}
+ {spec.displayName ? <div>{spec.displayName}</div> : null}
+ </div>
+ <div className="flex flex-row items-center space-x-8">
+ {spec.optional && (data !== undefined) ? (
+ <Button onClick={() => patchFormData(path, undefined)} iconLeading="delete" size="small">Clear value</Button>
+ ) : spec.optional ? (
+ <div className="text-xs px-2 py-1 bg-ui-10 roundness">
+ Optional
+ </div>
+ ) : null}
+ {controls}
+ </div>
+ </div>
+ ) : null}
+ {fieldInput}
+ {spec.helpText ? (
+ <div className="text-type-30 text-xs">{spec.helpText}</div>
+ ) : null}
+ </div>
+ );
+}
\ No newline at end of file
diff --git a/packages/webui/src/app/components/Form/index.tsx b/packages/webui/src/app/components/Form/index.tsx
new file mode 100644
index 0000000..b323de0
--- /dev/null
+++ b/packages/webui/src/app/components/Form/index.tsx
@@ -0,0 +1,81 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import type { IrisPluginConduitField } from "@jottocraft/iris-sdk";
+import { Button, Icon } from "@ffxp/react-dom";
+import { useCallback, useMemo, useState } from "react";
+import { createPortal } from "react-dom";
+import set from "lodash/set";
+import clone from "lodash/clone";
+
+import Field, { createDefaultFieldData } from "~/app/components/Form/Field";
+import { usePanelNavigation } from "~/app/data/panels";
+import { usePlugins } from "~/app/data/root";
+import { YggdrasilStatus } from "@jottocraft/yggdrasil";
+
+export default function Form({ ref, onFinished }: { ref: IrisWebuiFormRef; onFinished: () => void; }) {
+ const plugins = usePlugins()!;
+ const panelNavigation = usePanelNavigation() as IrisWebuiPanelNavigationCtxActive;
+ const form = plugins[panelNavigation.pluginId].plugin.forms[ref.id];
+
+ const defaultData = useMemo(() => form.data ? createDefaultFieldData(form.data, ref.data) : undefined, [form, ref]);
+
+ const [formData, setFormData] = useState<{ data: unknown; }>({
+ data: clone(defaultData)
+ });
+
+ const patchFormData = useCallback((path: string, value: unknown) => {
+ set(formData, path, value);
+
+ setFormData({
+ data: formData.data
+ });
+ }, [formData]);
+
+ const [formResult, setFormResult] = useState<YggdrasilStatus<unknown> | string | null>(null);
+ const submitForm = useCallback(() => {
+ (async () => {
+ try {
+ const data = await plugins[panelNavigation.pluginId].client().forms[ref.id](formData.data as any);
+ setFormResult(data);
+ } catch(e) {
+ if (!(e instanceof YggdrasilStatus)) throw e;
+ setFormResult(e);
+ }
+ })();
+ }, [panelNavigation, ref, formData]);
+
+ return createPortal((
+ <div className="w-full h-full py-10 absolute top-0 left-0 z-99 bg-black/90 flex items-center justify-center">
+ <div className="max-h-full overflow-auto w-2xl bg-ui-0 p-4 roundness-xl shadow-xl space-y-8">
+ <div className="flex flex-row items-center justify-between">
+ <div className="flex flex-row items-center text-xl space-x-2 font-medium">
+ {form.icon ? <Icon className="text-2xl">{form.icon}</Icon> : null}
+ <h1>{form.displayName}</h1>
+ </div>
+ <div>
+ <Button variant="borderless" onClick={() => onFinished()} iconLeading="close" size="small" />
+ </div>
+ </div>
+ {formResult ? (
+ <div>
+ <p>{formResult instanceof YggdrasilStatus ? formResult.statusCode : formResult}</p>
+ </div>
+ ) : (
+ <>
+ <div>
+ {form.data ? (
+ <Field path="data" data={formData.data} refData={ref.data} spec={form.data} patchFormData={patchFormData} />
+ ) : (
+ <p>This form doesn't take any input.</p>
+ )}
+ </div>
+ <div>
+ <Button onClick={submitForm} variant="primary">Submit</Button>
+ </div>
+ </>
+ )}
+ </div >
+ </div >
+ ), document.body);
+}
\ No newline at end of file
diff --git a/packages/webui/src/app/components/Panel/index.tsx b/packages/webui/src/app/components/Panel/index.tsx
new file mode 100644
index 0000000..bafc717
--- /dev/null
+++ b/packages/webui/src/app/components/Panel/index.tsx
@@ -0,0 +1,82 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import { Button, Icon } from "@ffxp/react-dom";
+import type { IrisPluginConduit, IrisPluginConduitPanelArgs, IrisPluginConduitPanelLayout } from "@jottocraft/iris-sdk";
+import { useEffect, useState } from "react";
+import { useSearchParams } from "react-router";
+
+import { usePlugins } from "~/app/data/root";
+import PanelSection from "~/app/components/PanelSection";
+import { usePanelNavigation } from "~/app/data/panels";
+import Form from "~/app/components/Form";
+
+export default function Panel() {
+ const panelNavigation = usePanelNavigation()!;
+ const plugins = usePlugins()!;
+ const [activeForm, setActiveForm] = useState<IrisWebuiFormRef | null>(null);
+
+ if (!panelNavigation.activeRef) {
+ return (
+ <p>Please select a panel to open from the sidebar.</p>
+ );
+ }
+
+ if (!panelNavigation.layout) {
+ return (
+ <p>Loading panel {panelNavigation.activeRef.id}...</p>
+ );
+ }
+
+ const plugin = plugins[panelNavigation.pluginId];
+ const layout = panelNavigation.layout;
+
+ return (
+ <>
+ {activeForm ? (
+ <Form ref={activeForm} onFinished={() => setActiveForm(null)} />
+ ) : null}
+ <div className="space-y-8">
+ <div className="flex flex-row items-center space-x-3 text-4xl font-semibold">
+ {layout.icon ? <Icon>{layout.icon}</Icon> : null}
+ <h1>{layout.displayName}</h1>
+ </div>
+ {layout.forms ? (
+ <div>
+ {layout.forms?.map((ref) => {
+ const form = plugin.plugin.forms[ref.id];
+
+ return (
+ <div className="inline-block m-1">
+ <Button onClick={() => setActiveForm(ref)} iconLeading={form.icon}>{form.displayName}</Button>
+ </div>
+ );
+ })}
+ </div>
+ ) : null}
+ {layout.main ? (
+ <div>
+ {layout.main.map(item => (
+ <div className="inline-block space-y-2 m-1 p-4 bg-ui-5 roundness-xl">
+ <div className="flex flex-row items-center space-x-2 text-xl font-medium">
+ {item.icon ? <Icon>{item.icon}</Icon> : null}
+ <div>{item.displayName}</div>
+ </div>
+ <div>
+ <div>{item.value}</div>
+ </div>
+ </div>
+ ))}
+ </div>
+ ) : null}
+ {layout.sections ? (
+ <div>
+ {layout.sections.map(section => (
+ <PanelSection section={section} />
+ ))}
+ </div>
+ ) : null}
+ </div>
+ </>
+ );
+}
\ No newline at end of file
diff --git a/packages/webui/src/app/components/PanelBreadcrumbs.tsx b/packages/webui/src/app/components/PanelBreadcrumbs.tsx
new file mode 100644
index 0000000..1e824a2
--- /dev/null
+++ b/packages/webui/src/app/components/PanelBreadcrumbs.tsx
@@ -0,0 +1,38 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import { Button, Icon, Link } from "@ffxp/react-dom";
+import { Link as RouterLink } from "react-router";
+import { panelRefHref, usePanelNavigation } from "~/app/data/panels";
+
+export default function PanelBreadcrumbs() {
+ const panelNavigation = usePanelNavigation()!;
+
+ if (!panelNavigation.activeRef) return;
+
+ return (
+ <div className="flex-grow flex flex-row items-center border-ui-20 border-2 roundness-lg p-1">
+ <div className="mr-4 space-x-1">
+ <Button onClick={() => panelNavigation.back()} iconLeading="arrow_back" size="small" />
+ <Button onClick={() => panelNavigation.refreshLayout()} iconLeading="refresh" size="small" />
+ </div>
+ {panelNavigation.breadcrumbs.map((breadcrumb, i) => (
+ <div className="flex flex-row items-center space-x-1 text-sm text-type-20">
+ {breadcrumb.icon ? <Icon className="text-base">{breadcrumb.icon}</Icon> : null}
+ <div>
+ <RouterLink to={panelRefHref(panelNavigation.pluginId, breadcrumb.ref)} onClick={(e) => panelNavigation.goToBreadcrumb(i, e)}>
+ <Link>{breadcrumb.displayName}</Link>
+ </RouterLink>
+ </div>
+ <Icon className="text-xl mx-1">keyboard_arrow_right</Icon>
+ </div>
+ ))}
+ {panelNavigation.layout ? (
+ <div className="flex flex-row items-center space-x-1 text-base text-type-0 mr-2">
+ {panelNavigation.layout.icon ? <Icon className="text-lg">{panelNavigation.layout.icon}</Icon> : null}
+ <div className="font-medium">{panelNavigation.layout.displayName}</div>
+ </div>
+ ) : null}
+ </div>
+ );
+}
\ No newline at end of file
diff --git a/packages/webui/src/app/components/PanelNavigator.tsx b/packages/webui/src/app/components/PanelNavigator.tsx
new file mode 100644
index 0000000..16bcb25
--- /dev/null
+++ b/packages/webui/src/app/components/PanelNavigator.tsx
@@ -0,0 +1,94 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import type { IrisPluginConduit, IrisPluginConduitPanelLayout } from "@jottocraft/iris-sdk";
+import { useCallback, useEffect, useState } from "react";
+import { useNavigate } from "react-router";
+
+import { panelNavigationCtx, panelRefHref } from "~/app/data/panels";
+import { usePlugins } from "~/app/data/root";
+
+export default function PanelNavigator({ children }: { children?: React.ReactNode; }) {
+ const routerNavigate = useNavigate();
+ const plugins = usePlugins()!;
+
+ const [pluginId, setPluginId] = useState<string | null>(null);
+ const [activeRef, setActiveRef] = useState<IrisWebuiPanelRef | null>(null);
+ const [layout, setLayout] = useState<IrisPluginConduitPanelLayout<IrisPluginConduit> | null>(null);
+ const [breadcrumbs, setBreadcrumbs] = useState<IrisWebuiPanelBreadcrumb[]>([]);
+
+ useEffect(() => {
+ if (!activeRef) return;
+ routerNavigate(panelRefHref(pluginId!, activeRef));
+ }, [pluginId, activeRef, routerNavigate]);
+
+ const fetchLayout = useCallback(() => {
+ (async () => {
+ const data = await plugins[pluginId!].client().panels[activeRef!.id]((activeRef!.args ?? {}) as { [key: string]: string; });
+ setLayout(data);
+ })();
+ }, [plugins, pluginId, activeRef]);
+
+ useEffect(() => {
+ if (!activeRef) return;
+
+ fetchLayout();
+ }, [activeRef, fetchLayout]);
+
+ const navigate = useCallback((ref: IrisWebuiPanelRef, e?: React.MouseEvent) => {
+ e?.preventDefault();
+
+ setBreadcrumbs([...breadcrumbs, {
+ displayName: layout?.displayName ?? activeRef!.id,
+ icon: layout?.icon ?? undefined,
+ ref: activeRef!
+ }]);
+ setActiveRef(ref);
+ }, [activeRef, layout, breadcrumbs]);
+
+ const back = useCallback((e?: React.MouseEvent) => {
+ e?.preventDefault();
+ if (!breadcrumbs.length) return;
+
+ setActiveRef(breadcrumbs[breadcrumbs.length - 1].ref);
+ setBreadcrumbs(breadcrumbs.slice(0, -1));
+ }, [breadcrumbs]);
+
+ const goToBreadcrumb = useCallback((index: number, e?: React.MouseEvent) => {
+ e?.preventDefault();
+ if (!breadcrumbs.length) return;
+
+ setActiveRef(breadcrumbs[index].ref);
+ setBreadcrumbs(breadcrumbs.slice(0, index));
+ }, [breadcrumbs]);
+
+ const startNavigation = useCallback((pluginId: string, ref: IrisWebuiPanelRef, e?: React.MouseEvent) => {
+ setPluginId(pluginId);
+ setActiveRef(ref);
+ setBreadcrumbs([]);
+ }, []);
+
+ const refreshLayout = useCallback(() => {
+ setLayout(null);
+ fetchLayout();
+ }, [fetchLayout]);
+
+ return (
+ <panelNavigationCtx.Provider value={activeRef ? {
+ activeRef,
+ layout,
+ pluginId: pluginId!,
+ breadcrumbs,
+ navigate,
+ back,
+ goToBreadcrumb,
+ startNavigation,
+ refreshLayout
+ } : {
+ activeRef,
+ startNavigation
+ }}>
+ {children}
+ </panelNavigationCtx.Provider>
+ );
+}
\ No newline at end of file
diff --git a/packages/webui/src/app/components/PanelSection/PanelSectionProperties.tsx b/packages/webui/src/app/components/PanelSection/PanelSectionProperties.tsx
new file mode 100644
index 0000000..1668d1c
--- /dev/null
+++ b/packages/webui/src/app/components/PanelSection/PanelSectionProperties.tsx
@@ -0,0 +1,10 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import type { IrisPluginConduit, IrisPluginConduitPanelLayoutSectionProperties } from "@jottocraft/iris-sdk";
+
+export default function PanelSectionProperties({ section }: { section: IrisPluginConduitPanelLayoutSectionProperties<IrisPluginConduit>; }) {
+ return (
+ <p>TODO</p>
+ );
+}
\ No newline at end of file
diff --git a/packages/webui/src/app/components/PanelSection/PanelSectionRef.tsx b/packages/webui/src/app/components/PanelSection/PanelSectionRef.tsx
new file mode 100644
index 0000000..c889178
--- /dev/null
+++ b/packages/webui/src/app/components/PanelSection/PanelSectionRef.tsx
@@ -0,0 +1,10 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import type { IrisPluginConduit, IrisPluginConduitPanelLayoutSectionRef } from "@jottocraft/iris-sdk";
+
+export default function PanelSectionRef({ section }: { section: IrisPluginConduitPanelLayoutSectionRef<IrisPluginConduit>; }) {
+ return (
+ <p>TODO</p>
+ );
+}
\ No newline at end of file
diff --git a/packages/webui/src/app/components/PanelSection/PanelSectionTable.tsx b/packages/webui/src/app/components/PanelSection/PanelSectionTable.tsx
new file mode 100644
index 0000000..26229e4
--- /dev/null
+++ b/packages/webui/src/app/components/PanelSection/PanelSectionTable.tsx
@@ -0,0 +1,46 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import type { IrisPluginConduit, IrisPluginConduitPanelLayoutSectionTable } from "@jottocraft/iris-sdk";
+import { Link, useParams } from "react-router";
+
+import { panelRefHref, usePanelNavigation } from "~/app/data/panels";
+
+export default function PanelSectionTable({ section }: { section: IrisPluginConduitPanelLayoutSectionTable<IrisPluginConduit>; }) {
+ const params = useParams();
+ const panelNavigation = usePanelNavigation() as IrisWebuiPanelNavigationCtxActive;
+
+ return (
+ <div>
+ <table>
+ <thead>
+ <tr>
+ {section.columns.map(column => (
+ <th>
+ {column.displayName}
+ </th>
+ ))}
+ </tr>
+ </thead>
+ <tbody>
+ {section.rows.map(row => (
+ <tr>
+ {section.columns.map(column => {
+ const link = row.links?.[column.key];
+ return (
+ <td>
+ {link ? (
+ <Link to={panelRefHref(panelNavigation.pluginId, link)} onClick={(e) => panelNavigation.navigate(link, e)}>{row.vals[column.key] ?? null}</Link>
+ ) : (
+ row.vals[column.key] ?? null
+ )}
+ </td>
+ );
+ })}
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ );
+}
\ No newline at end of file
diff --git a/packages/webui/src/app/components/PanelSection/index.tsx b/packages/webui/src/app/components/PanelSection/index.tsx
new file mode 100644
index 0000000..af483f0
--- /dev/null
+++ b/packages/webui/src/app/components/PanelSection/index.tsx
@@ -0,0 +1,26 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import type { IrisPluginConduit, IrisPluginConduitPanelLayoutSection } from "@jottocraft/iris-sdk";
+
+import PanelSectionProperties from "~/app/components/PanelSection/PanelSectionProperties";
+import PanelSectionTable from "~/app/components/PanelSection/PanelSectionTable";
+import PanelSectionRef from "~/app/components/PanelSection/PanelSectionRef";
+
+export default function PanelSection({ section }: { section: IrisPluginConduitPanelLayoutSection<IrisPluginConduit>; }) {
+ if (section.type === "properties") {
+ return (
+ <PanelSectionProperties section={section} />
+ );
+ } else if (section.type === "table") {
+ return (
+ <PanelSectionTable section={section} />
+ );
+ } else if (section.type === "ref") {
+ return (
+ <PanelSectionRef section={section} />
+ );
+ } else {
+ throw new Error("Unrecognized panel layout section type " + (section as any).type);
+ }
+}
\ No newline at end of file
diff --git a/packages/webui/src/app/components/Shell.tsx b/packages/webui/src/app/components/Shell.tsx
new file mode 100644
index 0000000..62267db
--- /dev/null
+++ b/packages/webui/src/app/components/Shell.tsx
@@ -0,0 +1,111 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import { Button, NavbarLayout, NavbarTitle, SidebarHeader, SidebarItem, SidebarItemGroup, SidebarItems, SidebarLayout, SidebarTitle, useThemeEngine } from "@ffxp/react-dom";
+import { useEffect } from "react";
+import { Link, Outlet, useLocation, useParams, useSearchParams } from "react-router";
+
+import IRIS_SITE from "~/IRIS_SITE";
+import { useAuth, usePlugins } from "~/app/data/root";
+import { isAtPanel, panelRefHref, usePanelNavigation } from "~/app/data/panels";
+import PanelNavigator from "~/app/components/PanelNavigator";
+import PanelBreadcrumbs from "~/app/components/PanelBreadcrumbs";
+
+function ShellLayout({ children }: { children?: React.ReactNode; }) {
+ const theme = useThemeEngine();
+ const plugins = usePlugins()!;
+ const auth = useAuth();
+ const panelNavigation = usePanelNavigation()!;
+ const routerParams = useParams();
+ const [searchParams] = useSearchParams();
+
+ useEffect(() => {
+ if (!panelNavigation.activeRef && (routerParams.pluginId && routerParams.panelId)) {
+ const args: { [key: string]: string } = {};
+ for (const [k, v] of searchParams.entries()) args[k] = v;
+
+ panelNavigation.startNavigation(routerParams.pluginId, {
+ id: routerParams.panelId,
+ args
+ });
+ }
+ }, [routerParams, searchParams, panelNavigation]);
+
+ return (
+ <NavbarLayout left={(
+ <div className="flex-grow flex flex-row items-center space-x-8">
+ <Link to="/">
+ <NavbarTitle logo="/webui/assets/favicon.png">
+ Iris
+ </NavbarTitle>
+ </Link>
+ <PanelBreadcrumbs />
+ </div>
+ )}
+ right={(
+ <>
+ <Button onClick={() => {
+ if (window.confirm("Do you want to sign out?")) {
+ window.localStorage.removeItem("jwt");
+ window.location.reload();
+ }
+ }}>
+ Signed in as {auth?.claims.email as string}
+ </Button>
+ </>
+ )}>
+ <SidebarLayout sidebar={(
+ <>
+ <SidebarHeader>
+ {IRIS_SITE.brand.logo ? (
+ <img className="w-full h-10 object-contain" src={theme.config.colors.dark ? IRIS_SITE.brand.logo.light : IRIS_SITE.brand.logo.dark} />
+ ) : (
+ <SidebarTitle>{IRIS_SITE.brand.name}</SidebarTitle>
+ )}
+ </SidebarHeader>
+ <SidebarItems>
+ {IRIS_SITE.plugins.map(({ id }) => {
+ const plugin = plugins[id];
+ return (
+ <SidebarItemGroup title={plugin.plugin.displayName} icon={plugin.plugin.icon}>
+ {plugin.plugin.navigation.map(item => (
+ <Link to={panelRefHref(id, item.panel)} onClick={e => panelNavigation.startNavigation(id, item.panel, e)}>
+ <SidebarItem active={(panelNavigation.activeRef && (panelNavigation.pluginId === id)) ? isAtPanel(panelNavigation.breadcrumbs[0]?.ref ?? panelNavigation.activeRef, item.panel) : false} icon={item.icon}>{item.displayName}</SidebarItem>
+ </Link>
+ ))}
+ </SidebarItemGroup>
+ );
+ })}
+ </SidebarItems>
+ </>
+ )}>
+ {children}
+ </SidebarLayout>
+ </NavbarLayout>
+ );
+}
+
+export default function Shell({ children }: { children?: React.ReactNode; }) {
+ const location = useLocation();
+ const plugins = usePlugins();
+
+ if (location.pathname === "/login") {
+ return (
+ <Outlet />
+ );
+ }
+
+ if (!plugins) {
+ return (
+ <p>Loading plugins...</p>
+ );
+ }
+
+ return (
+ <PanelNavigator>
+ <ShellLayout>
+ {children}
+ </ShellLayout>
+ </PanelNavigator>
+ );
+}
\ No newline at end of file
diff --git a/packages/webui/src/app/data/panels.ts b/packages/webui/src/app/data/panels.ts
new file mode 100644
index 0000000..26e99f0
--- /dev/null
+++ b/packages/webui/src/app/data/panels.ts
@@ -0,0 +1,28 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import { createContext, useContext } from "react";
+import isEqual from "lodash/isEqual";
+
+export const panelNavigationCtx = createContext<IrisWebuiPanelNavigationCtx | null>(null);
+export function usePanelNavigation() {
+ return useContext(panelNavigationCtx);
+}
+
+export function panelRefHref(pluginId: string, ref: IrisWebuiPanelRef) {
+ let linkHref: string = `/p/${pluginId}/${ref.id}`;
+ if (ref.args) {
+ const linkParams = new URLSearchParams();
+ for (const arg in ref.args) {
+ if (ref.args[arg]) linkParams.set(arg, ref.args[arg]);
+ }
+ linkHref = linkHref + "?" + linkParams.toString();
+ }
+
+ return linkHref;
+}
+
+export function isAtPanel(activeRef: IrisWebuiPanelRef, desiredRef: IrisWebuiPanelRef) {
+ if (activeRef.id !== desiredRef.id) return false;
+ return isEqual(activeRef.args ?? {}, desiredRef.args ?? {});
+}
\ No newline at end of file
diff --git a/packages/webui/src/app/data/root.ts b/packages/webui/src/app/data/root.ts
new file mode 100644
index 0000000..0697087
--- /dev/null
+++ b/packages/webui/src/app/data/root.ts
@@ -0,0 +1,15 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import type { IrisAuthCtx, IrisPluginCtx } from "@jottocraft/iris-sdk";
+import { createContext, useContext } from "react";
+
+export const pluginContext = createContext<IrisPluginCtx | null>(null);
+export function usePlugins() {
+ return useContext(pluginContext);
+}
+
+export const authContext = createContext<IrisAuthCtx | null>(null);
+export function useAuth() {
+ return useContext(authContext);
+}
\ No newline at end of file
diff --git a/packages/webui/src/app/root.tsx b/packages/webui/src/app/root.tsx
index abab211..c96602f 100644
--- a/packages/webui/src/app/root.tsx
+++ b/packages/webui/src/app/root.tsx
@@ -2,6 +2,9 @@
// Copyright (c) jottocraft 2025
import { BodyWrapper, Container, HeadWrapper, HTMLWrapper, Link, Root, ThemeEngine } from "@ffxp/react-dom";
+import type { IrisAuthCtx, IrisPluginConduit, IrisPluginConduitConduitServerTypes, IrisPluginCtx } from "@jottocraft/iris-sdk";
+import { createConduitClientSideClient } from "@jottocraft/yggdrasil";
+import { useEffect, useState } from "react";
import {
isRouteErrorResponse,
Links,
@@ -9,12 +12,88 @@
Scripts,
ScrollRestoration,
useLoaderData,
+ useLocation,
type LoaderFunction,
} from "react-router";
import "~/app/app.css";
+import Shell from "~/app/components/Shell";
+import { authContext, pluginContext } from "~/app/data/root";
+import IRIS_SITE from "~/IRIS_SITE";
export default function App() {
+ const location = useLocation();
+
+ const [auth, setAuth] = useState<IrisAuthCtx | null>(null);
+ useEffect(() => {
+ if (location.pathname === "/login") {
+ return;
+ }
+
+ if (!auth) {
+ let jwt = window.localStorage.getItem("jwt");
+ if (jwt) {
+ const jwtData = JSON.parse(atob(jwt.split(".")[1]));
+ if (!jwtData.exp || (new Date().getTime() > (jwtData.exp * 1000))) {
+ jwt = null;
+ } else {
+ setAuth({
+ jwt,
+ claims: jwtData
+ });
+ }
+ }
+
+ if (!jwt) {
+ const redirectUrl = new URL(window.location.origin);
+ redirectUrl.pathname = "/webui/login";
+
+ const nonce = crypto.randomUUID();
+ window.localStorage.setItem("nonce", JSON.stringify({
+ nonce,
+ // Expires in 30 minutes
+ useBefore: new Date().getTime() + 30 * 60 * 1000
+ } satisfies IrisWebuiNonceStorage));
+
+ const authUrl = new URL(IRIS_SITE.auth.authUrl);
+ authUrl.searchParams.set("client_id", IRIS_SITE.auth.clientId);
+ authUrl.searchParams.set("redirect_uri", redirectUrl.href);
+ authUrl.searchParams.set("response_type", "id_token");
+ authUrl.searchParams.set("scope", IRIS_SITE.auth.scope ?? "openid email profile");
+ authUrl.searchParams.set("nonce", nonce);
+ window.location.href = authUrl.href;
+ }
+ }
+ }, [auth, location.pathname]);
+
+ const [plugins, setPlugins] = useState<null | IrisPluginCtx>(null);
+ useEffect(() => {
+ if (!auth) return;
+
+ const plugins: IrisPluginCtx = {};
+ Promise.all(IRIS_SITE.plugins.map(async (sitePlugin) => {
+ if (sitePlugin.type === "conduit") {
+ const client = createConduitClientSideClient<IrisPluginConduitConduitServerTypes<IrisPluginConduit>>({
+ jwt: auth.jwt
+ }, {
+ baseURL: sitePlugin.baseUrl
+ });
+
+ const plugin = await client().plugin();
+
+ plugins[sitePlugin.id] = {
+ type: "conduit",
+ plugin,
+ client
+ };
+ } else {
+ throw new Error("Unexpected plugin type " + sitePlugin.type);
+ }
+ })).then(() => {
+ setPlugins(plugins);
+ }).catch(e => { throw e; });
+ }, [auth]);
+
return (
<Root>
<ThemeEngine plugins={[
@@ -53,7 +132,13 @@
<Links />
</HeadWrapper>
<BodyWrapper>
- <Outlet />
+ <authContext.Provider value={auth}>
+ <pluginContext.Provider value={plugins}>
+ <Shell>
+ <Outlet />
+ </Shell>
+ </pluginContext.Provider>
+ </authContext.Provider>
<ScrollRestoration />
<Scripts />
</BodyWrapper>
diff --git a/packages/webui/src/app/routes/login.tsx b/packages/webui/src/app/routes/login.tsx
new file mode 100644
index 0000000..f63ce03
--- /dev/null
+++ b/packages/webui/src/app/routes/login.tsx
@@ -0,0 +1,68 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import type { JWTPayload } from "jose";
+import { useEffect, useState } from "react";
+
+import IRIS_SITE from "~/IRIS_SITE";
+
+export default function Login() {
+ const [error, setError] = useState<null | string>(null);
+ useEffect(() => {
+ const nonce = window.localStorage.getItem("nonce");
+ if (!nonce) {
+ setError("Missing auth state");
+ return;
+ }
+
+ const nonceData = JSON.parse(nonce) as IrisWebuiNonceStorage;
+ if (new Date().getTime() > nonceData.useBefore) {
+ setError("Auth state has expired");
+ return;
+ }
+
+ if (!window.location.hash.slice(1)) {
+ setError("Missing auth parameters");
+ return;
+ }
+
+ const hashParams = new URLSearchParams(window.location.hash.slice(1));
+ const jwt = hashParams.get("id_token");
+ if (!jwt) {
+ setError("Missing required id_token parameter");
+ return;
+ }
+
+ const jwtData = JSON.parse(atob(jwt.split(".")[1])) as JWTPayload;
+ if ((jwtData.aud !== IRIS_SITE.auth.clientId) ||
+ (jwtData.iss !== IRIS_SITE.auth.iss) ||
+ !jwtData.nbf ||
+ ((jwtData.nbf * 1000) > new Date().getTime()) ||
+ !jwtData.exp ||
+ ((jwtData.exp * 1000) < new Date().getTime()) ||
+ (jwtData.nonce !== nonceData.nonce)) {
+ setError("Invalid or expired id_token");
+ return;
+ }
+
+ const webuiUrl = new URL(window.location.origin);
+ webuiUrl.pathname = "/webui";
+
+ window.localStorage.removeItem("nonce");
+ window.localStorage.setItem("jwt", jwt);
+ window.location.href = webuiUrl.href;
+ }, []);
+
+ return (
+ <div>
+ {error ? (
+ <div>
+ <h1>Authentication error</h1>
+ <p>{error}</p>
+ </div>
+ ) : (
+ <p>Signing in...</p>
+ )}
+ </div>
+ );
+}
\ No newline at end of file
diff --git a/packages/webui/src/app/routes/p/$.tsx b/packages/webui/src/app/routes/p/$.tsx
new file mode 100644
index 0000000..1837210
--- /dev/null
+++ b/packages/webui/src/app/routes/p/$.tsx
@@ -0,0 +1,10 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+export default function Home() {
+ return (
+ <div>
+ <p>Hello plugin!</p>
+ </div>
+ );
+}
\ No newline at end of file
diff --git a/packages/webui/src/app/routes/p/$pluginId/$panelId.tsx b/packages/webui/src/app/routes/p/$pluginId/$panelId.tsx
new file mode 100644
index 0000000..76e2f75
--- /dev/null
+++ b/packages/webui/src/app/routes/p/$pluginId/$panelId.tsx
@@ -0,0 +1,15 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import { useState } from "react";
+import { useParams } from "react-router";
+
+import Panel from "~/app/components/Panel";
+
+export default function PanelRoute() {
+ return (
+ <div className="py-12">
+ <Panel />
+ </div >
+ );
+}
\ No newline at end of file
diff --git a/packages/webui/src/webui.d.ts b/packages/webui/src/webui.d.ts
index dc05a7e..869b1fc 100644
--- a/packages/webui/src/webui.d.ts
+++ b/packages/webui/src/webui.d.ts
@@ -1,2 +1,42 @@
// Iris
-// Copyright (c) jottocraft 2025
\ No newline at end of file
+// Copyright (c) jottocraft 2025
+
+declare type IrisWebuiConfig = {
+ origin: string;
+ site: import("@jottocraft/iris-sdk").IrisSiteConfig;
+};
+
+declare type IrisWebuiNonceStorage = {
+ nonce: string;
+ useBefore: number;
+};
+
+declare type IrisWebuiPanelRef = import("@jottocraft/iris-sdk").IrisPluginConduitPanelRef<import("@jottocraft/iris-sdk").IrisPluginConduit>;
+declare type IrisWebuiFormRef = import("@jottocraft/iris-sdk").IrisPluginConduitFormRef<import("@jottocraft/iris-sdk").IrisPluginConduit>;
+
+declare type IrisWebuiPanelNavigationCtx = IrisWebuiPanelNavigationCtxActive | IrisWebuiPanelNavigationCtxInactive;
+
+declare type IrisWebuiPanelNavigationCtxCommon = {
+ startNavigation: (pluginId: string, ref: IrisWebuiPanelRef, e?: React.MouseEvent) => void;
+};
+
+declare type IrisWebuiPanelNavigationCtxActive = IrisWebuiPanelNavigationCtxCommon & {
+ activeRef: IrisWebuiPanelRef;
+ layout: null | import("@jottocraft/iris-sdk").IrisPluginConduitPanelLayout<import("@jottocraft/iris-sdk").IrisPluginConduit>;
+ pluginId: string;
+ breadcrumbs: IrisWebuiPanelBreadcrumb[];
+ navigate: (ref: IrisWebuiPanelRef, e?: React.MouseEvent) => void;
+ back: (e?: React.MouseEvent) => void;
+ goToBreadcrumb: (index: number, e?: React.MouseEvent) => void;
+ refreshLayout: () => void;
+};
+
+declare type IrisWebuiPanelNavigationCtxInactive = IrisWebuiPanelNavigationCtxCommon & {
+ activeRef: null;
+};
+
+declare type IrisWebuiPanelBreadcrumb = {
+ displayName: string;
+ icon?: string;
+ ref: IrisWebuiPanelRef;
+};
\ No newline at end of file