diff --git a/package-lock.json b/package-lock.json
index 82ed0bc..c4e1024 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,7 +8,10 @@
"name": "react-example",
"version": "0.0.0",
"dependencies": {
+ "@astryxdesign/core": "0.3.0",
+ "@astryxdesign/theme-neutral": "0.3.0",
"@google/genai": "^2.4.0",
+ "@stylexjs/stylex": "^0.19.0",
"@tailwindcss/vite": "^4.1.14",
"@tanstack/react-query": "^5.101.0",
"@types/leaflet": "^1.9.21",
@@ -29,8 +32,11 @@
"xlsx": "^0.18.5"
},
"devDependencies": {
+ "@astryxdesign/cli": "^0.3.0",
"@types/express": "^4.17.21",
"@types/node": "^22.14.0",
+ "@types/react": "^19.2.18",
+ "@types/react-dom": "^19.2.4",
"autoprefixer": "^10.4.21",
"esbuild": "^0.25.0",
"tailwindcss": "^4.1.14",
@@ -39,6 +45,83 @@
"vite": "^6.2.3"
}
},
+ "node_modules/@astryxdesign/cli": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@astryxdesign/cli/-/cli-0.3.0.tgz",
+ "integrity": "sha512-iLFJsBlZUb66GskA1r3AmuodW72yu0PhCRAorcGPMuwHbGYn54pXGb/EnnWSdHf3Yuw4E3OuN7r4dY0TYJuYmQ==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "commander": "^12.1.0",
+ "jiti": "^2.7.0",
+ "jscodeshift": "^17.3.0",
+ "zod": "^4.4.3"
+ },
+ "bin": {
+ "astryx": "clients/cli/bin/astryx.mjs",
+ "cli": "clients/cli/bin/astryx.mjs"
+ },
+ "peerDependencies": {
+ "@astryxdesign/charts": "*",
+ "@astryxdesign/core": "*",
+ "@astryxdesign/lab": "*",
+ "@astryxdesign/theme-neutral": "*",
+ "gpt-tokenizer": "^3.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@astryxdesign/charts": {
+ "optional": true
+ },
+ "@astryxdesign/core": {
+ "optional": true
+ },
+ "@astryxdesign/lab": {
+ "optional": true
+ },
+ "@astryxdesign/theme-neutral": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@astryxdesign/core": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@astryxdesign/core/-/core-0.3.0.tgz",
+ "integrity": "sha512-t2hHvR7AcwW0oKrnxCwbdEEE5Ytgy0Ug61DcYAl9BxAuV5z4XL0/kMo5DiPvX9jbDxzysmOa8ClnXqzxouRUVQ==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "intl-messageformat": "^11.2.9"
+ },
+ "peerDependencies": {
+ "@stylexjs/stylex": "^0.19.0",
+ "react": ">=19.0.0",
+ "react-dom": ">=19.0.0"
+ }
+ },
+ "node_modules/@astryxdesign/theme-neutral": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@astryxdesign/theme-neutral/-/theme-neutral-0.3.0.tgz",
+ "integrity": "sha512-CeKRfmTEtww60WZK4pSlW7bheBNyVPpgEliiXXUHRStXgcXOhwiUA3D/cXhMJlEx+VRgVxnzl0hMbWIr42RaVQ==",
+ "license": "MIT",
+ "dependencies": {
+ "lucide-react": "^1.18.0"
+ },
+ "peerDependencies": {
+ "@astryxdesign/core": "0.3.0",
+ "react": ">=19"
+ }
+ },
+ "node_modules/@astryxdesign/theme-neutral/node_modules/lucide-react": {
+ "version": "1.31.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.31.0.tgz",
+ "integrity": "sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -109,6 +192,19 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/helper-annotate-as-pure": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz",
+ "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/helper-compilation-targets": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
@@ -125,6 +221,28 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/helper-create-class-features-plugin": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz",
+ "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.29.7",
+ "@babel/helper-member-expression-to-functions": "^7.29.7",
+ "@babel/helper-optimise-call-expression": "^7.29.7",
+ "@babel/helper-replace-supers": "^7.29.7",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
"node_modules/@babel/helper-globals": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
@@ -134,6 +252,20 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/helper-member-expression-to-functions": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz",
+ "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/helper-module-imports": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
@@ -164,6 +296,19 @@
"@babel/core": "^7.0.0"
}
},
+ "node_modules/@babel/helper-optimise-call-expression": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz",
+ "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/helper-plugin-utils": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
@@ -173,6 +318,38 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/helper-replace-supers": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz",
+ "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-member-expression-to-functions": "^7.29.7",
+ "@babel/helper-optimise-call-expression": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-skip-transparent-expression-wrappers": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz",
+ "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
@@ -228,6 +405,155 @@
"node": ">=6.0.0"
}
},
+ "node_modules/@babel/plugin-syntax-flow": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.29.7.tgz",
+ "integrity": "sha512-ajMX6QPcyomotqwpzhkYGxcK2i/us0rs1Qo9QvUpa+Fca0FTmqrzKrctoIYLMxcOhGZldGT/BAVkRGTWBiR8gQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-jsx": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz",
+ "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-typescript": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz",
+ "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-class-properties": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.29.7.tgz",
+ "integrity": "sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-class-features-plugin": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-flow-strip-types": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.29.7.tgz",
+ "integrity": "sha512-wRHeUjUjCZnMHmiO5bRgjFLcoEh7JyTdByOW11ahhwNa4V0bmeGEaIvt51yq0zQp2yWIpqfxXXPyUP6GFJZHOQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/plugin-syntax-flow": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-modules-commonjs": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz",
+ "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-nullish-coalescing-operator": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.29.7.tgz",
+ "integrity": "sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-optional-chaining": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.29.7.tgz",
+ "integrity": "sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-private-methods": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.29.7.tgz",
+ "integrity": "sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-class-features-plugin": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
"node_modules/@babel/plugin-transform-react-jsx-self": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
@@ -258,6 +584,84 @@
"@babel/core": "^7.0.0-0"
}
},
+ "node_modules/@babel/plugin-transform-typescript": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz",
+ "integrity": "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.29.7",
+ "@babel/helper-create-class-features-plugin": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7",
+ "@babel/plugin-syntax-typescript": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/preset-flow": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/preset-flow/-/preset-flow-7.29.7.tgz",
+ "integrity": "sha512-KYIRV0BuaN68CDdsqFkAD7MU7yipUqQNuNElwATdxaIdpTjhvtY82QvkBJs7zV3Evxj2jFAAZ1iO8nyy0nhjqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
+ "@babel/plugin-transform-flow-strip-types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/preset-typescript": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.29.7.tgz",
+ "integrity": "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
+ "@babel/plugin-syntax-jsx": "^7.29.7",
+ "@babel/plugin-transform-modules-commonjs": "^7.29.7",
+ "@babel/plugin-transform-typescript": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/register": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/register/-/register-7.29.7.tgz",
+ "integrity": "sha512-AMGJoWuES861riy6pcB0fphE1YXybtQnBYQMuIyPv6mKLiosfa79BKTnAOyx215c/3RJPJpdQwoHZ3earVH7AA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "clone-deep": "^4.0.1",
+ "find-cache-dir": "^2.0.0",
+ "make-dir": "^2.1.0",
+ "pirates": "^4.0.6",
+ "source-map-support": "^0.5.16"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
"node_modules/@babel/template": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
@@ -719,6 +1123,27 @@
"node": ">=18"
}
},
+ "node_modules/@formatjs/fast-memoize": {
+ "version": "3.1.7",
+ "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.7.tgz",
+ "integrity": "sha512-zXfhLpvA6T7+efdt9JLbBwZ00tT7NsBMDVnDu8rpHeNNv8KfRZAMo2gkG0k9lK/Nzc//3kJ9pImsfuJxk3KhUA==",
+ "license": "MIT"
+ },
+ "node_modules/@formatjs/icu-messageformat-parser": {
+ "version": "3.5.16",
+ "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.16.tgz",
+ "integrity": "sha512-kl6b/4D56gjGZi4ZewSmvXbalHwjOUI5ogEHPZqw42goeXTTrL7/yuPzvdrvr0QigDtvaOeb+UeMf62jks43Yg==",
+ "license": "MIT",
+ "dependencies": {
+ "@formatjs/icu-skeleton-parser": "2.1.11"
+ }
+ },
+ "node_modules/@formatjs/icu-skeleton-parser": {
+ "version": "2.1.11",
+ "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.11.tgz",
+ "integrity": "sha512-j8cUmOJzVgkHuS0QiQ6ga76UIoLOFSAMWhs7aZJztH3aAdCOAE6vpC8KVvFB4cU10ON0y2/5oOVmPJ43s2lTwA==",
+ "license": "MIT"
+ },
"node_modules/@google/genai": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.7.0.tgz",
@@ -1241,6 +1666,18 @@
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
+ "node_modules/@stylexjs/stylex": {
+ "version": "0.19.0",
+ "resolved": "https://registry.npmjs.org/@stylexjs/stylex/-/stylex-0.19.0.tgz",
+ "integrity": "sha512-CnUFp7YMaDLDeemsWOfJgoC/gKM5P/yBNMcpJaE6ChJmXr7s0DJwSeGTTlHJcqqwN9OW1qGtmARWLFhGZN1pTA==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "css-mediaquery": "^0.1.2",
+ "invariant": "^2.2.4",
+ "styleq": "0.2.1"
+ }
+ },
"node_modules/@tailwindcss/node": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz",
@@ -1733,6 +2170,27 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/react": {
+ "version": "19.2.18",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
+ "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
+ "devOptional": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.4",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz",
+ "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
"node_modules/@types/retry": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz",
@@ -1835,6 +2293,19 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
+ "node_modules/ast-types": {
+ "version": "0.16.1",
+ "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz",
+ "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/autoprefixer": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz",
@@ -1992,6 +2463,13 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
+ "node_modules/buffer-from": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -2069,6 +2547,21 @@
"integrity": "sha512-j7oSibqQdIODNhkI1sEJzHMiPsF43L/GqNbcA+eDDyGM10+x2sH9NW/PK6vM3z0J2tLDKMBcc5ZjVaoRinhuCA==",
"license": "ISC"
},
+ "node_modules/clone-deep": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz",
+ "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-plain-object": "^2.0.4",
+ "kind-of": "^6.0.2",
+ "shallow-clone": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -2087,6 +2580,23 @@
"node": ">=0.8"
}
},
+ "node_modules/commander": {
+ "version": "12.1.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
+ "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/commondir": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
+ "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -2141,6 +2651,19 @@
"node": ">=0.8"
}
},
+ "node_modules/css-mediaquery": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/css-mediaquery/-/css-mediaquery-0.1.2.tgz",
+ "integrity": "sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==",
+ "license": "BSD"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "devOptional": true,
+ "license": "MIT"
+ },
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
@@ -2487,6 +3010,20 @@
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
+ "node_modules/esprima": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
+ "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "bin": {
+ "esparse": "bin/esparse.js",
+ "esvalidate": "bin/esvalidate.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
@@ -2642,6 +3179,57 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
+ "node_modules/find-cache-dir": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-2.1.0.tgz",
+ "integrity": "sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "commondir": "^1.0.1",
+ "make-dir": "^2.0.0",
+ "pkg-dir": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz",
+ "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/flow-estree": {
+ "version": "0.326.0",
+ "resolved": "https://registry.npmjs.org/flow-estree/-/flow-estree-0.326.0.tgz",
+ "integrity": "sha512-43Qv+Ei9qfabhLx8JGEJku4frHGD5zI2EuL73Hs9HrWqPMbTT/DS18Az7bcodGwdQEv1DVIEI2WowkEbMIk4BQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/flow-parser": {
+ "version": "0.326.0",
+ "resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.326.0.tgz",
+ "integrity": "sha512-H/Wqt2SDkQ8GH8wpyAj434zN40zomipZWBbKYlAwQYyNdMIQMKqpXTx82FMnITt2iDQ5zrV80NvSPAgIAafwGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flow-estree": "0.326.0"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
@@ -2857,6 +3445,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/gpt-tokenizer": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/gpt-tokenizer/-/gpt-tokenizer-3.4.0.tgz",
+ "integrity": "sha512-wxFLnhIXTDjYebd9A9pGl3e31ZpSypbpIJSOswbgop5jLte/AsZVDvjlbEuVFlsqZixVKqbcoNmRlFDf6pz/UQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -2942,6 +3538,16 @@
"url": "https://opencollective.com/immer"
}
},
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -2957,6 +3563,25 @@
"node": ">=12"
}
},
+ "node_modules/intl-messageformat": {
+ "version": "11.2.13",
+ "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.2.13.tgz",
+ "integrity": "sha512-JaPaE6TIX+TAS5XLhDUh41geLw4QfBHX4s5pW8Km+L9fVC8HzB9yOuhbh4EMR/F1+8C6b9qk4763Cv+LdOG1kg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@formatjs/fast-memoize": "3.1.7",
+ "@formatjs/icu-messageformat-parser": "3.5.16"
+ }
+ },
+ "node_modules/invariant": {
+ "version": "2.2.4",
+ "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
+ "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.0.0"
+ }
+ },
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -2966,6 +3591,29 @@
"node": ">= 0.10"
}
},
+ "node_modules/is-plain-object": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",
+ "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "isobject": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/isobject": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
+ "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -2981,6 +3629,47 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
+ "node_modules/jscodeshift": {
+ "version": "17.4.0",
+ "resolved": "https://registry.npmjs.org/jscodeshift/-/jscodeshift-17.4.0.tgz",
+ "integrity": "sha512-i3ESKiiTsGynxzTg5BhsZViD0ai72/6SsI1efDZxG6/5KCoElsmquxtyhXK5lpEgoO7MTNpYkjFEdEL97SkBNg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.24.7",
+ "@babel/parser": "^7.24.7",
+ "@babel/plugin-transform-class-properties": "^7.24.7",
+ "@babel/plugin-transform-modules-commonjs": "^7.24.7",
+ "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7",
+ "@babel/plugin-transform-optional-chaining": "^7.24.7",
+ "@babel/plugin-transform-private-methods": "^7.24.7",
+ "@babel/preset-flow": "^7.24.7",
+ "@babel/preset-typescript": "^7.24.7",
+ "@babel/register": "^7.24.6",
+ "flow-parser": "0.*",
+ "graceful-fs": "^4.2.4",
+ "neo-async": "^2.5.0",
+ "picocolors": "^1.0.1",
+ "picomatch": "^4.0.2",
+ "recast": "^0.23.11",
+ "tmp": "^0.2.3",
+ "write-file-atomic": "^5.0.1"
+ },
+ "bin": {
+ "jscodeshift": "bin/jscodeshift.js"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "peerDependencies": {
+ "@babel/preset-env": "^7.1.6"
+ },
+ "peerDependenciesMeta": {
+ "@babel/preset-env": {
+ "optional": true
+ }
+ }
+ },
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -3035,6 +3724,16 @@
"safe-buffer": "^5.0.1"
}
},
+ "node_modules/kind-of": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
+ "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
@@ -3291,12 +3990,38 @@
"url": "https://opencollective.com/parcel"
}
},
+ "node_modules/locate-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz",
+ "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^3.0.0",
+ "path-exists": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -3324,6 +4049,30 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/make-dir": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz",
+ "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pify": "^4.0.1",
+ "semver": "^5.6.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/make-dir/node_modules/semver": {
+ "version": "5.7.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
+ "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver"
+ }
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -3467,6 +4216,13 @@
"node": ">= 0.6"
}
},
+ "node_modules/neo-async": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
+ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
@@ -3538,6 +4294,35 @@
"node": ">= 0.8"
}
},
+ "node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz",
+ "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/p-retry": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz",
@@ -3551,6 +4336,16 @@
"node": ">=8"
}
},
+ "node_modules/p-try": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -3560,6 +4355,16 @@
"node": ">= 0.8"
}
},
+ "node_modules/path-exists": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz",
+ "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
@@ -3585,6 +4390,39 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/pify": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz",
+ "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/pirates": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
+ "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/pkg-dir": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz",
+ "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "find-up": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
@@ -3834,6 +4672,23 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/recast": {
+ "version": "0.23.21",
+ "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.21.tgz",
+ "integrity": "sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ast-types": "^0.16.1",
+ "esprima": "~4.0.0",
+ "source-map": "~0.6.1",
+ "tiny-invariant": "^1.3.3",
+ "tslib": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 4"
+ }
+ },
"node_modules/recharts": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
@@ -4046,6 +4901,19 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
+ "node_modules/shallow-clone": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz",
+ "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "kind-of": "^6.0.2"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
@@ -4118,6 +4986,29 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -4127,6 +5018,17 @@
"node": ">=0.10.0"
}
},
+ "node_modules/source-map-support": {
+ "version": "0.5.21",
+ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
+ "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "buffer-from": "^1.0.0",
+ "source-map": "^0.6.0"
+ }
+ },
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
@@ -4148,6 +5050,12 @@
"node": ">= 0.8"
}
},
+ "node_modules/styleq": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/styleq/-/styleq-0.2.1.tgz",
+ "integrity": "sha512-L0TR0NQb+X4/ktDEKmjWyp27gla+LUYi/by5k5SjKXf6/pvZP7wbwEB5J+tqxdFVPgzbsuz+d4RTScO/QZquBw==",
+ "license": "MIT"
+ },
"node_modules/tailwindcss": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
@@ -4189,6 +5097,16 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
+ "node_modules/tmp": {
+ "version": "0.2.7",
+ "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
+ "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.14"
+ }
+ },
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -4904,6 +5822,20 @@
"node": ">=0.8"
}
},
+ "node_modules/write-file-atomic": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz",
+ "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "imurmurhash": "^0.1.4",
+ "signal-exit": "^4.0.1"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
@@ -4951,6 +5883,16 @@
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"license": "ISC"
+ },
+ "node_modules/zod": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
}
}
}
diff --git a/package.json b/package.json
index 2852268..7052be5 100644
--- a/package.json
+++ b/package.json
@@ -11,7 +11,10 @@
"lint": "tsc --noEmit"
},
"dependencies": {
+ "@astryxdesign/core": "0.3.0",
+ "@astryxdesign/theme-neutral": "0.3.0",
"@google/genai": "^2.4.0",
+ "@stylexjs/stylex": "^0.19.0",
"@tailwindcss/vite": "^4.1.14",
"@tanstack/react-query": "^5.101.0",
"@types/leaflet": "^1.9.21",
@@ -32,8 +35,11 @@
"xlsx": "^0.18.5"
},
"devDependencies": {
+ "@astryxdesign/cli": "^0.3.0",
"@types/express": "^4.17.21",
"@types/node": "^22.14.0",
+ "@types/react": "^19.2.18",
+ "@types/react-dom": "^19.2.4",
"autoprefixer": "^10.4.21",
"esbuild": "^0.25.0",
"tailwindcss": "^4.1.14",
diff --git a/src/App.tsx b/src/App.tsx
index 1fbb9ef..b8c7d47 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -5,6 +5,9 @@
import React, { useState, useEffect } from 'react';
import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
+import PosStaffPanelAstryx from './components/spike/PosStaffPanelAstryx'; // PHASE 1 SPIKE — throwaway
+import ThemeShowcase from './components/spike/ThemeShowcase'; // PHASE 2 gate — throwaway
+import ConsoleUiProbe from './components/spike/ConsoleUiProbe'; // PHASE 3 verification — throwaway
import {
Network,
Truck,
@@ -560,6 +563,14 @@ export default function App() {
if (authRole === null) {
return (
+ {/* PHASE 1 SPIKE — throwaway, delete with the spike folder. Sits above
+ the auth gate only so the probe is reachable without a login. */}
+ } />
+ {/* PHASE 2 gate — the stock Astryx showcase rendered through the Nearle
+ theme. This is the acceptance test for the palette: every component
+ on one page, in whichever mode the OS is set to. */}
+ } />
+ } />
} />
} />
diff --git a/src/components/AdminConsole.tsx b/src/components/AdminConsole.tsx
index 5996292..a9d2178 100644
--- a/src/components/AdminConsole.tsx
+++ b/src/components/AdminConsole.tsx
@@ -33,8 +33,13 @@ import {
import { FIESTA_TENANT_ID, str as fstr, num as fnum } from '../services/fiestaApi';
import StoreQRView from './StoreQRView';
import AddressAutocomplete, { type AddressResult } from './AddressAutocomplete';
+import { useNotify } from './Toasts';
export default function AdminConsole({ activeTab: propActiveTab, showHeader = true, onBack, tenantId }: { activeTab?: 'tenant' | 'store' | 'rider', showHeader?: boolean, onBack?: () => void, tenantId?: number }) {
+ // PHASE 6 — replaces alert(). A native alert freezes the whole browser
+ // until dismissed; a toast does not, and errors stay until read.
+ const { notifyOk, notifyFail } = useNotify();
+
const [activeTab, setActiveTab] = useState<'tenant' | 'store' | 'rider'>(propActiveTab || 'tenant');
useEffect(() => {
@@ -171,7 +176,7 @@ export default function AdminConsole({ activeTab: propActiveTab, showHeader = tr
const handleTenantSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!tenantForm.tenantname || !tenantForm.companyname || !tenantForm.primarycontact || !tenantForm.primaryemail || !tenantForm.locationname) {
- alert('Kindly fill in all required fields.');
+ notifyFail('Kindly fill in all required fields.');
return;
}
try {
@@ -197,16 +202,16 @@ export default function AdminConsole({ activeTab: propActiveTab, showHeader = tr
},
});
setTenantSuccess(res);
- alert('Tenant Onboarded successfully!');
+ notifyOk('Tenant onboarded.');
} catch (err: any) {
- alert(err.message || 'Failed to onboard tenant.');
+ notifyFail(err, 'Failed to onboard tenant.');
}
};
const handleStoreSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!storeForm.locationname) {
- alert('Location Name is required.');
+ notifyFail('Location name is required.');
return;
}
try {
@@ -221,16 +226,16 @@ export default function AdminConsole({ activeTab: propActiveTab, showHeader = tr
roleid: 4,
});
setStoreSuccess(res);
- alert('Store Location created successfully!');
+ notifyOk('Store location created.');
} catch (err: any) {
- alert(err.message || 'Failed to create store location.');
+ notifyFail(err, 'Failed to create store location.');
}
};
const handleRiderSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!riderForm.firstname || !riderForm.email || !riderForm.contactno || !riderForm.locationid) {
- alert('Please fill out the Rider personal details and assign a store.');
+ notifyFail('Fill out the rider details and assign a store.');
return;
}
try {
@@ -282,9 +287,9 @@ VALUES (${newUserId}, 1, 'Active', NOW());
userid: newUserId,
});
- alert('Rider User created in app_users! SQL Script generated below.');
+ notifyOk('Rider user created. SQL script generated below.');
} catch (err: any) {
- alert(err.message || 'Failed to create rider user.');
+ notifyFail(err, 'Failed to create rider user.');
}
};
@@ -902,7 +907,7 @@ VALUES (${newUserId}, 1, 'Active', NOW());
>
Select Hub location...
{locations.map((loc) => (
- {fstr(loc.locationname)}
+ {fstr(loc.locationname)}
))}
@@ -1261,7 +1266,7 @@ VALUES (${newUserId}, 1, 'Active', NOW());
{tenantsQ.isLoading ? (
Loading tenants...
) : (tenantsQ.data || []).map((t) => (
- {fstr(t.tenantname)} (#{fstr(t.tenantid)})
+ {fstr(t.tenantname)} (#{fstr(t.tenantid)})
))}
@@ -1574,7 +1579,7 @@ VALUES (${newUserId}, 1, 'Active', NOW());
>
Select Hub location...
{locations.map((loc) => (
- {fstr(loc.locationname)}
+ {fstr(loc.locationname)}
))}
diff --git a/src/components/BulkCartDrawer.tsx b/src/components/BulkCartDrawer.tsx
index 5e371ff..a72b833 100644
--- a/src/components/BulkCartDrawer.tsx
+++ b/src/components/BulkCartDrawer.tsx
@@ -2,6 +2,7 @@ import React from 'react';
import { ShoppingCart, X, Package, Trash2, Plus, ArrowRight, Layers } from 'lucide-react';
import { useCompare } from '../contexts/CompareContext';
import { useStoreCatalogue } from '../services/storeCatalogue';
+import { useNotify } from './Toasts';
interface BulkCartDrawerProps {
isOpen: boolean;
@@ -12,13 +13,14 @@ interface BulkCartDrawerProps {
export default function BulkCartDrawer({ isOpen, onClose, onRequestSamples }: BulkCartDrawerProps) {
const { selectedProducts, removeProduct, clearSelection, setIsComparing } = useCompare();
const storeCat = useStoreCatalogue();
+ const { notifyOk } = useNotify();
if (!isOpen) return null;
const handleBulkAdd = () => {
let addedCount = 0;
selectedProducts.forEach(prod => {
- if (prod.verified && !storeCat.has(prod.id)) {
+ if (prod.verified && !storeCat.has(String(prod.id))) {
storeCat.add({
productid: String(prod.id),
name: prod.name,
@@ -43,7 +45,10 @@ export default function BulkCartDrawer({ isOpen, onClose, onRequestSamples }: Bu
addedCount++;
}
});
- alert(addedCount > 0 ? `Successfully added ${addedCount} product(s) to the store catalogue!` : 'Selected products are already in the catalogue or not verified.');
+ // PHASE 6 — "nothing was added" is not an error, it is a result, so it goes
+ // through notifyOk rather than being dressed up as a failure.
+ if (addedCount > 0) notifyOk(`Added ${addedCount} product${addedCount === 1 ? '' : 's'} to the store catalogue.`);
+ else notifyOk('Nothing to add — those products are already in the catalogue, or not verified.');
if (addedCount > 0) onClose();
};
@@ -55,7 +60,8 @@ export default function BulkCartDrawer({ isOpen, onClose, onRequestSamples }: Bu
removedCount++;
}
});
- alert(removedCount > 0 ? `Successfully removed ${removedCount} product(s) from the store catalogue!` : 'None of the selected products were in the catalogue.');
+ if (removedCount > 0) notifyOk(`Removed ${removedCount} product${removedCount === 1 ? '' : 's'} from the store catalogue.`);
+ else notifyOk('Nothing to remove — none of those products were in the catalogue.');
if (removedCount > 0) onClose();
};
diff --git a/src/components/CatalogueBrowser.tsx b/src/components/CatalogueBrowser.tsx
index 2cb3b91..ae2ac52 100644
--- a/src/components/CatalogueBrowser.tsx
+++ b/src/components/CatalogueBrowser.tsx
@@ -12,7 +12,13 @@ import { useStoreCatalogue, usePriceEverywhere } from '../services/storeCatalogu
import { useFiestaTenantLocations, useFiestaProductLocations } from '../services/fiestaQueries';
import { num as fnum } from '../services/fiestaApi';
import ImportProductModal from './ImportProductModal';
+import { useNotify } from './Toasts';
import { CardCarousel } from './CatalogueGallery';
+// PHASE 6 — the Global Catalogue's own controls. This component is rendered by
+// BOTH the admin (via InventoryView) and the super admin, so a change here
+// lands on two logins at once.
+import { TextInput } from '@astryxdesign/core/TextInput';
+import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
interface CatalogueBrowserProps {
tenantid: number;
@@ -87,6 +93,9 @@ export default function CatalogueBrowser({ tenantid, locationid, onClose }: Cata
[tenantCategories],
);
+ // PHASE 6 — replaces alert(), which blocked the whole browser until dismissed.
+ const { notifyOk, notifyFail } = useNotify();
+
const handleImportSubmit = (item: ImportCatalogueProductRequest) => {
importProductMutation.mutate([item], {
onSuccess: () => {
@@ -101,10 +110,14 @@ export default function CatalogueBrowser({ tenantid, locationid, onClose }: Cata
//
// The product now lands in the Admin Catalogue alone. Pricing and
// publishing happen there, in that order.
+ //
+ // PHASE 6 — the toast says where it went. Since the import stopped
+ // publishing, a silent success looked identical to nothing happening:
+ // the product does not appear in any store, which is correct but
+ // indistinguishable from a failure.
+ notifyOk('Imported to your Admin Catalogue. Set a price there to publish it.');
},
- onError: (err: any) => {
- alert(err.message || 'Failed to import product.');
- }
+ onError: (err: any) => notifyFail(err, 'Failed to import product.'),
});
};
@@ -131,21 +144,14 @@ export default function CatalogueBrowser({ tenantid, locationid, onClose }: Cata
) : (
{brandsData.map((b) => (
-
-
- setBrand(brand === b.brand ? undefined : b.brand)}
- className="peer appearance-none w-5 h-5 border-2 border-slate-300 rounded-none checked:border-[#662582] checked:bg-[#662582] transition-colors cursor-pointer"
- />
-
-
-
- {b.brand.replace('brand_', '').toUpperCase()}
-
- {b.product_count}
-
+
+ setBrand(brand === b.brand ? undefined : b.brand)}
+ />
+ {b.product_count}
+
))}
)}
@@ -198,23 +204,16 @@ export default function CatalogueBrowser({ tenantid, locationid, onClose }: Cata
diff --git a/src/components/ConfirmModal.tsx b/src/components/ConfirmModal.tsx
index ecff1ca..d085162 100644
--- a/src/components/ConfirmModal.tsx
+++ b/src/components/ConfirmModal.tsx
@@ -1,5 +1,26 @@
import React from 'react';
-import { AlertTriangle, X } from 'lucide-react';
+import { AlertTriangle } from 'lucide-react';
+import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
+import { Button } from '@astryxdesign/core/Button';
+
+/**
+ * PHASE 5 — rebuilt on Astryx Dialog.
+ *
+ * The props are untouched, so both call sites were left alone. What changed is
+ * everything underneath: this was a hand-rolled `fixed inset-0` overlay, and it
+ * is now a real ``.
+ *
+ * What that buys, none of which the original had:
+ * • Escape closes it (verified — see the `purpose` note below, the first
+ * choice of 'required' silently disabled this)
+ * • focus is trapped inside while open, and returns to the opener on close
+ * • the rest of the page is inert to screen readers
+ * • width comes from the Dialog, so it can no longer be squeezed by the
+ * `max-w-*` collision that once rendered these as ~100px strips
+ *
+ * `isLoading` still guards dismissal: onOpenChange is ignored mid-flight so a
+ * stray Escape cannot close the box while a mutation is in the air.
+ */
interface ConfirmModalProps {
isOpen: boolean;
@@ -24,67 +45,42 @@ export function ConfirmModal({
isDestructive = false,
isLoading = false,
}: ConfirmModalProps) {
- if (!isOpen) return null;
-
return (
- { if (e.target === e.currentTarget && !isLoading) onCancel(); }}
+
{
+ // Ignore every close request while a mutation is running — the original
+ // did this by disabling the buttons and swallowing backdrop clicks.
+ if (!open && !isLoading) onCancel();
+ }}
+ width={400}
+ // 'info', not 'required'. Verified in the browser: 'required' blocks
+ // Escape and backdrop dismissal entirely, which would have been a
+ // regression — the original closed on a backdrop click. Cancelling a
+ // confirmation is the safe direction, so making it harder protects
+ // nothing. 'info' keeps backdrop-dismiss and adds Escape.
+ purpose="info"
>
- e.stopPropagation()}
- >
-
-
- {isDestructive &&
}
-
{title}
-
-
-
-
-
+
: undefined}
+ onOpenChange={(open) => { if (!open && !isLoading) onCancel(); }}
+ />
-
+
{message}
-
-
- {cancelText}
-
-
- {isLoading ? (
-
-
-
-
- ) : (
- confirmText
- )}
-
-
+
+
+
-
+
);
}
+
+export default ConfirmModal;
diff --git a/src/components/InventoryView.tsx b/src/components/InventoryView.tsx
index 39d0c84..6c22151 100644
--- a/src/components/InventoryView.tsx
+++ b/src/components/InventoryView.tsx
@@ -58,6 +58,7 @@ import AwaitingApi from './AwaitingApi';
import { SlideDrawer, Skeleton, TH_STYLE, SURFACE_ALT, TEXT, TEXT_2, TEXT_3, BORDER, BRAND, tint, edge, StatusChip } from './consoleUi';
import FMCGHoverOverlay from './FMCGHoverOverlay';
import CatalogueDetailPanel from './CatalogueDetailPanel';
+import { useNotify } from './Toasts';
import CatalogueBrowser from './CatalogueBrowser';
import OfflineSalesUpload from './OfflineSalesUpload';
import { useCompare } from '../contexts/CompareContext';
@@ -178,6 +179,9 @@ export default function InventoryView({
}, [storeCat.items]);
const { priceEverywhere, isPending: isPricing } = usePriceEverywhere(tenantId);
+ // PHASE 6 — replaces alert(). A native alert blocks the whole browser and
+ // has to be dismissed before anything else can happen; a toast does not.
+ const { notifyOk, notifyFail } = useNotify();
const publishMut = usePublishProduct();
const unpublishMut = useUnpublishProduct();
const [importPrice, setImportPrice] = useState
('');
@@ -734,9 +738,13 @@ export default function InventoryView({
taxpercent: Number(cardImportTax) || 0,
},
{
- onSuccess: () => setAddingPriceProdId(null),
- onError: (err: any) =>
- alert(err?.message || 'Could not publish this product.'),
+ onSuccess: () => {
+ setAddingPriceProdId(null);
+ // Publishing used to succeed in silence — the only
+ // sign it worked was a row changing somewhere below.
+ notifyOk(`${prod.name} is live in every outlet.`);
+ },
+ onError: (err: any) => notifyFail(err, 'Could not publish this product.'),
},
);
}}
@@ -1091,7 +1099,10 @@ export default function InventoryView({
price: Number(importPrice),
taxpercent: Number(drawerTax) || 0,
},
- { onError: (err: any) => alert(err?.message || 'Could not publish this product.') },
+ {
+ onSuccess: () => notifyOk(`${selectedAdminProduct.name} is live in every outlet.`),
+ onError: (err: any) => notifyFail(err, 'Could not publish this product.'),
+ },
);
}}
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl text-xs font-extrabold transition-all bg-[#662582] hover:bg-[#531e6a] text-white shadow-md disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
diff --git a/src/components/NavRail.tsx b/src/components/NavRail.tsx
new file mode 100644
index 0000000..8df7ead
--- /dev/null
+++ b/src/components/NavRail.tsx
@@ -0,0 +1,98 @@
+/**
+ * @license
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * PHASE 4 — the one left rail.
+ *
+ * `SuperAdminSidebar` and `UserStoreSidebar` were 95% the same file. A diff of
+ * the two showed exactly three differences: the route prefix, the item corner
+ * radius, and the fact that the user rail closes itself after a tap on mobile
+ * while the super-admin rail does not. Everything else — the brand-purple rail,
+ * the collapse-to-icons behaviour, the backdrop, the active states — was
+ * duplicated line for line, which is why a fix to one never reached the other.
+ *
+ * Both are now thin wrappers over this. Each keeps its own props and its own
+ * appearance, so no page changed.
+ *
+ * NOT built on Astryx's `SideNav`. That would replace the main navigation of
+ * every role at once, and those screens sit behind a login I cannot reach to
+ * check the result. Deduplicating is safe and verifiable today; restyling the
+ * navigation is not. The swap belongs in a step where someone can look at it.
+ */
+
+import React from 'react';
+import { type LucideIcon } from 'lucide-react';
+import { NavLink } from 'react-router-dom';
+
+export interface NavRailItem {
+ id: string;
+ label: string;
+ icon: LucideIcon;
+}
+
+export interface NavRailProps {
+ items: NavRailItem[];
+ /** Route prefix each item id is appended to, e.g. `/superadmin`. */
+ basePath: string;
+ /** Collapsed → icon-only rail; expanded → labels visible. */
+ isOpen: boolean;
+ onClose: () => void;
+ /**
+ * Item corner radius. The two rails disagreed, so it stays a prop rather than
+ * silently restyling one of them.
+ */
+ itemRadius?: 'none' | 'lg';
+}
+
+export default function NavRail({ items, basePath, isOpen, onClose, itemRadius = 'none' }: NavRailProps) {
+ return (
+ <>
+ {/* Mobile Overlay */}
+ {isOpen && (
+
+ )}
+
+
+
+ {items.map((item) => {
+ const IconComponent = item.icon;
+ return (
+ { if (window.innerWidth < 768) onClose(); }}
+ className={({ isActive }) => `w-full flex items-center py-3 ${itemRadius === 'lg' ? 'rounded-lg' : 'rounded-none'} text-left transition-all duration-200 cursor-pointer ${
+ isOpen ? 'gap-md px-md border-l-4' : 'justify-center px-0'
+ } ${
+ isActive
+ ? 'bg-black/20 text-white font-semibold' + (isOpen ? ' border-white' : '')
+ : 'text-purple-200 hover:bg-white/10 hover:text-white' + (isOpen ? ' border-transparent' : '')
+ }`}
+ >
+ {({ isActive }) => (
+ <>
+
+ {isOpen && {item.label} }
+ >
+ )}
+
+ );
+ })}
+
+
+ >
+ );
+}
diff --git a/src/components/OperationsView.tsx b/src/components/OperationsView.tsx
index 38866bf..e7a25f1 100644
--- a/src/components/OperationsView.tsx
+++ b/src/components/OperationsView.tsx
@@ -30,6 +30,7 @@ import {
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, num as fnum, str as fstr } from '../services/fiestaApi';
import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers';
import AwaitingApi from './AwaitingApi';
+import { useNotify } from './Toasts';
interface OperationsViewProps {
searchQuery: string;
@@ -37,6 +38,10 @@ interface OperationsViewProps {
}
export default function OperationsView({ searchQuery, isCoimbatoreView }: OperationsViewProps) {
+ // PHASE 6 — replaces alert(). A native alert freezes the whole browser
+ // until dismissed; a toast does not, and errors stay until read.
+ const { notifyOk, notifyFail } = useNotify();
+
// Sub-tabs state
const [activeSubTab, setActiveSubTab] = useState<'inventory' | 'catalogue' | 'import'>('inventory');
@@ -111,7 +116,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
const handleAddSku = (e: React.FormEvent) => {
e.preventDefault();
if (!newSku.sku || !newSku.name || !newSku.warehouse) {
- alert('Please fill out all metadata fields before committing.');
+ notifyFail('Fill out all metadata fields before committing.');
return;
}
@@ -143,7 +148,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
const handleExecuteTransfer = (e: React.FormEvent) => {
e.preventDefault();
if (!transferData.sku || !transferData.origin || !transferData.destination) {
- alert('Kindly configure transfer coordinates and SKU.');
+ notifyFail('Configure the transfer coordinates and SKU first.');
return;
}
@@ -162,7 +167,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
})
);
- alert(`Transfer of ${transferData.quantity} units committed successfully. Tracking ID: TRF-${Math.floor(Math.random() * 900000 + 100000)}`);
+ notifyOk(`Transfer of ${transferData.quantity} units committed successfully. Tracking ID: TRF-${Math.floor(Math.random() * 900000 + 100000)}`);
setShowTransferModal(false);
};
@@ -414,7 +419,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
onClick={() => {
const amount = prompt('Enter return item SKU code:');
if (amount) {
- alert(`Returns logged successfully for target SKU code ${amount}. Waiting for physical hub clearance inspection.`);
+ notifyOk(`Returns logged successfully for target SKU code ${amount}. Waiting for physical hub clearance inspection.`);
}
}}
className="bg-[#f8fafc] border border-[#e2e8f0] p-sm rounded-lg flex flex-col items-center gap-xs text-zinc-700 hover:bg-[#faf5ff] hover:text-[#662582] hover:border-[#662582] transition-all cursor-pointer group"
@@ -425,7 +430,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
{
- alert('Generating automated physical audit compliance report draft sheets... Download started background.');
+ notifyOk('Generating the audit compliance report. The download will start shortly.');
}}
className="bg-[#f8fafc] border border-[#e2e8f0] p-sm rounded-lg flex flex-col items-center gap-xs text-zinc-700 hover:bg-[#faf5ff] hover:text-[#662582] hover:border-[#662582] transition-all cursor-pointer group"
>
@@ -473,7 +478,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
verified: false
}
]);
- alert(`${title} added successfully to unreleased draft portfolio.`);
+ notifyOk(`${title} added successfully to unreleased draft portfolio.`);
}
}}
className="bg-[#662582] hover:bg-purple-800 active:bg-purple-900 text-white font-sans text-xs font-semibold px-4 py-2 rounded-lg cursor-pointer transition-colors shadow-sm"
@@ -549,7 +554,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
onClick={() => {
const fileRef = prompt('Enter CSV filename representation path:');
if (fileRef) {
- alert('Uploaded successfully. Metadata schema verification committed.');
+ notifyOk('Uploaded. Metadata schema verified.');
}
}}
className="bg-white border-2 border-dashed border-zinc-300 rounded-xl p-xl flex flex-col items-center justify-center text-center cursor-pointer hover:bg-[#faf5ff]/30 hover:border-[#662582] transition-all duration-200"
@@ -573,7 +578,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
{
e.stopPropagation();
- alert('Template documentation initiated.');
+ notifyOk('Template documentation initiated.');
}}
className="bg-white border border-[#e2e8f0] text-zinc-600 text-xs px-4 py-2 rounded-lg cursor-pointer hover:bg-zinc-50 transition-colors"
>
diff --git a/src/components/OrderDetailsModal.tsx b/src/components/OrderDetailsModal.tsx
index 7e7196e..398913c 100644
--- a/src/components/OrderDetailsModal.tsx
+++ b/src/components/OrderDetailsModal.tsx
@@ -55,7 +55,10 @@ export default function OrderDetailsModal({ order, onClose }: OrderDetailsModalP
const deliveryFee = fnum(order.deliveryfee) || fnum(order.deliverycharge) || 0;
const address = fstr(order.shippingaddress) || fstr(order.address) || 'No address provided';
- const { data: details, isLoading } = useFiestaOrderDetails(order.orderheaderid);
+ // Cast rather than coerce: the hook itself does `orderheaderid as number |
+ // string` internally, and running it through fstr/fnum here would change the
+ // query key for every order that arrives as a number.
+ const { data: details, isLoading } = useFiestaOrderDetails(order.orderheaderid as number | string);
return createPortal(
diff --git a/src/components/PosConsoleView.tsx b/src/components/PosConsoleView.tsx
index 7b003b9..2b13016 100644
--- a/src/components/PosConsoleView.tsx
+++ b/src/components/PosConsoleView.tsx
@@ -69,6 +69,20 @@ import {
type PosTerminal,
} from '../services/posApi';
import PosStaffPanel from './PosStaffPanel';
+// PHASE 6 — POS console tabs on Astryx.
+import { TextInput } from '@astryxdesign/core/TextInput';
+import { Selector } from '@astryxdesign/core/Selector';
+import { Button } from '@astryxdesign/core/Button';
+import { Card as AxCard } from '@astryxdesign/core/Card';
+import { IconButton } from '@astryxdesign/core/IconButton';
+import { Link } from '@astryxdesign/core/Link';
+import {
+ Table as AxTable,
+ TableHeader,
+ TableHeaderCell,
+ TableRow,
+ TableBody,
+} from '@astryxdesign/core/Table';
import {
type PosOutletHealth,
usePosCatalogue,
@@ -320,18 +334,20 @@ export default function PosConsoleView({
))}
- }
onClick={() => {
healthQ.refetch();
summaryQ.refetch();
salesQ.refetch();
}}
- title="Refresh"
- className="inline-flex items-center justify-center rounded-full cursor-pointer transition-colors"
- style={{ width: 30, height: 30, background: tint(BRAND), border: `1.5px solid ${edge(BRAND)}`, color: BRAND }}
- >
-
-
+ />
}
/>
@@ -380,9 +396,13 @@ export default function PosConsoleView({
{pendingTotal} bill{pendingTotal === 1 ? '' : 's'} still sitting on{' '}
{strandedTills} till{strandedTills === 1 ? '' : 's'} and not yet synced.
- setTab('terminals')} className="ml-2 underline font-semibold cursor-pointer" style={{ color: BRAND }}>
- See which
-
+ {/* Reads as a link mid-sentence but performs an in-page action, so
+ it stays a underneath — Link renders as one when given
+ no href, which is the correct element for something that changes
+ a tab rather than navigating. */}
+
+ setTab('terminals')}>See which
+
)}
@@ -983,54 +1003,60 @@ function BillsTab(props: {
)}
-
-
-
-
+
+ setReference(e.target.value)}
+ onChange={(v) => setReference(v)}
onKeyDown={(e) => e.key === 'Enter' && onLookup()}
placeholder="Invoice, order UUID or bill id…"
- className="bg-transparent outline-none text-[12.5px] w-56"
- style={{ color: TEXT }}
+ startIcon={ }
+ // Replaces the separate magnifier button beside the field. The
+ // clear affordance now belongs to the input, and Enter still
+ // runs the lookup exactly as before.
+ hasClear
/>
-
-
-
-
setTerminalFilter(e.target.value)}
- className="rounded-full px-3 py-1.5 text-[12.5px] font-semibold cursor-pointer outline-none"
- style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}`, color: TEXT }}
- >
- All terminals
- {terminalOptions.filter(Boolean).map((t) => (
- {t}
- ))}
-
+
+ setTerminalFilter(String(v ?? ''))}
+ placeholder="All terminals"
+ options={[
+ { value: '', label: 'All terminals' },
+ ...terminalOptions.filter(Boolean).map((t) => ({ value: t, label: t })),
+ ]}
+ />
+
-
setModeFilter(e.target.value)}
- className="rounded-full px-3 py-1.5 text-[12.5px] font-semibold cursor-pointer outline-none"
- style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}`, color: TEXT }}
- >
- All tenders
- {['cash', 'card', 'upi', 'wallet'].map((m) => (
- {m}
- ))}
-
+
+ setModeFilter(String(v ?? ''))}
+ placeholder="All tenders"
+ options={[
+ { value: '', label: 'All tenders' },
+ ...['cash', 'card', 'upi', 'wallet'].map((m) => ({ value: m, label: m })),
+ ]}
+ />
+
-
-
-
+ setCashierFilter(e.target.value)}
- placeholder="Cashier (exact name)"
- className="bg-transparent outline-none text-[12.5px] w-40"
- style={{ color: TEXT }}
+ onChange={(v) => setCashierFilter(v)}
+ placeholder="Exact name"
+ startIcon={ }
+ hasClear
/>
@@ -1071,22 +1097,23 @@ function BillsTab(props: {
Page {pageno + 1} of {pages}
- setPageno(pageno - 1)}
- className="rounded-full px-3 py-1 text-[12px] font-bold cursor-pointer disabled:opacity-40 disabled:cursor-default"
- style={{ background: tint(BRAND), color: BRAND, border: `1px solid ${edge(BRAND)}` }}
- >
- Previous
-
- = pages}
+ />
+ = pages}
onClick={() => setPageno(pageno + 1)}
- className="rounded-full px-3 py-1 text-[12px] font-bold cursor-pointer disabled:opacity-40 disabled:cursor-default"
- style={{ background: tint(BRAND), color: BRAND, border: `1px solid ${edge(BRAND)}` }}
- >
- Next
-
+ />
) : undefined
@@ -1187,17 +1214,20 @@ function BillDetail({ bill, loading, reference }: { bill: PosBill | null; loadin
{/* The drawer clips horizontally, so the line table scrolls inside
its own box rather than being cut off at the panel edge. */}
-
-
-
- Item
- Qty
- Rate
- GST
- Total
-
-
-
+ {/* The bill's line items. Header cells move to Astryx; the rows
+ below stay as the caller's own , so the money formatting
+ and the barcode sub-line are untouched. */}
+
+
+
+ Item
+ Qty
+ Rate
+ GST
+ Total
+
+
+
{items.map((it) => (
@@ -1219,8 +1249,8 @@ function BillDetail({ bill, loading, reference }: { bill: PosBill | null; loadin
{inr(it.linetotal)}
))}
-
-
+
+
) : (
@@ -1460,25 +1490,27 @@ function PosTable({
children: React.ReactNode;
}) {
return (
-
+ // PHASE 6 — one conversion covers the Terminals, Bills and Catalogue tabs,
+ // since all three render through this. The hardcoded `bg-white` is gone
+ // with it: Card takes its surface from the theme, so this is one of the
+ // places that no longer blocks dark mode.
+
-
-
-
+
+
+
{columns.map((c, i) => (
-
- {c.label}
-
+
+ {c.label}
+
))}
-
-
-
- {children}
-
-
+
+
+
{children}
+
{footer}
-
+
);
}
diff --git a/src/components/PosErrorBoundary.tsx b/src/components/PosErrorBoundary.tsx
index 62fd224..f199f85 100644
--- a/src/components/PosErrorBoundary.tsx
+++ b/src/components/PosErrorBoundary.tsx
@@ -19,6 +19,10 @@
import React from 'react';
import { AlertTriangle } from 'lucide-react';
+import { Card as AxCard } from '@astryxdesign/core/Card';
+import { Button } from '@astryxdesign/core/Button';
+import { Heading } from '@astryxdesign/core/Heading';
+import { Text } from '@astryxdesign/core/Text';
interface Props {
children: React.ReactNode;
@@ -29,15 +33,11 @@ interface State {
}
export default class PosErrorBoundary extends React.Component {
- // This project has no `@types/react` installed, so `React.Component`'s
- // inherited members are not typed and `this.props` / `this.setState` do not
- // resolve. (It is the same gap that makes `key` unassignable on a custom
- // component, which is why the codebase wraps keyed elements in fragments.)
- // Re-declared here rather than cast at each use — `declare` emits nothing and
- // keeps the bodies below honest. Delete once the types are added.
- declare props: Props;
- declare setState: (state: State) => void;
-
+ // The `declare props` / `declare setState` workaround that used to sit here
+ // is gone. It existed only because the project had no `@types/react`, so
+ // React.Component's inherited members did not resolve. Phase 0 installed the
+ // types, so the real ones now apply and the shim was actively misleading —
+ // it narrowed setState to a shape React does not actually guarantee.
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
@@ -54,28 +54,30 @@ export default class PosErrorBoundary extends React.Component {
if (!this.state.error) return this.props.children;
return (
+ // PHASE 6 — Card + Button. The hardcoded `bg-white` and `#662582` are
+ // gone, so the fallback follows the theme like everything else. That
+ // matters more here than elsewhere: this is the screen someone sees when
+ // things have already gone wrong, and a white slab on a dark page would
+ // read as a second failure.
-
-
-
+
+
+
+
The POS screen hit a problem
+
+
+ Counter sales are unaffected — bills keep syncing from the tills whatever this
+ page does. Only the display failed.
+
+
+
+ {this.state.error.message}
+
+
this.setState({ error: null })} />
-
- The POS screen hit a problem
-
-
- Counter sales are unaffected — bills keep syncing from the tills whatever this page
- does. Only the display failed.
-
-
- {this.state.error.message}
-
- this.setState({ error: null })}
- className="px-5 py-2.5 bg-[#662582] hover:bg-purple-800 text-white text-sm font-bold rounded-xl cursor-pointer transition-colors shadow-sm"
- >
- Try again
-
-
+
);
}
diff --git a/src/components/PosStaffPanel.tsx b/src/components/PosStaffPanel.tsx
index 8751278..94cd06c 100644
--- a/src/components/PosStaffPanel.tsx
+++ b/src/components/PosStaffPanel.tsx
@@ -20,6 +20,12 @@
*/
import React, { useMemo, useState } from 'react';
+// PHASE 6 — this page's form is the first converted to Astryx.
+import { TextInput } from '@astryxdesign/core/TextInput';
+import { Selector } from '@astryxdesign/core/Selector';
+import { Button } from '@astryxdesign/core/Button';
+import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
+import { TimeInput } from '@astryxdesign/core/TimeInput';
import {
AlertTriangle,
Check,
@@ -106,6 +112,19 @@ export default function PosStaffPanel({
const [phone, setPhone] = useState('');
const [shiftId, setShiftId] = useState(0);
const [formError, setFormError] = useState('');
+ /**
+ * PHASE 6 — per-field errors, replacing the single `formError` string for the
+ * create form.
+ *
+ * The old validation returned on the first failure, so an empty form told you
+ * about the name, then about the role once you fixed it, then about the PIN.
+ * Four round trips to learn four things. Astryx's inputs take a `status`, so
+ * every failure can be shown at once, against the field that caused it.
+ *
+ * `formError` stays for the edit and reset dialogs, which are not converted
+ * yet — Phase 6 moves one form at a time.
+ */
+ const [fieldErrs, setFieldErrs] = useState
>>({});
const [issued, setIssued] = useState(null);
const [copied, setCopied] = useState(false);
const [confirmOff, setConfirmOff] = useState(null);
@@ -138,29 +157,44 @@ export default function PosStaffPanel({
setPhone('');
setShiftId(0);
setFormError('');
+ // Clear the per-field errors too, or reopening the form shows the errors
+ // from the last attempt against fields that are now empty.
+ setFieldErrs({});
};
const submit = () => {
const name = fullName.trim();
const chosen = role || (roles.length === 1 ? roles[0].role : '');
- if (!name) return setFormError('Enter the person’s name.');
- if (!chosen) return setFormError('Choose a role.');
- if (!/^\d{4}$/.test(pin)) return setFormError('The PIN must be exactly 4 digits.');
- // Checked here rather than left to the server: two people sharing a PIN at
- // one counter both appear on the till's sign-in list and neither can be
- // told apart on a bill.
- if (staff.some((s) => s.pin === pin && (s.status || '').toLowerCase() !== 'inactive')) {
- return setFormError('Another active person at this outlet already uses that PIN.');
+ // Same rules, same wording, same order as before — the only change is that
+ // every failure is collected and attached to its field instead of the first
+ // one short-circuiting the rest.
+ const errs: typeof fieldErrs = {};
+
+ if (!name) errs.name = 'Enter the person’s name.';
+ if (!chosen) errs.role = 'Choose a role.';
+
+ if (!/^\d{4}$/.test(pin)) {
+ errs.pin = 'The PIN must be exactly 4 digits.';
+ } else if (staff.some((s) => s.pin === pin && (s.status || '').toLowerCase() !== 'inactive')) {
+ // Checked here rather than left to the server: two people sharing a PIN at
+ // one counter both appear on the till's sign-in list and neither can be
+ // told apart on a bill.
+ errs.pin = 'Another active person at this outlet already uses that PIN.';
}
+
// The till signs in with this, so it is required here even though the
// column allows blank — an account without one can only ever log in by
// username, which is what this change exists to stop relying on.
const digits = phone.replace(/\D/g, '').replace(/^91(?=\d{10}$)/, '').replace(/^0(?=\d{10}$)/, '');
- if (digits.length !== 10) return setFormError('Enter a 10-digit mobile number.');
- if (staff.some((s) => (s.contactno || '') === digits && (s.status || '').toLowerCase() !== 'inactive')) {
- return setFormError('Another active person here already signs in with that number.');
+ if (digits.length !== 10) {
+ errs.phone = 'Enter a 10-digit mobile number.';
+ } else if (staff.some((s) => (s.contactno || '') === digits && (s.status || '').toLowerCase() !== 'inactive')) {
+ errs.phone = 'Another active person here already signs in with that number.';
}
+ setFieldErrs(errs);
+ if (Object.keys(errs).length) return;
+
setFormError('');
createMut.mutate(
{
@@ -302,87 +336,87 @@ export default function PosStaffPanel({
{showForm && (
+ {/* Every field now carries a real label rather than relying on a
+ placeholder. A placeholder disappears the moment you type, so
+ the only cue for what a half-filled field is went away exactly
+ when it was needed. */}
- setFullName(e.target.value)}
- placeholder="Full name"
- className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white"
- style={{ border: `1px solid ${BORDER}`, color: TEXT }}
+ onChange={(v) => setFullName(v)}
+ placeholder="e.g. Anitha R"
+ status={fieldErrs.name ? { type: 'error', message: fieldErrs.name } : undefined}
/>
- setRole(e.target.value)}
- disabled={roles.length === 1}
- className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white cursor-pointer disabled:cursor-default"
- style={{ border: `1px solid ${BORDER}`, color: TEXT }}
- >
- {roles.length !== 1 && Choose a role… }
- {roles.map((r) => (
- {r.label}
- ))}
-
- setRole(String(v ?? ''))}
+ placeholder="Choose a role…"
+ options={roles.map((r) => ({ value: r.role, label: r.label }))}
+ status={fieldErrs.role ? { type: 'error', message: fieldErrs.role } : undefined}
+ />
+ setPin(e.target.value.replace(/\D/g, '').slice(0, 4))}
- placeholder="4-digit PIN"
- inputMode="numeric"
- className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white font-mono tracking-widest"
- style={{ border: `1px solid ${BORDER}`, color: TEXT }}
+ onChange={(v) => setPin(v.replace(/\D/g, '').slice(0, 4))}
+ placeholder="4 digits"
+ status={fieldErrs.pin ? { type: 'error', message: fieldErrs.pin } : undefined}
/>
- setPhone(e.target.value.replace(/[^\d+\s-]/g, '').slice(0, 16))}
- placeholder="Mobile number — used to sign in"
- inputMode="tel"
- className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white font-mono"
- style={{ border: `1px solid ${BORDER}`, color: TEXT }}
+ onChange={(v) => setPhone(v.replace(/[^\d+\s-]/g, '').slice(0, 16))}
+ placeholder="10 digits"
+ status={fieldErrs.phone ? { type: 'error', message: fieldErrs.phone } : undefined}
+ />
+ setShiftId(Number(v ?? 0))}
+ placeholder={shiftsQ.data?.length ? 'No shift assigned' : 'No shifts set up yet'}
+ options={[
+ { value: '0', label: shiftsQ.data?.length ? 'No shift assigned' : 'No shifts set up yet' },
+ ...(shiftsQ.data ?? []).map((sh) => ({
+ value: String(sh.staff_shift_id),
+ label: `${sh.name} · ${sh.start_time}–${sh.end_time}`,
+ })),
+ ]}
/>
- setShiftId(Number(e.target.value))}
- className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white cursor-pointer"
- style={{ border: `1px solid ${BORDER}`, color: TEXT }}
- >
-
- {shiftsQ.data?.length ? 'No shift assigned' : 'No shifts set up yet'}
-
- {(shiftsQ.data ?? []).map((sh) => (
-
- {sh.name} · {sh.start_time}–{sh.end_time}
-
- ))}
-
- The mobile number is what they type at the till. A username and password are also
- generated; the password is shown once, on the next screen.
+ A username and password are also generated; the password is shown once, on the
+ next screen.
- {formError && (
-
{formError}
- )}
+ {/* The mobile-number hint moved onto the field itself as a
+ `description`, so this paragraph no longer repeats it. */}
-
- {createMut.isPending ? 'Creating…' : 'Create account'}
-
- { setShowForm(false); resetForm(); }}
- className="rounded-full px-4 py-1.5 text-[12.5px] font-bold cursor-pointer"
- style={{ background: '#fff', color: TEXT_2, border: `1px solid ${BORDER}` }}
- >
- Cancel
-
+ />
+ { setShowForm(false); resetForm(); setFieldErrs({}); }}
+ />
)}
@@ -503,71 +537,53 @@ export default function PosStaffPanel({
{editing && (
setEditing(null)} title={`Edit ${editing.full_name}`}>
- setEditName(e.target.value)}
- placeholder="Full name"
- className="w-full rounded-lg px-3 py-2 text-[13px] outline-none"
- style={{ border: `1px solid ${BORDER}`, color: TEXT }}
- />
- setEditName(v)} />
+ setEditRole(e.target.value)}
- disabled={cashierOnly}
- className="w-full rounded-lg px-3 py-2 text-[13px] outline-none cursor-pointer disabled:cursor-default"
- style={{ border: `1px solid ${BORDER}`, color: TEXT }}
- >
- {roles.map((r) => (
- {r.label}
- ))}
-
- setEditRole(String(v ?? ''))}
+ options={roles.map((r) => ({ value: r.role, label: r.label }))}
+ />
+ setEditPin(e.target.value.replace(/\D/g, '').slice(0, 4))}
- placeholder="4-digit PIN"
- inputMode="numeric"
- className="w-full rounded-lg px-3 py-2 text-[13px] outline-none font-mono tracking-widest"
- style={{ border: `1px solid ${BORDER}`, color: TEXT }}
+ onChange={(v) => setEditPin(v.replace(/\D/g, '').slice(0, 4))}
+ placeholder="4 digits"
/>
- setEditPhone(e.target.value.replace(/[^\d+\s-]/g, '').slice(0, 16))}
- placeholder="Mobile number — used to sign in"
- inputMode="tel"
- className="w-full rounded-lg px-3 py-2 text-[13px] outline-none font-mono"
- style={{ border: `1px solid ${BORDER}`, color: TEXT }}
+ onChange={(v) => setEditPhone(v.replace(/[^\d+\s-]/g, '').slice(0, 16))}
+ />
+ setEditShift(Number(v ?? 0))}
+ options={[
+ { value: '0', label: 'No shift assigned' },
+ ...(shiftsQ.data ?? []).map((sh) => ({
+ value: String(sh.staff_shift_id),
+ label: `${sh.name} · ${sh.start_time}–${sh.end_time}`,
+ })),
+ ]}
/>
- setEditShift(Number(e.target.value))}
- className="w-full rounded-lg px-3 py-2 text-[13px] outline-none cursor-pointer"
- style={{ border: `1px solid ${BORDER}`, color: TEXT }}
- >
- No shift assigned
- {(shiftsQ.data ?? []).map((sh) => (
-
- {sh.name} · {sh.start_time}–{sh.end_time}
-
- ))}
-
+ {/* editError stays a single message here. The edit dialog validates
+ one rule at a time against fields that are already populated, so
+ there is no four-errors-at-once problem to solve — unlike the
+ create form, which starts empty. */}
{editError && {editError}
}
-
- {updateMut.isPending ? 'Saving…' : 'Save'}
-
- setEditing(null)}
- className="rounded-full px-4 py-1.5 text-[12.5px] font-bold cursor-pointer"
- style={{ background: '#fff', color: TEXT_2, border: `1px solid ${BORDER}` }}
- >
- Cancel
-
+ />
+ setEditing(null)} />
)}
@@ -781,26 +797,28 @@ function ShiftManager({
{open && (
- setName(e.target.value)}
- placeholder="Name — e.g. Morning"
- className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white"
- style={{ border: `1px solid ${BORDER}`, color: TEXT }}
+ onChange={(v) => setName(v)}
+ placeholder="e.g. Morning"
/>
- setStart(e.target.value)}
- className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white"
- style={{ border: `1px solid ${BORDER}`, color: TEXT }}
+ {/* PHASE 7's TimeInput, landing here rather than as a separate
+ step — these two fields are the reason it was on the list.
+ It emits an ISO time string or undefined, so the `?? ''`
+ keeps the existing string state contract unchanged. */}
+ setStart((v ?? '') as string)}
/>
- setEnd(e.target.value)}
- className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white"
- style={{ border: `1px solid ${BORDER}`, color: TEXT }}
+ setEnd((v ?? '') as string)}
/>
@@ -947,24 +965,24 @@ function Modal({
onClose: () => void;
children: React.ReactNode;
}) {
+ // PHASE 6 — on Astryx Dialog. All three dialogs on this page (edit, reset
+ // password, deactivate) render through here, so converting this one function
+ // converts all three without touching a single call site.
+ //
+ // isOpen is always true because callers mount this conditionally
+ // (`{editing &&
}`) rather than passing a flag. Dialog wants the
+ // flag, so it gets a constant one; the caller's conditional is still what
+ // controls visibility.
+ //
+ // The `minWidth: min(20rem, 100%)` floor is gone with the hand-rolled card.
+ // It existed to survive `max-w-sm` collapsing to 8px, and Dialog sizes itself
+ // from a `width` prop rather than a Tailwind utility — so the collision that
+ // bug came from cannot reach it.
return (
-
-
-
- {/* Names come from user input and can be long and unbroken. */}
-
- {title}
-
- {children}
-
-
+ { if (!open) onClose(); }} width={400} purpose="info">
+ {/* Names come from user input and can be long and unbroken. */}
+ { if (!open) onClose(); }} />
+ {children}
+
);
}
diff --git a/src/components/PosView.tsx b/src/components/PosView.tsx
index dcc435b..8725712 100644
--- a/src/components/PosView.tsx
+++ b/src/components/PosView.tsx
@@ -2,6 +2,51 @@ import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react'
import { Search, Plus, Minus, Trash2, Receipt, X, Tag, QrCode, CreditCard, PieChart, Barcode, ShoppingCart, User, RefreshCcw, Save, Download, ChevronRight, Zap, Wallet, Smartphone, AlertCircle } from 'lucide-react';
import { useFiestaStockStatement, FIESTA_TENANT_ID } from '../services/fiestaQueries';
import { num as fnum, str as fstr, type Row } from '../services/fiestaApi';
+import { useNotify } from './Toasts';
+import { Dialog } from '@astryxdesign/core/Dialog';
+
+/**
+ * PHASE 6 — the one modal shell for this screen.
+ *
+ * PosView carried SEVEN hand-rolled `fixed inset-0` overlays, the largest
+ * concentration anywhere in the app. Every one was the same construction: a
+ * blurred backdrop, a white card, an inline `maxWidth`, and a header row with a
+ * close button. None trapped focus, none closed on Escape, and each had to be
+ * dismissed by finding its own X.
+ *
+ * They now share this. Widths are passed per call site so each dialog keeps the
+ * exact size it had (500 / 450 / 400px), and `maxHeight` is preserved where the
+ * original set one — the two that scroll internally would otherwise grow past
+ * the viewport on a long list.
+ *
+ * `print:hidden` is intentionally NOT carried over. It was on the backdrop of
+ * every overlay including the receipt one, which is why the receipt modal had
+ * to fight it back with `print:max-w-none`. Dialog renders in the top layer,
+ * where the browser's own print handling applies.
+ */
+function PosModal({
+ onClose,
+ width,
+ maxHeight,
+ children,
+}: {
+ onClose: () => void;
+ width: number;
+ maxHeight?: string;
+ children: React.ReactNode;
+}) {
+ return (
+ { if (!open) onClose(); }}
+ width={width}
+ maxHeight={maxHeight}
+ purpose="info"
+ >
+ {children}
+
+ );
+}
interface PosViewProps {
locationid?: number;
@@ -24,6 +69,10 @@ interface CartItem {
}
export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: PosViewProps) {
+ // PHASE 6 — replaces alert(). A native alert freezes the whole browser
+ // until dismissed; a toast does not, and errors stay until read.
+ const { notifyOk, notifyFail } = useNotify();
+
const stockQ = useFiestaStockStatement({ tenantid: tenantId, locationid: locationid ?? 0, pagesize: 200 });
const [search, setSearch] = useState('');
@@ -236,7 +285,7 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
if (product) {
handleProductScanned(product);
} else {
- alert('Product not found in inventory.');
+ notifyFail('Product not found in inventory.');
}
}
}
@@ -733,8 +782,7 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
{/* ── MODAL: Manual Product Search ──────────────────────────────── */}
{showManualSearch && (
-
-
+
setShowManualSearch(false)} width={500} maxHeight="85vh">
Product Search
@@ -781,14 +829,12 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
))}
-
-
+
)}
{/* ── MODAL: Customer CRM ───────────────────────────────────────── */}
{showCustomerPrompt && (
-
-
+
@@ -842,14 +888,12 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
)}
-
-
+
)}
{/* ── MODAL: Parked Carts ───────────────────────────────────────── */}
{showParkedPrompt && (
-
-
+
setShowParkedPrompt(false)} width={450} maxHeight="80vh">
Parked Sales
@@ -883,14 +927,12 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
))
)}
-
-
+
)}
{/* ── MODAL: Discount ───────────────────────────────────────────── */}
{discountPromptItem && (
-
-
+
setDiscountPromptItem(null)} width={400}>
@@ -938,14 +980,12 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
Confirm Discount
-
-
+
)}
{/* ── MODAL: Payment ────────────────────────────────────────────── */}
{showPayment && (
-
-
+
setShowPayment(false)} width={450}>
{/* Header */}
@@ -1104,14 +1144,12 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
Confirm {grandTotal < 0 ? 'Refund' : 'Payment'}
-
-
+
)}
{/* ── MODAL: Receipt ────────────────────────────────────────────── */}
{showReceipt && (
-
-
+
Receipt
@@ -1177,14 +1215,12 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
Print
-
-
+
)}
{/* ── MODAL: Daily Report ───────────────────────────────────────── */}
{showReport && (
-
-
+
setShowReport(false)} width={450}>
Daily Sales Report
@@ -1274,8 +1310,7 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
)}
-
-
+
)}
);
diff --git a/src/components/ReportsView.tsx b/src/components/ReportsView.tsx
index c78303d..93fd8f0 100644
--- a/src/components/ReportsView.tsx
+++ b/src/components/ReportsView.tsx
@@ -40,6 +40,7 @@ import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, num as fnum, str as fstr,
import { stockRowToProduct } from '../services/fiestaMappers';
import AwaitingApi from './AwaitingApi';
import SalesRevenueReport from './SalesRevenueReport';
+import { useNotify } from './Toasts';
interface ReportsViewProps {
searchQuery: string;
@@ -52,6 +53,10 @@ const WEEK_KEYS = ['week1', 'week2', 'week3', 'week4', 'week5'];
const WEEK_LABELS = ['Week 1', 'Week 2', 'Week 3', 'Week 4', 'Week 5'];
export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimbatoreView, tenantId = FIESTA_TENANT_ID }: ReportsViewProps) {
+ // PHASE 6 — replaces alert(). A native alert freezes the whole browser
+ // until dismissed; a toast does not, and errors stay until read.
+ const { notifyOk, notifyFail } = useNotify();
+
const [selectedTimeframe, setSelectedTimeframe] = useState('This Year (YTD)');
const [selectedRegion, setSelectedRegion] = useState<'all' | 'coimbatore' | 'chennai' | 'bangalore'>('all');
const [activeStoreId, setActiveStoreId] = useState
('all');
@@ -433,7 +438,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
clearInterval(interval);
setTimeout(() => {
setExportingFormat(null);
- alert(`${format} ledger exported successfully.`);
+ notifyOk(`${format} ledger exported.`);
}, 450);
return 100;
}
diff --git a/src/components/RiderTelemetryPanel.tsx b/src/components/RiderTelemetryPanel.tsx
index 424fe19..d5043c5 100644
--- a/src/components/RiderTelemetryPanel.tsx
+++ b/src/components/RiderTelemetryPanel.tsx
@@ -43,7 +43,10 @@ export default function RiderTelemetryPanel({ logs, riderName, isLoading }: Ride
const lat = latest?.latitude || latest?.lat;
const lon = latest?.longitude || latest?.lng;
const battery = latest?.batterypercentage || latest?.battery;
- const signal = latest?.signalstrength || latest?.signal;
+ // Typed at the declaration rather than cast at each use. The telemetry rows
+ // are untyped, so without this `signal` is `unknown` and cannot be rendered
+ // or passed to getSignalBars. The value is a dBm reading or absent.
+ const signal = (latest?.signalstrength || latest?.signal) as number | undefined;
const speed = latest?.speed || 0;
const timestamp = latest?.logdate || latest?.timestamp;
diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx
index 802ca1d..de3150a 100644
--- a/src/components/SettingsView.tsx
+++ b/src/components/SettingsView.tsx
@@ -25,6 +25,7 @@ import AwaitingApi from './AwaitingApi';
import AdminConsole from './AdminConsole';
import { ConfirmModal } from './ConfirmModal';
import type { AuthUser } from '../services/auth';
+import { useNotify } from './Toasts';
type TabKey = 'profile' | 'outlets' | 'users';
@@ -128,6 +129,10 @@ export interface SettingsViewProps {
}
export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: SettingsViewProps) {
+ // PHASE 6 — replaces alert(). A native alert freezes the whole browser
+ // until dismissed; a toast does not, and errors stay until read.
+ const { notifyOk, notifyFail } = useNotify();
+
const [activeTab, setActiveTab] = useState('profile');
// Live tenant profile + outlets.
@@ -166,9 +171,9 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
});
setShowEditStoreModal(false);
setEditingStore(null);
- alert('Outlet updated successfully!');
+ notifyOk('Outlet updated.');
} catch (err: any) {
- alert(err.message || 'Failed to update outlet.');
+ notifyFail(err, 'Failed to update outlet.');
}
};
@@ -183,7 +188,7 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
await deleteLocationMut.mutateAsync({ tenantid: tenantId, locationid: loc.locationid });
setConfirmModalState({ isOpen: false, loc: null });
} catch (err: any) {
- alert(err.message || 'Failed to delete outlet.');
+ notifyFail(err, 'Failed to delete outlet.');
setConfirmModalState({ isOpen: false, loc: null });
}
};
diff --git a/src/components/StoreCatalogView.tsx b/src/components/StoreCatalogView.tsx
index 183123e..aaef1b7 100644
--- a/src/components/StoreCatalogView.tsx
+++ b/src/components/StoreCatalogView.tsx
@@ -33,6 +33,12 @@ import { CardCarousel, ProductGallery } from './CatalogueGallery';
import FMCGHoverOverlay from './FMCGHoverOverlay';
import CatalogueDetailPanel from './CatalogueDetailPanel';
import OfflineSalesUpload from './OfflineSalesUpload';
+import { useNotify } from './Toasts';
+// PHASE 6 — the store catalogue's own controls.
+import { TextInput } from '@astryxdesign/core/TextInput';
+import { NumberInput } from '@astryxdesign/core/NumberInput';
+import { DateInput } from '@astryxdesign/core/DateInput';
+import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
const PLACEHOLDER = 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200';
@@ -79,6 +85,10 @@ function catBadgeClass(category: string): string {
}
export default function StoreCatalogView({ locationid, storeName = 'your store', tenantId = FIESTA_TENANT_ID, isSidebarOpen = false }: StoreCatalogViewProps) {
+ // PHASE 6 — replaces alert(). A native alert freezes the whole browser
+ // until dismissed; a toast does not, and errors stay until read.
+ const { notifyOk, notifyFail } = useNotify();
+
const tenantid = tenantId;
const [view, setView] = useState<'catalogue' | 'inventory' | 'requests'>('catalogue');
const [isLocalSidebarOpen, setIsLocalSidebarOpen] = useState(false);
@@ -146,7 +156,11 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
);
const stockRequestsQ = useFiestaGetStockRequests({ tenantid, locationid: storeLocationId, pagesize: 500, date: requestDate });
- const [picks, setPicks] = useState>({});
+ // `requestid` is written by the effect below and read by the Mark Received
+ // button; it was simply missing from this type, so the read did not compile
+ // once React types were installed. The value has always been present at
+ // runtime — this closes the type gap, it does not change behaviour.
+ const [picks, setPicks] = useState>({});
useEffect(() => {
if (stockRequestsQ.data) {
@@ -187,7 +201,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
if (picks[String(data.productid)]?.status === 'Received') return;
if (updateRequestMutation.isPending) return;
if (!storeLocationId) {
- alert('Your account isn\'t linked to a store outlet yet, so stock can\'t be received.');
+ notifyFail('Your account isn\'t linked to a store outlet yet, so stock can\'t be received.');
return;
}
updateRequestMutation.mutate(
@@ -200,7 +214,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
},
{
onError: (err: any) => {
- alert(err.message || 'Failed to record received stock.');
+ notifyFail(err, 'Failed to record received stock.');
},
},
);
@@ -213,7 +227,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
setPicks(prev => ({ ...prev, [id]: { ...prev[id], status: 'Cancelled', resolvedAt: new Date().toISOString() } }));
} else {
if (!storeLocationId) {
- alert('Your account isn\'t linked to a store outlet yet, so stock can\'t be requested.');
+ notifyFail('Your account isn\'t linked to a store outlet yet, so stock can\'t be requested.');
return;
}
setPicks(prev => ({ ...prev, [id]: { qty: 1, status: 'Pending', requestedAt: new Date().toISOString() } }));
@@ -231,7 +245,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
const setPickQty = (id: string, qty: number) => {
const safeQty = Math.max(1, Math.round(qty) || 1);
if (!storeLocationId) {
- alert('Your account isn\'t linked to a store outlet yet, so stock can\'t be requested.');
+ notifyFail('Your account isn\'t linked to a store outlet yet, so stock can\'t be requested.');
return;
}
setPicks((prev) => {
@@ -404,14 +418,16 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
Upload Offline Sales
+ {/* PHASE 6 — DateInput carries its own label, so the separate "Date:"
+ caption is dropped rather than duplicated beside it. The comment sits
+ outside the conditional: as its first child it read as a second root
+ element and broke the whole file. */}
{view === 'requests' && (
-
-
Date:
-
setRequestDate(e.target.value)}
- className="px-3 py-1.5 border border-slate-200 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-[#662582] focus:border-[#662582] text-xs font-semibold text-slate-700 bg-white"
+
+ setRequestDate((v ?? '') as string)}
/>
)}
@@ -436,20 +452,13 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
{categories.map((c) => (
-
-
- toggleCategory(c)}
- className="peer appearance-none w-5 h-5 border-2 border-slate-300 rounded-lg checked:border-[#662582] checked:bg-[#662582] transition-colors cursor-pointer"
- />
-
-
-
- {c}
-
-
+
+ toggleCategory(c)}
+ />
+
))}
@@ -463,20 +472,13 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
{['Healthy', 'Low', 'Critical', 'Out of stock'].map((h) => (
-
-
- toggleStockHealth(h)}
- className="peer appearance-none w-5 h-5 border-2 border-slate-300 rounded-lg checked:border-[#662582] checked:bg-[#662582] transition-colors cursor-pointer"
- />
-
-
-
- {h}
-
-
+
+ toggleStockHealth(h)}
+ />
+
))}
@@ -521,20 +523,20 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
Filter
-
-
-
+ setSearch(e.target.value)}
- className="w-full h-full pl-9 pr-8 py-2 bg-white border border-slate-200 rounded-xl text-xs text-slate-800 placeholder-slate-400 focus:outline-none focus:border-[#662582] transition-all shadow-sm font-medium"
+ onChange={(v) => setSearch(v)}
+ placeholder={view === 'catalogue' ? 'Search catalogue…' : 'Search your stock…'}
+ startIcon={ }
+ hasClear
/>
- {search && (
- setSearch('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 cursor-pointer border-none bg-transparent">
-
-
- )}
@@ -881,24 +883,15 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
-
-
Custom Amount
-
-
-
-
-
setTempQty(Math.max(1, parseInt(e.target.value) || 1))}
- className="w-full h-10 pl-9 pr-12 text-right text-lg font-bold font-mono text-[#662582] bg-slate-50/50 rounded-lg border border-slate-200 outline-none focus:border-[#662582] focus:bg-white transition-all shadow-inner"
- />
-
- Units
-
-
-
+ {/* NumberInput enforces the minimum itself, so the Math.max guard
+ stays only as the floor for a pasted or cleared value. */}
+ setTempQty(Math.max(1, Number(v) || 1))}
+ startIcon={ }
+ />
{/* Total & Submit */}
diff --git a/src/components/SuperAdminSidebar.tsx b/src/components/SuperAdminSidebar.tsx
index af50da4..94f23d7 100644
--- a/src/components/SuperAdminSidebar.tsx
+++ b/src/components/SuperAdminSidebar.tsx
@@ -3,16 +3,19 @@
* SPDX-License-Identifier: Apache-2.0
*/
+/**
+ * PHASE 4 — now a wrapper over the shared `NavRail`.
+ *
+ * The props and the rendered result are unchanged, so `SuperAdminPage` was not
+ * touched. The only behavioural difference is that tapping an item on a phone
+ * now closes the rail, which the user rail already did and this one didn't.
+ */
+
import React from 'react';
-import { type LucideIcon } from 'lucide-react';
-import { NavLink } from 'react-router-dom';
+import NavRail, { type NavRailItem } from './NavRail';
/** One entry in the super admin sidebar. */
-export interface SuperAdminNavItem {
- id: string;
- label: string;
- icon: LucideIcon;
-}
+export type SuperAdminNavItem = NavRailItem;
interface SuperAdminSidebarProps {
items: SuperAdminNavItem[];
@@ -21,53 +24,6 @@ interface SuperAdminSidebarProps {
onClose: () => void;
}
-/**
- * The Super Admin page's left rail. Visually identical to the admin `Sidebar`.
- */
export default function SuperAdminSidebar({ items, isOpen, onClose }: SuperAdminSidebarProps) {
- return (
- <>
- {/* Mobile Overlay */}
- {isOpen && (
-
- )}
-
-
- {/* Main Navigation Sidebar Links */}
-
- {items.map((item) => {
- const IconComponent = item.icon;
- return (
- `w-full flex items-center py-3 rounded-none text-left transition-all duration-200 cursor-pointer ${
- isOpen ? 'gap-md px-md border-l-4' : 'justify-center px-0'
- } ${
- isActive
- ? 'bg-black/20 text-white font-semibold' + (isOpen ? ' border-white' : '')
- : 'text-purple-200 hover:bg-white/10 hover:text-white' + (isOpen ? ' border-transparent' : '')
- }`}
- >
- {({ isActive }) => (
- <>
-
- {isOpen && {item.label} }
- >
- )}
-
- );
- })}
-
-
- >
- );
+ return ;
}
diff --git a/src/components/Toasts.tsx b/src/components/Toasts.tsx
new file mode 100644
index 0000000..4e9caca
--- /dev/null
+++ b/src/components/Toasts.tsx
@@ -0,0 +1,52 @@
+/**
+ * PHASE 5 — toasts.
+ *
+ * The console has never had any. Publishing a product, importing from the
+ * catalogue, deactivating a till user and deleting a product location all
+ * complete in silence today: the only feedback is that a row eventually
+ * changes, or doesn't. When something fails, the user usually cannot tell
+ * whether it failed or simply hasn't finished.
+ *
+ * `ToastViewport` is mounted once in main.tsx. Anything below it can call
+ * `useToast()` and get a function that shows one. This file adds two thin
+ * helpers over that so call sites read as intent rather than configuration.
+ *
+ * Astryx's ToastType is only 'info' | 'error' — there is no success type — so
+ * `notifyOk` uses 'info'. Do not invent a 'success' string; it will not
+ * typecheck, and silently falling back would be worse.
+ */
+
+import { useToast } from '@astryxdesign/core/Toast';
+
+/**
+ * Toasts for the result of a mutation.
+ *
+ * @example
+ * const { notifyOk, notifyFail } = useNotify();
+ * publishMut.mutate(vars, {
+ * onSuccess: () => notifyOk('Published to all outlets'),
+ * onError: (e) => notifyFail(e),
+ * });
+ */
+export function useNotify() {
+ const toast = useToast();
+
+ return {
+ notifyOk: (message: string) => toast({ body: message, isAutoHide: true }),
+
+ /**
+ * Accepts the raw error so no call site has to remember how to unwrap one.
+ * Errors do NOT auto-hide: a failure the user blinked past is a failure
+ * they will report as "nothing happened".
+ */
+ notifyFail: (err: unknown, fallback = 'Something went wrong. Please try again.') => {
+ const message =
+ err instanceof Error ? err.message
+ : typeof err === 'string' ? err
+ : fallback;
+ return toast({ body: message, type: 'error', isAutoHide: false });
+ },
+ };
+}
+
+export { useToast };
diff --git a/src/components/TrialBatchDrawer.tsx b/src/components/TrialBatchDrawer.tsx
index cd4c77a..24d9201 100644
--- a/src/components/TrialBatchDrawer.tsx
+++ b/src/components/TrialBatchDrawer.tsx
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { Package, MapPin, Truck, ChevronDown, Check, X } from 'lucide-react';
import { useFiestaTenantLocations } from '../services/fiestaQueries';
import { str as fstr } from '../services/fiestaApi';
+import { useNotify } from './Toasts';
export interface TrialProduct {
id: string | number;
@@ -21,6 +22,10 @@ interface TrialBatchDrawerProps {
}
export default function TrialBatchDrawer({ isOpen, onClose, product, tenantId, onSuccess }: TrialBatchDrawerProps) {
+ // PHASE 6 — replaces alert(). A native alert freezes the whole browser
+ // until dismissed; a toast does not, and errors stay until read.
+ const { notifyOk, notifyFail } = useNotify();
+
const [qty, setQty] = useState(5);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
@@ -49,7 +54,7 @@ export default function TrialBatchDrawer({ isOpen, onClose, product, tenantId, o
const handleSubmit = () => {
if (selectedLocationIds.length === 0) {
- alert("Please select at least one store delivery hub.");
+ notifyFail('Select at least one store delivery hub.');
return;
}
setIsSubmitting(true);
diff --git a/src/components/UserStorePage.tsx b/src/components/UserStorePage.tsx
index acfc012..67356ef 100644
--- a/src/components/UserStorePage.tsx
+++ b/src/components/UserStorePage.tsx
@@ -41,6 +41,7 @@ import PosConsoleView from './PosConsoleView';
import PosErrorBoundary from './PosErrorBoundary';
import UserStoreSidebar, { type UserNavItem } from './UserStoreSidebar';
import ComparisonModal from './ComparisonModal';
+import { useNotify } from './Toasts';
interface UserStorePageProps {
/** Returns to the login screen. */
onLogout: () => void;
@@ -68,6 +69,10 @@ type StoreShape = React.ComponentProps['store'];
* no other stores. Layout mirrors the admin console (fixed Header + left rail).
*/
export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
+ // PHASE 6 — replaces alert(). A native alert freezes the whole browser
+ // until dismissed; a toast does not, and errors stay until read.
+ const { notifyOk, notifyFail } = useNotify();
+
const [sidebarOpen, setSidebarOpen] = useState(false);
const [showQrModal, setShowQrModal] = useState(false);
const location = useLocation();
@@ -151,7 +156,7 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
.toUpperCase() || 'NA';
const handleHelp = () =>
- alert('nearledaily Store User Help — contact your store administrator for access or data questions.');
+ notifyOk('Contact your store administrator for access or data questions.');
// Build the store shape StoreDetailView expects, mirroring the App registry
// mapping so the console renders identically to the admin's store view.
diff --git a/src/components/UserStoreSidebar.tsx b/src/components/UserStoreSidebar.tsx
index 446516f..5686008 100644
--- a/src/components/UserStoreSidebar.tsx
+++ b/src/components/UserStoreSidebar.tsx
@@ -3,16 +3,19 @@
* SPDX-License-Identifier: Apache-2.0
*/
+/**
+ * PHASE 4 — now a wrapper over the shared `NavRail`.
+ *
+ * Props and rendered result are unchanged, so `UserStorePage` was not touched.
+ * `itemRadius="lg"` preserves this rail's rounded items, which is where it
+ * differed from the super-admin one.
+ */
+
import React from 'react';
-import { type LucideIcon } from 'lucide-react';
-import { NavLink } from 'react-router-dom';
+import NavRail, { type NavRailItem } from './NavRail';
/** One entry in the user store sidebar. Add to `UserStorePage`'s `NAV_ITEMS`. */
-export interface UserNavItem {
- id: string;
- label: string;
- icon: LucideIcon;
-}
+export type UserNavItem = NavRailItem;
interface UserStoreSidebarProps {
items: UserNavItem[];
@@ -21,60 +24,6 @@ interface UserStoreSidebarProps {
onClose: () => void;
}
-/**
- * The user store page's left rail. Visually identical to the admin `Sidebar`
- * (fixed, sits under the fixed Header, collapses to an icon rail) but driven by
- * a generic items list so new user sections can be added without touching this.
- */
export default function UserStoreSidebar({ items, isOpen, onClose }: UserStoreSidebarProps) {
- return (
- <>
- {/* Mobile Overlay */}
- {isOpen && (
-
- )}
-
-
- {/* Main Navigation Sidebar Links */}
-
- {items.map((item) => {
- const IconComponent = item.icon;
- return (
- {
- if (window.innerWidth < 768) {
- onClose();
- }
- }}
- className={({ isActive }) => `w-full flex items-center py-3 rounded-lg text-left transition-all duration-200 cursor-pointer ${
- isOpen ? 'gap-md px-md border-l-4' : 'justify-center px-0'
- } ${
- isActive
- ? 'bg-black/20 text-white font-semibold' + (isOpen ? ' border-white' : '')
- : 'text-purple-200 hover:bg-white/10 hover:text-white' + (isOpen ? ' border-transparent' : '')
- }`}
- >
- {({ isActive }) => (
- <>
-
- {isOpen && {item.label} }
- >
- )}
-
- );
- })}
-
-
- >
- );
+ return ;
}
diff --git a/src/components/UsersPanel.tsx b/src/components/UsersPanel.tsx
index 64b9501..27a63f9 100644
--- a/src/components/UsersPanel.tsx
+++ b/src/components/UsersPanel.tsx
@@ -34,6 +34,7 @@ import { useFiestaUsers, useFiestaCreateUser, useFiestaUpdateUser, useFiestaRide
import { useAppRoles } from '../services/queries';
import { FIESTA_TENANT_ID, str as fstr, num as fnum, roleName } from '../services/fiestaApi';
import AddressAutocomplete, { type AddressResult } from './AddressAutocomplete';
+import { useNotify } from './Toasts';
interface UsersPanelProps {
tenantId?: number;
@@ -83,6 +84,10 @@ const FALLBACK_ROLE_CHOICES = [
];
export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUserRole = 4 }: UsersPanelProps) {
+ // PHASE 6 — replaces alert(). A native alert freezes the whole browser
+ // until dismissed; a toast does not, and errors stay until read.
+ const { notifyOk, notifyFail } = useNotify();
+
const usersQ = useFiestaUsers({ tenantid: tenantId, pagesize: 100 });
const createUserMut = useFiestaCreateUser();
const updateUserMut = useFiestaUpdateUser();
@@ -105,7 +110,7 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
await updateUserMut.mutateAsync({ userid, roleid: -1, locationid: -1, applocationid: -1 });
}
} catch (err) {
- alert(`Could not update user role: ${err instanceof Error ? err.message : 'Unknown error'}`);
+ notifyFail(err, 'Could not update user role.');
}
};
@@ -246,23 +251,23 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
const handleCreateUser = async (e: React.FormEvent) => {
e.preventDefault();
if (!newUser.firstname || !newUser.email || !newUser.contactno) {
- alert('Please provide first name, contact number, and email.');
+ notifyFail('Please provide first name, contact number, and email.');
return;
}
if (!/^\d{10}$/.test(newUser.contactno.trim())) {
- alert('Contact number must be exactly 10 digits.');
+ notifyFail('Contact number must be exactly 10 digits.');
return;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newUser.email.trim())) {
- alert('Please enter a valid email address.');
+ notifyFail('Please enter a valid email address.');
return;
}
if (!Number(newUser.roleid)) {
- alert('Please select a role.');
+ notifyFail('Please select a role.');
return;
}
if (!newUser.locationid) {
- alert('Please select the store this user belongs to.');
+ notifyFail('Please select the store this user belongs to.');
return;
}
try {
@@ -288,9 +293,9 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
});
setShowAddUserModal(false);
setNewUser({ firstname: '', email: '', contactno: '', roleid: defaultNewUserRole, locationid: 0, applocationid: 0, address: '', suburb: '', city: '', state: '', postcode: '', latitude: '', longitude: '', shiftid: 0 });
- alert(`Team member "${newUser.firstname}" added successfully.`);
+ notifyOk(`Team member "${newUser.firstname}" added.`);
} catch (err) {
- alert(`Could not add team member: ${err instanceof Error ? err.message : 'Unknown error'}`);
+ notifyFail(err, 'Could not add team member.');
}
};
@@ -715,7 +720,7 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
});
setAssignUserId(null);
} catch (err) {
- alert(`Could not assign user: ${err instanceof Error ? err.message : 'Unknown error'}`);
+ notifyFail(err, 'Could not assign user.');
}
}} className="p-6 space-y-5">
diff --git a/src/components/consoleUi.tsx b/src/components/consoleUi.tsx
index 6556ffd..0c8eeee 100644
--- a/src/components/consoleUi.tsx
+++ b/src/components/consoleUi.tsx
@@ -18,6 +18,41 @@
import React, { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { X, Info } from 'lucide-react';
+import { Card as AxCard } from '@astryxdesign/core/Card';
+import { Skeleton as AxSkeleton } from '@astryxdesign/core/Skeleton';
+import { Tooltip as AxTooltip } from '@astryxdesign/core/Tooltip';
+import { StatusDot } from '@astryxdesign/core/StatusDot';
+import {
+ Table as AxTable,
+ TableHeader,
+ TableHeaderCell,
+ TableRow,
+ TableBody,
+} from '@astryxdesign/core/Table';
+
+/*
+ * PHASE 3 — Astryx adapters.
+ *
+ * Every export below keeps its original name and signature, so the ten files
+ * that import from here were not touched. What changed is what each one renders
+ * underneath.
+ *
+ * SEVEN of the fourteen primitives now sit on Astryx: Card, FilterBar,
+ * TableShell, Skeleton, Tooltip, LiveStatus and SlideDrawer. Each was a
+ * lossless swap.
+ *
+ * THE OTHER SEVEN DELIBERATELY DID NOT MOVE, and the reason is the same for
+ * most of them: `Pill`, `StatusChip` and `MetricPill` take an arbitrary hex via
+ * a `color` prop, fed from ORDER_STATUS / DELIVERY_STATUS. Astryx's Badge takes
+ * a named variant from a fixed set of fourteen. Mapping onto it is lossy in a
+ * way that matters — both `accepted` (#6366f1) and `picked` (#8b5cf6) collapse
+ * to `purple`, so two distinct states on the delivery board would become
+ * indistinguishable. That is a functional regression, not a cosmetic one, so
+ * these keep rendering their own markup until the status maps themselves are
+ * reworked onto semantic names. GradientHeader (brand gradient), KpiStrip
+ * (bespoke layout), StampCell and SearchPill are unchanged for want of a
+ * genuine equivalent.
+ */
// ── Design tokens ────────────────────────────────────────────────────────────────
export const BRAND = '#662582';
@@ -114,13 +149,13 @@ function BrandMark() {
/** Live / loading / error status line used under the header title. */
export function LiveStatus({ state, label }: { state: 'live' | 'loading' | 'error'; label: string }) {
- const color = state === 'error' ? '#ef4444' : state === 'loading' ? '#94a3b8' : '#10b981';
+ // The three states map exactly onto three Astryx variants, and `isPulsing`
+ // replaces the hand-rolled animate-pulse. Semantic tokens mean these now
+ // track the theme instead of three hardcoded hexes.
+ const variant = state === 'error' ? 'error' : state === 'loading' ? 'neutral' : 'success';
return (
-
+
{label}
);
@@ -153,7 +188,9 @@ export function KpiStrip({ items, loading }: { items: KpiItem[]; loading?: boole
className="p-1.5 rounded-lg transition-transform group-hover:scale-110 flex items-center justify-center shrink-0"
style={{ background: soft(it.color), color: it.color }}
>
- {React.cloneElement(it.icon as React.ReactElement, { className: 'w-3.5 h-3.5' })}
+ {/* The element's props have to be named for cloneElement to accept
+ className — a bare ReactElement is typed as Partial. */}
+ {React.cloneElement(it.icon as React.ReactElement<{ className?: string }>, { className: 'w-3.5 h-3.5' })}
@@ -276,21 +313,20 @@ export function SearchPill({ value, onChange, placeholder, color = BRAND, inputR
// ── Card shells & table head cell ────────────────────────────────────────────────
export function Card({ children, className = '', flush }: { children: React.ReactNode; className?: string; flush?: 'top' }) {
return (
-
+ // padding={0} because every caller already supplies its own padding
+ // classes; letting Astryx add its default would shift ten screens at once.
+
{children}
-
+
);
}
/** Filter/tab bar paper that visually joins the table below it (flat bottom). */
export function FilterBar({ children, className = '' }: { children: React.ReactNode; className?: string }) {
return (
-
+
);
}
@@ -306,24 +342,31 @@ export const TH_STYLE: React.CSSProperties = {
};
export function TableShell({ head, children, minWidth, footer }: { head: string[]; children: React.ReactNode; minWidth?: number; footer?: React.ReactNode }) {
+ // `children` is a stream of caller-authored
/, so the body stays a
+ // plain TableBody passthrough — swapping every row in ten screens is Phase 6's
+ // job, not this one. Head cells move to Astryx now because they are owned
+ // here and nobody outside supplies them.
return (
-
+
-
-
-
+
+
+
{head.map((h, i) => (
- 1 ? 'text-right' : ''}`}>{h}
+ // TableHeaderCell has no `align` prop, so the right-alignment
+ // from column three onwards is kept on an inner span rather
+ // than lost.
+
+ 1 ? 'block text-right' : 'block'}>{h}
+
))}
-
-
-
- {children}
-
-
+
+
+
{children}
+
{footer}
-
+
);
}
@@ -334,11 +377,13 @@ export function TableShell({ head, children, minWidth, footer }: { head: string[
* Uses a pulsing animation to match shape before data arrives.
*/
export function Skeleton({ className = '', style }: { className?: string; style?: React.CSSProperties }) {
+ // Callers size this with Tailwind classes (`h-4 w-20`), while Astryx's
+ // Skeleton wants numeric width/height. The wrapper keeps the caller's sizing
+ // and lets the Astryx one fill it, so no call site had to change.
return (
-
+
);
}
@@ -347,16 +392,14 @@ export function Skeleton({ className = '', style }: { className?: string; style?
* Wraps children and shows a sleek dark explanation box on hover.
*/
export function Tooltip({ children, content, position = 'top' }: { children: React.ReactNode; content: React.ReactNode; position?: 'top' | 'bottom' }) {
+ // Straight swap. Astryx's version also opens on keyboard focus, which the
+ // hover-only original never did — a real accessibility gain for free.
return (
-
+ // Astryx names placements by flow direction, not by physical edge — so
+ // top/bottom become above/below. The public prop keeps its old names.
+
+ {children}
+
);
}
@@ -376,20 +419,37 @@ export function SlideDrawer({
children: React.ReactNode;
}) {
- // Prevent body scroll when open
+ // Kept rather than delegated to Astryx's Dialog. Dialog is a centred modal
+ // built on ; this is an edge-anchored drawer, and forcing one into
+ // the other would change the shape of six screens in a phase whose whole
+ // premise is that call sites do not change. What it does gain is Dialog's
+ // two behaviours that were missing here: Escape closes it, and focus is
+ // restored to whatever opened it. Phase 5 is where the overlay family gets
+ // unified properly.
useEffect(() => {
if (isOpen) document.body.style.overflow = 'hidden';
else document.body.style.overflow = '';
return () => { document.body.style.overflow = ''; };
}, [isOpen]);
+ useEffect(() => {
+ if (!isOpen) return;
+ const opener = document.activeElement as HTMLElement | null;
+ const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
+ document.addEventListener('keydown', onKey);
+ return () => {
+ document.removeEventListener('keydown', onKey);
+ opener?.focus?.();
+ };
+ }, [isOpen, onClose]);
+
if (!isOpen) return null;
return (
{/* Muted Backdrop */}
-
diff --git a/src/components/spike/ConsoleUiProbe.tsx b/src/components/spike/ConsoleUiProbe.tsx
new file mode 100644
index 0000000..3dc8f35
--- /dev/null
+++ b/src/components/spike/ConsoleUiProbe.tsx
@@ -0,0 +1,121 @@
+/**
+ * PHASE 3 verification harness — THROWAWAY, delete with the spike folder.
+ *
+ * The ten screens that consume consoleUi sit behind a login, so this renders
+ * the seven rewritten primitives directly with stand-in data. It imports them
+ * by their original names through the original module path, which is the point:
+ * if these render, the adapters are signature-compatible.
+ */
+
+import React, { useState } from 'react';
+import {
+ Card,
+ FilterBar,
+ TableShell,
+ Skeleton,
+ Tooltip,
+ LiveStatus,
+ SlideDrawer,
+ Pill,
+ StatusChip,
+ MetricPill,
+ StampCell,
+ SearchPill,
+ KpiStrip,
+ ORDER_STATUS,
+ DELIVERY_STATUS,
+ statusColor,
+ BRAND,
+} from '../consoleUi';
+import { Package } from 'lucide-react';
+import { ConfirmModal } from '../ConfirmModal';
+import { useNotify } from '../Toasts';
+
+export default function ConsoleUiProbe() {
+ const { notifyOk, notifyFail } = useNotify();
+ const [confirm, setConfirm] = useState(false);
+ const [drawer, setDrawer] = useState(false);
+ const [q, setQ] = useState('');
+ const [tab, setTab] = useState('pending');
+
+ return (
+
+
consoleUi — Phase 3 adapters
+
+
},
+ { label: 'Delivered', value: '1,102', color: '#10b981', icon:
, badge: '88%' },
+ { label: 'Pending', value: '94', color: '#f59e0b', icon:
},
+ { label: 'Cancelled', value: '52', color: '#ef4444', icon:
},
+ ]}
+ />
+
+
+
+
+
+
+
+ {['pending', 'confirmed', 'delivered', 'cancelled'].map((s) => (
+
setTab(s)} count={12}>
+ {s}
+
+ ))}
+
+ hover me
+
+
setDrawer(true)}>open drawer
+
+
+
+
+ {[
+ { id: '#1043', s: 'delivered', km: '4.2', amt: '₹248' },
+ { id: '#1041', s: 'cancelled', km: '1.1', amt: '₹89' },
+ { id: '#1040', s: 'pending', km: '7.8', amt: '₹156' },
+ ].map((r) => (
+
+ {r.id}
+
+ {r.km} km
+ {r.amt}
+
+
+ ))}
+
+
+
+ Skeletons
+
+
+
+
+
+
+
+
+ Phase 5 — overlays & toasts
+
+ setConfirm(true)}>open confirm dialog
+ notifyOk('Published to all outlets')}>toast: ok
+ notifyFail(new Error('Set a price before publishing.'))}>toast: error
+
+
+
+
{ setConfirm(false); notifyOk('Unpublished from 3 outlets'); }}
+ onCancel={() => setConfirm(false)}
+ />
+
+ setDrawer(false)} title="Order #1043">
+ Escape should close this, and focus should return to the button that opened it.
+
+
+ );
+}
diff --git a/src/components/spike/PosStaffPanelAstryx.tsx b/src/components/spike/PosStaffPanelAstryx.tsx
new file mode 100644
index 0000000..741de86
--- /dev/null
+++ b/src/components/spike/PosStaffPanelAstryx.tsx
@@ -0,0 +1,207 @@
+/**
+ * PHASE 1 SPIKE — THROWAWAY. Delete this whole folder to revert.
+ *
+ * A rebuild of the create-staff form and roster table from PosStaffPanel.tsx
+ * on Astryx, to answer three questions before committing to the migration:
+ *
+ * 1. Does the cascade hold? Specifically, do our unlayered `.max-w-*`
+ * overrides in index.css bleed into Astryx components — they beat every
+ * cascade layer, including `astryx-base`.
+ * 2. What does it cost in bundle size?
+ * 3. Do Field / TextInput / Selector actually cover our validation patterns?
+ *
+ * The validation rules below are copied verbatim in behaviour from
+ * PosStaffPanel.submit() — same order, same messages, same PIN and phone
+ * normalisation — so the comparison is like-for-like. This component is wired
+ * to no mutation: it is a rendering and typing probe, not a working screen.
+ */
+
+import React, { useState } from 'react';
+import { Button } from '@astryxdesign/core/Button';
+import { TextInput } from '@astryxdesign/core/TextInput';
+import { Selector } from '@astryxdesign/core/Selector';
+import { Card } from '@astryxdesign/core/Card';
+import { Badge } from '@astryxdesign/core/Badge';
+import { Banner } from '@astryxdesign/core/Banner';
+import { Text } from '@astryxdesign/core/Text';
+import { Heading } from '@astryxdesign/core/Heading';
+import {
+ Table,
+ TableHeader,
+ TableHeaderCell,
+ TableRow,
+ TableCell,
+ TableBody,
+} from '@astryxdesign/core/Table';
+
+type Row = {
+ pos_user_id: number;
+ full_name: string;
+ role: string;
+ pin: string;
+ contactno: string;
+ status: string;
+};
+
+/** Stand-in roster. The real panel reads this from usePosStaff(). */
+const SAMPLE: Row[] = [
+ { pos_user_id: 1, full_name: 'Anitha R', role: 'Supervisor', pin: '4417', contactno: '9840012345', status: 'active' },
+ { pos_user_id: 2, full_name: 'Karthik S', role: 'Cashier', pin: '2280', contactno: '9840067890', status: 'active' },
+ { pos_user_id: 3, full_name: 'Meena V', role: 'Cashier', pin: '9032', contactno: '9840055512', status: 'inactive' },
+];
+
+const ROLE_OPTIONS = [
+ { value: 'Supervisor', label: 'Supervisor' },
+ { value: 'Cashier', label: 'Cashier' },
+];
+
+/** Per-field errors — the shape Astryx's `status` prop wants. */
+type Errs = Partial
>;
+
+export default function PosStaffPanelAstryx() {
+ const [fullName, setFullName] = useState('');
+ const [role, setRole] = useState('');
+ const [pin, setPin] = useState('');
+ const [phone, setPhone] = useState('');
+ const [errs, setErrs] = useState({});
+ const [banner, setBanner] = useState('');
+
+ const staff = SAMPLE;
+
+ /**
+ * Same rules and same wording as PosStaffPanel.submit(), but each failure is
+ * attached to the field that caused it instead of being funnelled into one
+ * `formError` string above the form. That is the substantive difference the
+ * spike is measuring: today the second error only appears after the first is
+ * fixed, because there is one slot to show it in.
+ */
+ const submit = () => {
+ const next: Errs = {};
+ const name = fullName.trim();
+
+ if (!name) next.name = 'Enter the person’s name.';
+ if (!role) next.role = 'Choose a role.';
+ if (!/^\d{4}$/.test(pin)) {
+ next.pin = 'The PIN must be exactly 4 digits.';
+ } else if (staff.some((s) => s.pin === pin && (s.status || '').toLowerCase() !== 'inactive')) {
+ next.pin = 'Another active person at this outlet already uses that PIN.';
+ }
+
+ const digits = phone.replace(/\D/g, '').replace(/^91(?=\d{10}$)/, '').replace(/^0(?=\d{10}$)/, '');
+ if (digits.length !== 10) {
+ next.phone = 'Enter a 10-digit mobile number.';
+ } else if (staff.some((s) => (s.contactno || '') === digits && (s.status || '').toLowerCase() !== 'inactive')) {
+ next.phone = 'Another active person here already signs in with that number.';
+ }
+
+ setErrs(next);
+ setBanner(Object.keys(next).length ? '' : `Would create ${name} as ${role}. (Spike — no mutation is wired.)`);
+ };
+
+ return (
+
+
+ POS staff — Astryx spike
+
+ Throwaway rebuild of the create-staff form and roster. Compare against the live panel
+ at Settings › POS staff.
+
+
+
+ {/* Banner takes `title`, not children — one of several components here
+ that are label-prop driven rather than composition driven. */}
+ {banner &&
}
+
+ {/* Q1 probe: max-w-sm is one of the five utilities our unlayered repair
+ restates. If the repair bleeds past the layer boundary it will squeeze
+ this card, exactly as it squeezed the modals before the repair. */}
+
+
+
Add a person
+
+
setFullName(v)}
+ placeholder="e.g. Anitha R"
+ status={errs.name ? { type: 'error', message: errs.name } : undefined}
+ />
+
+ setRole(String(v ?? ''))}
+ placeholder="Choose a role"
+ status={errs.role ? { type: 'error', message: errs.role } : undefined}
+ />
+
+ setPin(v)}
+ placeholder="4 digits"
+ description="Used to sign in at the till."
+ status={errs.pin ? { type: 'error', message: errs.pin } : undefined}
+ />
+
+ setPhone(v)}
+ placeholder="10 digits"
+ status={errs.phone ? { type: 'error', message: errs.phone } : undefined}
+ />
+
+
+
+ {
+ setFullName('');
+ setRole('');
+ setPin('');
+ setPhone('');
+ setErrs({});
+ setBanner('');
+ }}
+ />
+
+
+
+
+
+
Roster
+
+
+
+ Name
+ Role
+ PIN
+ Mobile
+ Status
+
+
+
+ {staff.map((s) => (
+
+ {s.full_name}
+ {s.role}
+ {s.pin}
+ {s.contactno}
+
+
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/components/spike/ThemeShowcase.tsx b/src/components/spike/ThemeShowcase.tsx
new file mode 100644
index 0000000..484ba0c
--- /dev/null
+++ b/src/components/spike/ThemeShowcase.tsx
@@ -0,0 +1,1273 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+'use client';
+
+import {type CSSProperties, type ReactNode} from 'react';
+import {
+ Plus,
+ Search,
+ Tag,
+ Folder,
+ MapPin,
+ List,
+ LayoutGrid,
+ ShoppingBag,
+ Banknote,
+ Mic,
+ CreditCard,
+ Lock,
+ X,
+ Download,
+ Smartphone,
+ Wallet,
+ User,
+} from 'lucide-react';
+import {Text, Heading} from '@astryxdesign/core/Text';
+import {VStack, HStack} from '@astryxdesign/core/Layout';
+import {useAppShellMobile} from '@astryxdesign/core/AppShell';
+import {Grid, GridSpan} from '@astryxdesign/core/Grid';
+import {Card} from '@astryxdesign/core/Card';
+import {Button} from '@astryxdesign/core/Button';
+import {Link} from '@astryxdesign/core/Link';
+import {Badge} from '@astryxdesign/core/Badge';
+import {Banner} from '@astryxdesign/core/Banner';
+import {Divider} from '@astryxdesign/core/Divider';
+import {CheckboxInput} from '@astryxdesign/core/CheckboxInput';
+import {Item} from '@astryxdesign/core/Item';
+import {Table, proportional, pixel} from '@astryxdesign/core/Table';
+import {TextInput} from '@astryxdesign/core/TextInput';
+import {Selector} from '@astryxdesign/core/Selector';
+import {RadioList, RadioListItem} from '@astryxdesign/core/RadioList';
+import {SelectableCard} from '@astryxdesign/core/SelectableCard';
+import {MoreMenu} from '@astryxdesign/core/MoreMenu';
+import {Center} from '@astryxdesign/core/Center';
+import {Section} from '@astryxdesign/core/Section';
+import {AspectRatio} from '@astryxdesign/core/AspectRatio';
+import {NumberInput} from '@astryxdesign/core/NumberInput';
+import {OverflowList} from '@astryxdesign/core/OverflowList';
+import {TopNav, TopNavHeading, TopNavItem} from '@astryxdesign/core/TopNav';
+import {
+ ChatComposer,
+ ChatMessage,
+ ChatMessageBubble,
+ ChatMessageList,
+ ChatSystemMessage,
+} from '@astryxdesign/core/Chat';
+
+// Styles passed to Astryx components via their `style` prop. Astryx components
+// forward the DOM `style` prop, so these work with no CSS compiler — in
+// compiled builds and in the live playground preview alike.
+const styles: Record = {
+ card: {
+ backgroundColor: 'var(--color-background-body)',
+ color: 'var(--color-text-primary)',
+ minWidth: 0,
+ borderColor: 'transparent',
+ },
+ checkoutStack: {
+ minWidth: 0,
+ width: '100%',
+ },
+ paymentCardContent: {
+ minWidth: 0,
+ width: '100%',
+ textAlign: 'center',
+ wordBreak: 'break-word',
+ },
+ inventoryCard: {
+ backgroundColor: 'var(--color-background-surface)',
+ color: 'var(--color-text-primary)',
+ overflow: 'hidden',
+ },
+ inventoryHeader: {
+ paddingBlock: 'var(--spacing-6)',
+ paddingInline: 'var(--spacing-6)',
+ },
+ inventoryFilterRow: {
+ paddingBlock: 'var(--spacing-4)',
+ paddingInline: 'var(--spacing-6)',
+ width: '100%',
+ overflowX: 'auto' as const,
+ },
+ // Inset the table by --spacing-6 (the card is padding={0}) so its edge lines
+ // up with the header/filter row in every theme's spacing scale.
+ inventoryTableWrap: {
+ paddingInline: 'var(--spacing-6)',
+ paddingBlockEnd: 'var(--spacing-2)',
+ },
+ searchInput: {
+ flex: 1,
+ minWidth: 0,
+ maxWidth: 240,
+ },
+ activityCard: {
+ backgroundColor: 'var(--color-background-surface)',
+ color: 'var(--color-text-primary)',
+ minWidth: 0,
+ height: '100%',
+ },
+ chatCard: {
+ backgroundColor: 'var(--color-background-surface)',
+ color: 'var(--color-text-primary)',
+ minWidth: 0,
+ overflow: 'hidden',
+ display: 'flex',
+ flexDirection: 'column' as const,
+ },
+ chatHeader: {
+ paddingBlock: 'var(--spacing-4)',
+ paddingInline: 'var(--spacing-4)',
+ },
+ activityCardStack: {
+ height: '100%',
+ },
+ activityListFade: {
+ flex: 1,
+ minHeight: 0,
+ overflow: 'hidden',
+ maskImage:
+ 'linear-gradient(to bottom, black calc(100% - 48px), transparent)',
+ WebkitMaskImage:
+ 'linear-gradient(to bottom, black calc(100% - 48px), transparent)',
+ marginInline: 'calc(var(--spacing-2) * -1)',
+ },
+ content: {
+ maxWidth: 960,
+ marginInline: 'auto',
+ minWidth: 0,
+ },
+ contentFluid: {
+ maxWidth: 880,
+ },
+ heroText: {
+ textAlign: 'center' as const,
+ maxWidth: 560,
+ },
+ centerText: {
+ textAlign: 'center',
+ },
+ cardStack: {
+ height: '100%',
+ },
+ cardDescription: {
+ flex: 1,
+ },
+ quantityInput: {
+ // minWidth (not a hard width) so the field grows to fit the digit + the
+ // theme's input padding. A fixed 40px was too tight on themes with larger
+ // padding / bigger type scale (e.g. Matcha, Y2K), clipping the value.
+ minWidth: 64,
+ flexShrink: 0,
+ },
+ cartButton: {
+ flex: 1,
+ },
+};
+
+// Styles applied directly to plain DOM elements via the `style` prop.
+// Plain inline styles so they render with no CSS compiler. All are static
+// (no media/pseudo variants), so inline styles reproduce them exactly.
+const inlineStyles: Record = {
+ inventoryBannerWrap: {
+ paddingInline: 'var(--spacing-6)',
+ paddingBottom: 'var(--spacing-4)',
+ },
+ thumbnail: {
+ width: 40,
+ height: 40,
+ borderRadius: 'var(--radius-element)',
+ objectFit: 'cover',
+ display: 'block',
+ flexShrink: 0,
+ },
+ thumbnailFallback: {
+ width: 40,
+ height: 40,
+ borderRadius: 'var(--radius-element)',
+ backgroundColor: 'var(--color-background-muted)',
+ color: 'var(--color-text-secondary)',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ fontSize: 'var(--font-size-sm)',
+ fontWeight: 600,
+ flexShrink: 0,
+ },
+ kpiValue: {
+ fontSize: 24,
+ fontWeight: 700,
+ lineHeight: 1.1,
+ color: 'var(--color-text-primary)',
+ fontFamily: 'var(--font-family-heading)',
+ letterSpacing: '-0.01em',
+ },
+ chatBody: {
+ flex: 1,
+ minHeight: 0,
+ overflow: 'hidden',
+ },
+ chatSuggestions: {
+ paddingInline: 'var(--spacing-4)',
+ paddingBottom: 'var(--spacing-2)',
+ },
+ chatComposer: {
+ paddingInline: 'var(--spacing-4)',
+ paddingBottom: 'var(--spacing-4)',
+ },
+ activityIcon: {
+ width: 32,
+ height: 32,
+ borderRadius: 'var(--radius-full)',
+ backgroundColor: 'var(--color-background-muted)',
+ color: 'var(--color-text-secondary)',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ flexShrink: 0,
+ },
+ productImage: {
+ width: '100%',
+ height: '100%',
+ objectFit: 'cover',
+ },
+ cardBody: {
+ padding: 'var(--spacing-4)',
+ flex: 1,
+ display: 'flex',
+ flexDirection: 'column',
+ },
+};
+
+const PRODUCT_IMAGE_KEYS = ['watch', 'headphones', 'backpack'];
+
+/** Categorical badge variants usable for showcase product/inventory tags. */
+export type ShowcaseBadgeVariant =
+ | 'blue'
+ | 'cyan'
+ | 'green'
+ | 'orange'
+ | 'pink'
+ | 'purple'
+ | 'red'
+ | 'teal'
+ | 'yellow';
+
+export interface ProductSpec {
+ name: string;
+ description: string;
+ badge: string;
+ badgeVariant: ShowcaseBadgeVariant;
+}
+
+const DEFAULT_PRODUCTS: ProductSpec[] = [
+ {
+ name: 'Minimalist Watch',
+ description: 'Clean design meets everyday durability.',
+ badge: 'New',
+ badgeVariant: 'blue',
+ },
+ {
+ name: 'Wireless Headphones',
+ description: 'Immersive sound, all-day comfort.',
+ badge: 'Popular',
+ badgeVariant: 'green',
+ },
+ {
+ name: 'Canvas Backpack',
+ description: 'Water-resistant canvas with a quiet, modern profile.',
+ badge: 'Limited',
+ badgeVariant: 'yellow',
+ },
+];
+
+// Neutral product photos, served from the shared astryx asset CDN so the
+// scaffolded template renders real imagery without needing local public assets.
+const DEFAULT_IMAGES: Record = {
+ watch: 'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20400%20300%22%20preserveAspectRatio%3D%22xMidYMid%20slice%22%3E%3Crect%20width%3D%22400%22%20height%3D%22300%22%20fill%3D%22%23f5f6f8%22%2F%3E%3Cg%20transform%3D%22translate%28200%20150%29%22%20fill%3D%22none%22%20stroke%3D%22%23c2cad6%22%20stroke-width%3D%225%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Crect%20x%3D%22-44%22%20y%3D%22-44%22%20width%3D%2288%22%20height%3D%2288%22%20rx%3D%2216%22%2F%3E%3Ccircle%20cx%3D%2218%22%20cy%3D%22-18%22%20r%3D%222.5%22%20fill%3D%22%23c2cad6%22%20stroke%3D%22none%22%2F%3E%3Cpath%20d%3D%22M-34%2030%20L-8%200%20L10%2018%20L20%208%20L34%2024%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E',
+ headphones:
+ 'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20400%20300%22%20preserveAspectRatio%3D%22xMidYMid%20slice%22%3E%3Crect%20width%3D%22400%22%20height%3D%22300%22%20fill%3D%22%23f5f6f8%22%2F%3E%3Cg%20transform%3D%22translate%28200%20150%29%22%20fill%3D%22none%22%20stroke%3D%22%23c2cad6%22%20stroke-width%3D%225%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Crect%20x%3D%22-44%22%20y%3D%22-44%22%20width%3D%2288%22%20height%3D%2288%22%20rx%3D%2216%22%2F%3E%3Ccircle%20cx%3D%2218%22%20cy%3D%22-18%22%20r%3D%222.5%22%20fill%3D%22%23c2cad6%22%20stroke%3D%22none%22%2F%3E%3Cpath%20d%3D%22M-34%2030%20L-8%200%20L10%2018%20L20%208%20L34%2024%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E',
+ backpack: 'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20400%20300%22%20preserveAspectRatio%3D%22xMidYMid%20slice%22%3E%3Crect%20width%3D%22400%22%20height%3D%22300%22%20fill%3D%22%23f5f6f8%22%2F%3E%3Cg%20transform%3D%22translate%28200%20150%29%22%20fill%3D%22none%22%20stroke%3D%22%23c2cad6%22%20stroke-width%3D%225%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Crect%20x%3D%22-44%22%20y%3D%22-44%22%20width%3D%2288%22%20height%3D%2288%22%20rx%3D%2216%22%2F%3E%3Ccircle%20cx%3D%2218%22%20cy%3D%22-18%22%20r%3D%222.5%22%20fill%3D%22%23c2cad6%22%20stroke%3D%22none%22%2F%3E%3Cpath%20d%3D%22M-34%2030%20L-8%200%20L10%2018%20L20%208%20L34%2024%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E',
+ wallet: 'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20400%20300%22%20preserveAspectRatio%3D%22xMidYMid%20slice%22%3E%3Crect%20width%3D%22400%22%20height%3D%22300%22%20fill%3D%22%23f5f6f8%22%2F%3E%3Cg%20transform%3D%22translate%28200%20150%29%22%20fill%3D%22none%22%20stroke%3D%22%23c2cad6%22%20stroke-width%3D%225%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Crect%20x%3D%22-44%22%20y%3D%22-44%22%20width%3D%2288%22%20height%3D%2288%22%20rx%3D%2216%22%2F%3E%3Ccircle%20cx%3D%2218%22%20cy%3D%22-18%22%20r%3D%222.5%22%20fill%3D%22%23c2cad6%22%20stroke%3D%22none%22%2F%3E%3Cpath%20d%3D%22M-34%2030%20L-8%200%20L10%2018%20L20%208%20L34%2024%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E',
+ tumbler: 'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20400%20300%22%20preserveAspectRatio%3D%22xMidYMid%20slice%22%3E%3Crect%20width%3D%22400%22%20height%3D%22300%22%20fill%3D%22%23f5f6f8%22%2F%3E%3Cg%20transform%3D%22translate%28200%20150%29%22%20fill%3D%22none%22%20stroke%3D%22%23c2cad6%22%20stroke-width%3D%225%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Crect%20x%3D%22-44%22%20y%3D%22-44%22%20width%3D%2288%22%20height%3D%2288%22%20rx%3D%2216%22%2F%3E%3Ccircle%20cx%3D%2218%22%20cy%3D%22-18%22%20r%3D%222.5%22%20fill%3D%22%23c2cad6%22%20stroke%3D%22none%22%2F%3E%3Cpath%20d%3D%22M-34%2030%20L-8%200%20L10%2018%20L20%208%20L34%2024%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E',
+ throw_: 'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20400%20300%22%20preserveAspectRatio%3D%22xMidYMid%20slice%22%3E%3Crect%20width%3D%22400%22%20height%3D%22300%22%20fill%3D%22%23f5f6f8%22%2F%3E%3Cg%20transform%3D%22translate%28200%20150%29%22%20fill%3D%22none%22%20stroke%3D%22%23c2cad6%22%20stroke-width%3D%225%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Crect%20x%3D%22-44%22%20y%3D%22-44%22%20width%3D%2288%22%20height%3D%2288%22%20rx%3D%2216%22%2F%3E%3Ccircle%20cx%3D%2218%22%20cy%3D%22-18%22%20r%3D%222.5%22%20fill%3D%22%23c2cad6%22%20stroke%3D%22none%22%2F%3E%3Cpath%20d%3D%22M-34%2030%20L-8%200%20L10%2018%20L20%208%20L34%2024%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E',
+};
+
+export interface ThemeShowcaseProps {
+ /** Product card images keyed by slot (watch/headphones/backpack/…). */
+ images?: Record;
+ /** The three hero product cards. Defaults to the neutral store products. */
+ products?: ProductSpec[];
+ /** Inventory table rows. Defaults to the neutral store inventory. */
+ inventory?: InventoryRow[];
+}
+
+// Default export is the route page (sandbox renders this as a Next.js page, so
+// it must take no props / satisfy PageProps). It renders the store with the
+// neutral defaults. Consumers that need per-theme content import the named
+// `ThemeShowcaseStore` below and pass images/products/inventory.
+export default function ThemeShowcase() {
+ return ;
+}
+
+export function ThemeShowcaseStore({
+ images = DEFAULT_IMAGES,
+ products = DEFAULT_PRODUCTS,
+ inventory = DEFAULT_INVENTORY,
+}: ThemeShowcaseProps = {}) {
+ const {isMobile} = useAppShellMobile();
+ return (
+
+ );
+}
+
+function CardShowcase({
+ images,
+ inventory,
+ isMobile,
+}: {
+ images: Record;
+ inventory: InventoryRow[];
+ isMobile: boolean;
+}) {
+ const columns = isMobile ? 1 : ({minWidth: 200, repeat: 'fit'} as const);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function StorePreview({
+ images,
+ products,
+ isMobile,
+}: {
+ images: Record;
+ products: ProductSpec[];
+ isMobile: boolean;
+}) {
+ return (
+
+
+ }
+ centerContent={
+ isMobile ? undefined : (
+ <>
+
+
+
+
+ >
+ )
+ }
+ endContent={
+
+
+ }
+ href="#"
+ />
+ }
+ href="#"
+ />
+ }
+ href="#"
+ />
+
+
+
+ }
+ />
+
+
+
+
+
+
+ Little joys,
+
+ everywhere you go
+
+
+ We believe the smallest details are the ones that matter most.
+ Turn an ordinary day into something worth remembering.
+
+
+
+
+
+ {products.map((p, i) => (
+
+
+
+
+
+
+
+
+
+
+
+ {p.name}
+
+
+ {p.description}
+
+
+ {}}
+ min={1}
+ max={99}
+ size="sm"
+ style={styles.quantityInput}
+ />
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+ );
+}
+
+function CheckoutCard({isMobile}: {isMobile: boolean}) {
+ return (
+
+
+ Checkout
+
+
+ {}}
+ size="lg"
+ />
+
+ {}}>
+
+ $12.00
+
+ }
+ />
+
+ $16.00
+
+ }
+ />
+
+ $24.00
+
+ }
+ />
+
+
+
+
+ Payment method
+
+
+ {}}
+ padding={3}>
+
+
+
+ Card
+
+
+
+ {}}
+ padding={3}>
+
+
+
+ Apple Pay
+
+
+
+ {}}
+ padding={3}>
+
+
+
+ Google Pay
+
+
+
+
+
+
+ {}}
+ startIcon={ }
+ size="lg"
+ />
+
+
+ {}}
+ size="lg"
+ />
+ {}}
+ size="lg"
+ />
+
+
+ {}}
+ size="lg"
+ options={[
+ {value: 'us', label: 'United States'},
+ {value: 'ca', label: 'Canada'},
+ {value: 'uk', label: 'United Kingdom'},
+ {value: 'de', label: 'Germany'},
+ {value: 'jp', label: 'Japan'},
+ {value: 'au', label: 'Australia'},
+ ]}
+ />
+
+
+ {}}
+ />
+
+ }
+ />
+
+
+ );
+}
+
+const SUGGESTED_QUESTIONS = [
+ 'Reschedule delivery',
+ 'Update shipping address',
+ 'Start a return',
+];
+
+function ChatCard() {
+ return (
+
+
+ Studio AI
+
+
+ }
+ />
+ }
+ />
+
+
+
+
+
+
+
+ Today
+
+
+
+ Where’s my order?
+
+
+
+
+
+
+ Your order #1043 — the Minimalist Watch and Linen Throw —
+ shipped this morning from the Aisle 3 warehouse and is currently
+ in transit with UPS. It’s on track to arrive at your address by
+ end of day tomorrow.
+
+
+ Let me know if you’d like to reschedule the delivery, redirect
+ it to a pickup point, or start a return once it arrives.
+
+
+
+
+
+
+ Can you show me the full details?
+
+
+
+
+
+ Here’s everything I have on order #1043:
+
+
+ -
+ $248
+
+ }
+ />
+
-
+ $12
+
+ }
+ />
+
}
+ />
+ - Track →}
+ />
+
+
+
+
+
+
+
+
+
+ {SUGGESTED_QUESTIONS.map(question => (
+
+ ))}
+
+
+
+
+ {}}
+ onSubmit={() => {}}
+ placeholder="Ask Studio AI…"
+ footerActions={
+ }
+ />
+ }
+ sendActions={
+ }
+ />
+ }
+ />
+
+
+ );
+}
+
+interface ActivityRow {
+ id: string;
+ icon: ReactNode;
+ label: string;
+ detail: string;
+ time: string;
+ amount: number;
+}
+
+const ACTIVITY: ActivityRow[] = [
+ {
+ id: '1',
+ icon: ,
+ label: 'Order #1043',
+ detail: 'Placed · 1:59 pm',
+ time: '1:59 pm',
+ amount: 248,
+ },
+ {
+ id: '2',
+ icon: ,
+ label: 'Order #1041',
+ detail: 'Refunded · 12:40 pm',
+ time: '12:40 pm',
+ amount: -89,
+ },
+ {
+ id: '3',
+ icon: ,
+ label: 'Order #1040',
+ detail: 'Placed · 10:30 am',
+ time: '10:30 am',
+ amount: 156,
+ },
+ {
+ id: '4',
+ icon: ,
+ label: 'Order #1038',
+ detail: 'Placed · 9:11 am',
+ time: '9:11 am',
+ amount: 412,
+ },
+ {
+ id: '5',
+ icon: ,
+ label: 'Order #1037',
+ detail: 'Placed · 8:42 am',
+ time: '8:42 am',
+ amount: 95,
+ },
+];
+
+function formatAmount(amount: number): string {
+ const sign = amount < 0 ? '−' : '+';
+ return sign + '$' + Math.abs(amount).toLocaleString();
+}
+
+function LatestActivityCard({isMobile}: {isMobile: boolean}) {
+ return (
+
+
+ Revenue
+
+
+
+ 18K
+
+ Monthly revenue
+
+
+
+ +12%
+
+ Order growth
+
+
+
+
+
+
+
+ Activity
+ See all
+
+
+
+ {ACTIVITY.map(item => (
+ -
+ {item.icon}
+
+ }
+ label={item.label}
+ description={item.detail}
+ endContent={
+
+ {formatAmount(item.amount)}
+
+ }
+ href="#"
+ />
+ ))}
+
+
+
+ );
+}
+
+type TagSpec = {label: string; variant: ShowcaseBadgeVariant};
+
+export interface InventoryRow extends Record {
+ id: string;
+ name: string;
+ meta: string;
+ available: number;
+ location: string;
+ tags: TagSpec[];
+ imageKey?: string;
+ thumbnailFallback: string;
+ selected: boolean;
+}
+
+const DEFAULT_INVENTORY: InventoryRow[] = [
+ {
+ id: 'a',
+ name: 'Minimalist Watch',
+ meta: 'Stainless steel, sapphire crystal',
+ available: 42,
+ location: 'Aisle 3',
+ tags: [{label: 'New', variant: 'blue'}],
+ imageKey: 'watch',
+ thumbnailFallback: 'M',
+ selected: false,
+ },
+ {
+ id: 'b',
+ name: 'Wireless Headphones',
+ meta: 'ANC, 30hr battery',
+ available: 128,
+ location: 'Aisle 1',
+ tags: [{label: 'Popular', variant: 'green'}],
+ imageKey: 'headphones',
+ thumbnailFallback: 'W',
+ selected: true,
+ },
+ {
+ id: 'c',
+ name: 'Canvas Backpack',
+ meta: 'Water-resistant, 25L',
+ available: 63,
+ location: 'Aisle 2',
+ tags: [{label: 'Limited', variant: 'yellow'}],
+ imageKey: 'backpack',
+ thumbnailFallback: 'C',
+ selected: false,
+ },
+ {
+ id: 'd',
+ name: 'Leather Wallet',
+ meta: 'Full-grain, RFID blocking',
+ available: 15,
+ location: 'Aisle 4',
+ tags: [{label: 'Leather', variant: 'yellow'}],
+ imageKey: 'wallet',
+ thumbnailFallback: 'L',
+ selected: true,
+ },
+ {
+ id: 'e',
+ name: 'Travel Tumbler',
+ meta: 'Vacuum insulated, 16oz',
+ available: 87,
+ location: 'Aisle 5',
+ tags: [{label: 'Drinkware', variant: 'green'}],
+ imageKey: 'tumbler',
+ thumbnailFallback: 'T',
+ selected: false,
+ },
+ {
+ id: 'f',
+ name: 'Linen Throw',
+ meta: 'Heavyweight, oat',
+ available: 24,
+ location: 'Aisle 6',
+ tags: [{label: 'Home', variant: 'orange'}],
+ imageKey: 'throw_',
+ thumbnailFallback: 'L',
+ selected: true,
+ },
+];
+
+const LOW_STOCK_THRESHOLD = 25;
+
+function SelectCell({row}: {row: InventoryRow}) {
+ return (
+ {}}
+ />
+ );
+}
+
+function ItemCell({
+ row,
+ images,
+}: {
+ row: InventoryRow;
+ images: Record;
+}) {
+ const thumbnailSrc = row.imageKey ? images[row.imageKey] : undefined;
+ return (
+
+ {thumbnailSrc ? (
+
+ ) : (
+
+ {row.thumbnailFallback}
+
+ )}
+
+
+ {row.name}
+
+
+ {row.meta}
+
+
+
+ );
+}
+
+function TagsCell({row}: {row: InventoryRow}) {
+ return (
+
+ {row.tags.map(tag => (
+
+ ))}
+
+ );
+}
+
+function ActionsCell() {
+ return (
+
+ );
+}
+
+function InventoryCard({
+ images,
+ inventory,
+}: {
+ images: Record;
+ inventory: InventoryRow[];
+}) {
+ const lowStockCount = inventory.filter(
+ row => row.available < LOW_STOCK_THRESHOLD,
+ ).length;
+ return (
+
+
+ Inventory
+ }
+ />
+
+
+
+
+
+
+ {}}
+ startIcon={ }
+ style={styles.searchInput}
+ />
+ (
+ }
+ />
+ )}>
+ }
+ value={undefined}
+ onChange={() => {}}
+ options={['Wearables', 'Audio', 'Bags', 'Drinkware', 'Home']}
+ />
+ }
+ value={undefined}
+ onChange={() => {}}
+ options={[
+ 'Aisle 1',
+ 'Aisle 2',
+ 'Aisle 3',
+ 'Aisle 4',
+ 'Aisle 5',
+ 'Aisle 6',
+ ]}
+ />
+ }
+ value={undefined}
+ onChange={() => {}}
+ options={[
+ 'New',
+ 'Popular',
+ 'Limited',
+ 'Leather',
+ 'Drinkware',
+ 'Home',
+ ]}
+ />
+
+
+
+ }
+ />
+ }
+ />
+
+
+
+ {lowStockCount > 0 && (
+
+
+
+ )}
+
+
+
+ data={inventory}
+ columns={[
+ {
+ key: 'select',
+ header: '',
+ // Wide enough that the control + the theme's cell padding (up to
+ // --spacing-4 = 16px/side on spacious density) fit inside the cell,
+ // so the control's hover background doesn't overflow toward the
+ // card's clipped (rounded) edge on larger-padding themes.
+ width: pixel(64),
+ renderCell: row => ,
+ },
+ {
+ key: 'item',
+ header: 'Item',
+ // Lower min-width (default 120) so the table fits its container on
+ // larger-spacing themes instead of overflowing the actions column.
+ width: proportional(3, {minWidth: 80}),
+ renderCell: row => ,
+ },
+ {
+ key: 'available',
+ header: 'Available',
+ width: pixel(100),
+ renderCell: row => {row.available} ,
+ },
+ {
+ key: 'location',
+ header: 'Location',
+ width: pixel(100),
+ renderCell: row => {row.location} ,
+ },
+ {
+ key: 'tags',
+ header: 'Tags',
+ width: proportional(2, {minWidth: 80}),
+ align: 'end',
+ renderCell: row => ,
+ },
+ {
+ key: 'actions',
+ header: '',
+ // Match the select column: fit the sm more-menu button + cell
+ // padding so its hover background stays clear of the card's
+ // clipped rounded edge across themes.
+ width: pixel(64),
+ align: 'end',
+ renderCell: () => ,
+ },
+ ]}
+ density="spacious"
+ dividers="rows"
+ hasHover
+ />
+
+
+ );
+}
diff --git a/src/index.css b/src/index.css
index ac82ccb..f96c4db 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1,5 +1,30 @@
+/* ── PHASE 1 SPIKE — Astryx ─────────────────────────────────────────────────
+ Experimental. Revert by restoring `@import "tailwindcss";` in place of the
+ block below and dropping the three @astryxdesign/@stylexjs dependencies.
+
+ The layer order and the split of `@import "tailwindcss"` into its three
+ parts are both prescribed verbatim by the Usage block at the top of
+ @astryxdesign/core/src/tailwind-theme.css — this is not our invention. The
+ ordering matters: `utilities` sits last, so Tailwind utilities keep winning
+ over `astryx-base`, which is what makes a half-migrated file behave.
+
+ Note `astryx.css` loads BEFORE the theme package: the theme supplies the
+ token values that the component CSS references. */
+@layer reset, theme, base, astryx-base, astryx-theme, components, utilities;
+
@import url('https://fonts.googleapis.com/css2?family=Public+Sans:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;700&display=swap');
-@import "tailwindcss";
+
+@import "tailwindcss/theme.css" layer(theme);
+@import "tailwindcss/preflight.css" layer(base);
+@import "@astryxdesign/core/reset.css";
+@import "@astryxdesign/core/astryx.css";
+/* Our own theme, compiled by `astryx theme build ./src/theme/nearle.ts`.
+ Regenerate with that command after editing nearle.ts — nearle.css is
+ generated and must not be hand-edited. theme-neutral was scaffolding for
+ the Phase 1 spike and is no longer imported. */
+@import "./theme/nearle.css";
+@import "@astryxdesign/core/tailwind-theme.css";
+@import "tailwindcss/utilities.css" layer(utilities);
@theme {
--font-sans: "Public Sans", ui-sans-serif, system-ui, sans-serif;
diff --git a/src/main.tsx b/src/main.tsx
index e66cc21..0ca5718 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -34,15 +34,47 @@ const queryClient = new QueryClient({
});
import { BrowserRouter } from 'react-router-dom';
+import { Theme } from '@astryxdesign/core';
+import { ToastViewport } from '@astryxdesign/core/Toast';
+import { nearleTheme } from './theme/nearle';
createRoot(document.getElementById('root')!).render(
-
-
-
-
-
-
-
+ {/*
+ Theme has to wrap the app, not sit beside it: it stamps the
+ `data-astryx-theme` / `data-theme` attributes that every token in
+ nearle.css is scoped to. Without it the stylesheet loads and applies to
+ nothing.
+
+ mode is deliberately "light", NOT "system", and it must stay that way
+ until the migration finishes.
+
+ The theme carries a full dark palette and `mode="system"` does work — but
+ only Astryx-backed components respond to it. Everything still on raw
+ Tailwind is pinned to the light scheme by hardcoded classes: KpiStrip
+ renders `bg-white text-slate-900`, SearchPill sets `color: TEXT`
+ (#0f172a). Switching to "system" today means any user whose OS is dark
+ gets white cards on a dark page and a search box with black text on a
+ black field. Verified in the browser, not assumed.
+
+ Flip this to "system" once Phase 6 has removed the hardcoded colours.
+ That single word is the dark-mode launch switch.
+
+ Outermost so nothing above it renders unthemed, but inside StrictMode so
+ it gets the same double-render checks as everything else.
+ */}
+
+ {/* One viewport for the whole app. useToast() anywhere below this renders
+ into it — see components/Toasts.tsx. */}
+
+
+
+
+
+
+
+
+
+
,
);
diff --git a/src/services/fiestaApi.ts b/src/services/fiestaApi.ts
index daa004b..e79c12b 100644
--- a/src/services/fiestaApi.ts
+++ b/src/services/fiestaApi.ts
@@ -42,7 +42,10 @@ export function buildStoreQrPayload(opts: { tenantid: number; locationid: number
return JSON.stringify({ tenantid: opts.tenantid, locationid: opts.locationid });
}
-async function fiestaGet(endpoint: string, params: QueryParams = {}): Promise {
+// Exported so ad-hoc probes (test-api.ts) can hit an endpoint that has no
+// wrapper yet. Every function below is the supported way in; reach for this
+// only when checking something against the live API.
+export async function fiestaGet(endpoint: string, params: QueryParams = {}): Promise {
const qs = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => {
// Fiesta requires some params to be present-but-empty (e.g. keyword=), so we
diff --git a/src/theme/nearle.css b/src/theme/nearle.css
new file mode 100644
index 0000000..d924e33
--- /dev/null
+++ b/src/theme/nearle.css
@@ -0,0 +1,132 @@
+/*
+ * @generated by `astryx theme build` — do not edit manually.
+ * Source: src\theme\nearle.ts
+ * Command: astryx theme build src\theme\nearle.ts
+ * CLI: @astryxdesign/cli@0.3.0
+ * Core: @astryxdesign/core@0.3.0
+ */
+
+@layer reset {
+@scope ([data-astryx-theme="nearle"]) to ([data-astryx-theme]) {
+ :where(h1, h2, h3, h4, h5, h6) {
+ font-family: var(--font-family-heading);
+ color: var(--color-text-primary);
+ }
+
+ :where(h1) {
+ font-size: var(--text-heading-1-size);
+ font-weight: var(--text-heading-1-weight);
+ line-height: var(--text-heading-1-leading);
+ }
+
+ :where(h2) {
+ font-size: var(--text-heading-2-size);
+ font-weight: var(--text-heading-2-weight);
+ line-height: var(--text-heading-2-leading);
+ }
+
+ :where(h3) {
+ font-size: var(--text-heading-3-size);
+ font-weight: var(--text-heading-3-weight);
+ line-height: var(--text-heading-3-leading);
+ }
+
+ :where(h4) {
+ font-size: var(--text-heading-4-size);
+ font-weight: var(--text-heading-4-weight);
+ line-height: var(--text-heading-4-leading);
+ }
+
+ :where(h5) {
+ font-size: var(--text-heading-5-size);
+ font-weight: var(--text-heading-5-weight);
+ line-height: var(--text-heading-5-leading);
+ }
+
+ :where(h6) {
+ font-size: var(--text-heading-6-size);
+ font-weight: var(--text-heading-6-weight);
+ line-height: var(--text-heading-6-leading);
+ }
+
+ :where(p) {
+ font-family: var(--font-family-body);
+ font-size: var(--text-body-size);
+ font-weight: var(--text-body-weight);
+ line-height: var(--text-body-leading);
+ color: var(--color-text-primary);
+ }
+
+ :where(small) {
+ font-size: var(--text-supporting-size);
+ font-weight: var(--text-supporting-weight);
+ line-height: var(--text-supporting-leading);
+ color: var(--color-text-secondary);
+ }
+
+ :where(code, pre) {
+ font-family: var(--font-family-code);
+ font-size: var(--text-code-size);
+ line-height: var(--text-code-leading);
+ }
+
+ :where(hr) {
+ border: none;
+ border-top: 1px solid var(--color-border);
+ }
+}
+}
+
+@layer astryx-theme {
+ :root { color-scheme: light dark; }
+ html[data-theme="light"] { color-scheme: light; }
+ html[data-theme="dark"] { color-scheme: dark; }
+
+@scope ([data-astryx-theme="nearle"]) to ([data-astryx-theme]) {
+ :scope {
+ --font-family-body: "Public Sans", ui-sans-serif, system-ui, sans-serif;
+ --font-family-heading: "Public Sans", ui-sans-serif, system-ui, sans-serif;
+ --font-family-code: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
+ --color-accent: light-dark(#662582, #B57FD0);
+ --color-on-accent: light-dark(#FFFFFF, #1B1420);
+ --color-accent-muted: light-dark(#66258218, #B57FD024);
+ --color-text-accent: light-dark(#662582, #B57FD0);
+ --color-icon-accent: light-dark(#662582, #B57FD0);
+ --color-text-primary: light-dark(#0f172a, #ECE6F1);
+ --color-text-secondary: light-dark(#64748b, #B3A8BD);
+ --color-text-disabled: light-dark(#94a3b8, #85798F);
+ --color-background-surface: light-dark(#FFFFFF, #1C1724);
+ --color-background-body: light-dark(#f8fafc, #14101A);
+ --color-background-card: light-dark(#FFFFFF, #1C1724);
+ --color-background-popover: light-dark(#FFFFFF, #241D2E);
+ --color-background-muted: light-dark(#f1f5f9, #241D2E);
+ --color-border: light-dark(#e2e8f0, #352C40);
+ --color-border-emphasized: light-dark(#cbd5e1, #4A3E58);
+ --color-skeleton: light-dark(#f1f5f9, #241D2E);
+ --color-success: light-dark(#10b981, #34D399);
+ --color-error: light-dark(#ef4444, #F87171);
+ --color-warning: light-dark(#f59e0b, #FBBF24);
+ --shadow-low: light-dark(0 8px 24px rgba(15, 23, 42, 0.08), 0 8px 24px rgba(0, 0, 0, 0.45));
+ --shadow-med: light-dark(0 14px 40px rgba(15, 23, 42, 0.10), 0 14px 40px rgba(0, 0, 0, 0.55));
+ --shadow-high: light-dark(0 18px 50px rgba(15, 23, 42, 0.18), 0 18px 50px rgba(0, 0, 0, 0.65));
+ }
+}
+}
+
+@layer astryx-theme {
+@scope ([data-astryx-theme="nearle"]) to ([data-astryx-theme]) {
+ [data-astryx-media="dark"] {
+ color-scheme: dark;
+ --color-text-primary: var(--color-on-dark);
+ --color-icon-primary: var(--color-on-dark);
+ --color-accent: var(--color-on-dark);
+ }
+
+ [data-astryx-media="light"] {
+ color-scheme: light;
+ --color-text-primary: var(--color-on-light);
+ --color-icon-primary: var(--color-on-light);
+ --color-accent: var(--color-on-light);
+ }
+}
+}
diff --git a/src/theme/nearle.d.ts b/src/theme/nearle.d.ts
new file mode 100644
index 0000000..4087815
--- /dev/null
+++ b/src/theme/nearle.d.ts
@@ -0,0 +1,10 @@
+/**
+ * @generated by `astryx theme build` — do not edit manually.
+ * Source: src\theme\nearle.ts
+ * Command: astryx theme build src\theme\nearle.ts
+ * CLI: @astryxdesign/cli@0.3.0
+ * Core: @astryxdesign/core@0.3.0
+ */
+
+import type { DefinedTheme } from '@astryxdesign/core/theme';
+export declare const nearleTheme: DefinedTheme;
diff --git a/src/theme/nearle.js b/src/theme/nearle.js
new file mode 100644
index 0000000..b624a8d
--- /dev/null
+++ b/src/theme/nearle.js
@@ -0,0 +1,47 @@
+/**
+ * @generated by `astryx theme build` — do not edit manually.
+ * Source: src\theme\nearle.ts
+ * Command: astryx theme build src\theme\nearle.ts
+ * CLI: @astryxdesign/cli@0.3.0
+ * Core: @astryxdesign/core@0.3.0
+ */
+
+/**
+ * nearle theme — built by `npx astryx theme build`
+ * Import the CSS file alongside this module:
+ *
+ * import { nearleTheme } from './nearle';
+ * import './nearle.css';
+ */
+export const nearleTheme = {
+ name: 'nearle',
+ __built: true,
+ tokens: {
+ "--font-family-body": "\"Public Sans\", ui-sans-serif, system-ui, sans-serif",
+ "--font-family-heading": "\"Public Sans\", ui-sans-serif, system-ui, sans-serif",
+ "--font-family-code": "\"JetBrains Mono\", ui-monospace, SFMono-Regular, monospace",
+ "--color-accent": "light-dark(#662582, #B57FD0)",
+ "--color-on-accent": "light-dark(#FFFFFF, #1B1420)",
+ "--color-accent-muted": "light-dark(#66258218, #B57FD024)",
+ "--color-text-accent": "light-dark(#662582, #B57FD0)",
+ "--color-icon-accent": "light-dark(#662582, #B57FD0)",
+ "--color-text-primary": "light-dark(#0f172a, #ECE6F1)",
+ "--color-text-secondary": "light-dark(#64748b, #B3A8BD)",
+ "--color-text-disabled": "light-dark(#94a3b8, #85798F)",
+ "--color-background-surface": "light-dark(#FFFFFF, #1C1724)",
+ "--color-background-body": "light-dark(#f8fafc, #14101A)",
+ "--color-background-card": "light-dark(#FFFFFF, #1C1724)",
+ "--color-background-popover": "light-dark(#FFFFFF, #241D2E)",
+ "--color-background-muted": "light-dark(#f1f5f9, #241D2E)",
+ "--color-border": "light-dark(#e2e8f0, #352C40)",
+ "--color-border-emphasized": "light-dark(#cbd5e1, #4A3E58)",
+ "--color-skeleton": "light-dark(#f1f5f9, #241D2E)",
+ "--color-success": "light-dark(#10b981, #34D399)",
+ "--color-error": "light-dark(#ef4444, #F87171)",
+ "--color-warning": "light-dark(#f59e0b, #FBBF24)",
+ "--shadow-low": "light-dark(0 8px 24px rgba(15, 23, 42, 0.08), 0 8px 24px rgba(0, 0, 0, 0.45))",
+ "--shadow-med": "light-dark(0 14px 40px rgba(15, 23, 42, 0.10), 0 14px 40px rgba(0, 0, 0, 0.55))",
+ "--shadow-high": "light-dark(0 18px 50px rgba(15, 23, 42, 0.18), 0 18px 50px rgba(0, 0, 0, 0.65))"
+ },
+
+};
diff --git a/src/theme/nearle.ts b/src/theme/nearle.ts
new file mode 100644
index 0000000..542a9b2
--- /dev/null
+++ b/src/theme/nearle.ts
@@ -0,0 +1,102 @@
+/**
+ * The Nearle theme for Astryx — PHASE 2.
+ *
+ * Every value here already existed in the codebase; nothing was invented for
+ * aesthetics. The light column is lifted verbatim from the eleven hardcoded
+ * constants at the top of `components/consoleUi.tsx` and from the two status
+ * colour maps beneath them. The dark column is new, because the console has
+ * never had a dark mode and there was nothing to lift.
+ *
+ * Written by hand rather than by `astryx theme`: that CLI command is an
+ * interactive wizard, and a generated file would still have needed every value
+ * below typed into it.
+ *
+ * TOKEN VALUES take one of two forms (see defineTheme's contract):
+ * 'string' — same in both modes
+ * ['light', 'dark'] — compiled to CSS light-dark()
+ *
+ * HOW MODE IS CHOSEN. `theme-neutral` ships no `prefers-color-scheme` block at
+ * all; Astryx switches on a `data-theme` attribute that the `` provider
+ * stamps. So `mode="system"` on the provider is what connects this to the OS
+ * setting — the stylesheet alone will not do it.
+ */
+
+import { defineTheme } from '@astryxdesign/core';
+
+/**
+ * The dark accent is the one value in this file that is not simply lifted, and
+ * it is the one that needs a human's approval.
+ *
+ * `BRAND` #662582 scores 9.76:1 on white — comfortably AA. On Astryx's dark
+ * surface #1F1F22 it is unreadable, and `BRAND_LIGHT` #9255AB only reaches
+ * **3.22:1**, which is below the 4.5:1 threshold. Measured candidates:
+ *
+ * #9255AB 3.22:1 BRAND_LIGHT as-is fails AA
+ * #A96FC4 4.51:1 passes by 0.01 no headroom
+ * #B57FD0 5.41:1 passes with room ← chosen
+ * #C48FDD 6.51:1 passes, drifts pastel
+ *
+ * #B57FD0 is the lightest value that still reads as the Nearle purple rather
+ * than as lilac. **Brand owner to confirm before dark mode ships.**
+ */
+const BRAND_DARK = '#B57FD0';
+
+export const nearleTheme = defineTheme({
+ name: 'nearle',
+
+ typography: {
+ // Both families are already pulled in by the Google Fonts @import at the
+ // top of index.css, so this re-points Astryx at them rather than adding a
+ // request. Astryx does not load fonts itself — the consumer must.
+ body: { family: 'Public Sans', fallbacks: 'ui-sans-serif, system-ui, sans-serif' },
+ heading: { family: 'Public Sans', fallbacks: 'ui-sans-serif, system-ui, sans-serif' },
+ code: { family: 'JetBrains Mono', fallbacks: 'ui-monospace, SFMono-Regular, monospace' },
+ },
+
+ tokens: {
+ // ── Brand ───────────────────────────────────────────────────────────────
+ '--color-accent': ['#662582', BRAND_DARK],
+ '--color-on-accent': ['#FFFFFF', '#1B1420'],
+ // The alpha suffixes mirror consoleUi's `soft()` helper (18 ≈ 9%), which is
+ // what every tinted brand surface in the app is currently built from.
+ '--color-accent-muted': ['#66258218', `${BRAND_DARK}24`],
+ '--color-text-accent': ['#662582', BRAND_DARK],
+ '--color-icon-accent': ['#662582', BRAND_DARK],
+
+ // ── Text ── TEXT / TEXT_2 / TEXT_3 ──────────────────────────────────────
+ '--color-text-primary': ['#0f172a', '#ECE6F1'],
+ '--color-text-secondary': ['#64748b', '#B3A8BD'],
+ '--color-text-disabled': ['#94a3b8', '#85798F'],
+
+ // ── Surfaces ── SURFACE_ALT / DIVIDER ───────────────────────────────────
+ // The dark greys carry a slight violet bias so they sit under the accent
+ // as a chosen neutral rather than a stock grey.
+ '--color-background-surface': ['#FFFFFF', '#1C1724'],
+ '--color-background-body': ['#f8fafc', '#14101A'],
+ '--color-background-card': ['#FFFFFF', '#1C1724'],
+ '--color-background-popover': ['#FFFFFF', '#241D2E'],
+ '--color-background-muted': ['#f1f5f9', '#241D2E'],
+
+ // ── Lines ── BORDER / DIVIDER ───────────────────────────────────────────
+ '--color-border': ['#e2e8f0', '#352C40'],
+ '--color-border-emphasized': ['#cbd5e1', '#4A3E58'],
+ '--color-skeleton': ['#f1f5f9', '#241D2E'],
+
+ // ── Semantic ────────────────────────────────────────────────────────────
+ // Taken from ORDER_STATUS / DELIVERY_STATUS so a Badge and a status pill
+ // agree. Dark variants are lightened for the same contrast reason as the
+ // accent — the source values are tuned for white.
+ '--color-success': ['#10b981', '#34D399'],
+ '--color-error': ['#ef4444', '#F87171'],
+ '--color-warning': ['#f59e0b', '#FBBF24'],
+
+ // ── Elevation ── SHADOW_MD / SOFT / POP ─────────────────────────────────
+ // Dark mode gets its own shadows rather than the same rgba: a shadow tuned
+ // for a white ground is invisible on a dark one, so the alpha is raised.
+ '--shadow-low': ['0 8px 24px rgba(15, 23, 42, 0.08)', '0 8px 24px rgba(0, 0, 0, 0.45)'],
+ '--shadow-med': ['0 14px 40px rgba(15, 23, 42, 0.10)', '0 14px 40px rgba(0, 0, 0, 0.55)'],
+ '--shadow-high': ['0 18px 50px rgba(15, 23, 42, 0.18)', '0 18px 50px rgba(0, 0, 0, 0.65)'],
+ },
+});
+
+export default nearleTheme;