{"_id":"@ascendenceai/cortena-extensions-shared-web","_rev":"2-56bb4b7a6f344cb45fa8b7c89a912081","name":"@ascendenceai/cortena-extensions-shared-web","dist-tags":{"latest":"0.4.0"},"versions":{"0.1.0":{"name":"@ascendenceai/cortena-extensions-shared-web","version":"0.1.0","license":"SEE LICENSE IN LICENSE","_id":"@ascendenceai/cortena-extensions-shared-web@0.1.0","maintainers":[{"name":"amit_ascendence","email":"connect@mindmentors.net"}],"homepage":"https://github.com/Ascendence-AI-Technology-Pvt-Ltd/cortena-extensions#readme","bugs":{"url":"https://github.com/Ascendence-AI-Technology-Pvt-Ltd/cortena-extensions/issues"},"dist":{"shasum":"3b4ee12e130bb89ab9120ec5e54a09b2541f86da","tarball":"https://registry.npmjs.org/@ascendenceai/cortena-extensions-shared-web/-/cortena-extensions-shared-web-0.1.0.tgz","fileCount":33,"integrity":"sha512-cGJAjYKKo3QB+b+lzWo/69+ChIoIun2Tj38l+iXymk0H26w3Oy1rhORpNsXPtuF8wjpDvPhFh3Owzk2oDy9Xug==","signatures":[{"sig":"MEUCIQDsaMPPqO0hOMx44TWJPlIp13M6uF2lFQwTHHCO6v2+HQIgcSViQNrjeAsHgIYTvIGXo8pq34VPL3o11zla6PYzPE8=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":88053},"main":"dist/index.js","type":"module","_from":"file:ascendenceai-cortena-extensions-shared-web-0.1.0.tgz","types":"dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"./package.json":"./package.json","./react-router":{"types":"./dist/react-router.d.ts","default":"./dist/react-router.js"}},"scripts":{"lint":"tsc --noEmit -p tsconfig.test.json","test":"vitest run","build":"tsc","typecheck":"tsc --noEmit -p tsconfig.test.json"},"_npmUser":{"name":"amit_ascendence","email":"connect@mindmentors.net"},"_resolved":"/private/var/folders/yz/wdclk8jx2l3c4bkzs3wg_0z80000gp/T/478dd16d60f212dc6e4293572ec9dbd4/ascendenceai-cortena-extensions-shared-web-0.1.0.tgz","_integrity":"sha512-cGJAjYKKo3QB+b+lzWo/69+ChIoIun2Tj38l+iXymk0H26w3Oy1rhORpNsXPtuF8wjpDvPhFh3Owzk2oDy9Xug==","repository":{"url":"git+https://github.com/Ascendence-AI-Technology-Pvt-Ltd/cortena-extensions.git","type":"git","directory":"packages/shared-web"},"_npmVersion":"11.9.0","description":"The web half of the extension boilerplate: URL-first list state, so view, filter, sort, page and selection survive navigation and reload (§8).","directories":{},"_nodeVersion":"25.6.1","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"zod":"^3.24.0","jsdom":"^25.0.1","react":"^19.1.0","vitest":"^3.0.0","react-dom":"^19.1.0","typescript":"^5.7.0","@types/react":"^19.1.0","react-router":"^7.6.0","@types/react-dom":"^19.1.0","@testing-library/react":"^16.3.2"},"peerDependencies":{"zod":"^3.24.0","react":">=18.0.0","react-router":">=7.0.0"},"peerDependenciesMeta":{"react-router":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/cortena-extensions-shared-web_0.1.0_1788785621919_0.7677192978619718","host":"s3://npm-registry-packages-npm-production"}},"0.4.0":{"_id":"@ascendenceai/cortena-extensions-shared-web@0.4.0","bugs":{"url":"https://github.com/Ascendence-AI-Technology-Pvt-Ltd/cortena-extensions/issues"},"dist":{"shasum":"3e9ce1d25d8186352871ddffb8bb5f566d62f598","tarball":"https://registry.npmjs.org/@ascendenceai/cortena-extensions-shared-web/-/cortena-extensions-shared-web-0.4.0.tgz","fileCount":33,"integrity":"sha512-Plb+UzOs1ZNzXneFtq1VmDoMaIGx8O/NPE38gBCkedHLcuOqhjaRP0HrCHeGcthWxEK/ZklpsXAWlijBR8hqpw==","signatures":[{"sig":"MEUCIEPChUyQQ3UZfIW6AkHGdpmuYgI5baKp6t6gnznOTMBRAiEAyjFudfd8gxv8u+7OePOB+tp3j12iRz8D3eJJex5ALLg=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEYCIQCRni4OANQGPCv2vFOA/3oya6hhW15FLKfbYSxk8n9vKQIhAL3c3RC3nT8y9OWRWqCOfngwXwziEpUwnPk4S/Y6fGt0"}],"unpackedSize":88053},"main":"dist/index.js","name":"@ascendenceai/cortena-extensions-shared-web","type":"module","_from":"file:ascendenceai-cortena-extensions-shared-web-0.4.0.tgz","types":"dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"./package.json":"./package.json","./react-router":{"types":"./dist/react-router.d.ts","default":"./dist/react-router.js"}},"license":"SEE LICENSE IN LICENSE","scripts":{"lint":"tsc --noEmit -p tsconfig.test.json","test":"vitest run","build":"tsc","typecheck":"tsc --noEmit -p tsconfig.test.json"},"version":"0.4.0","_npmUser":{"name":"amit_ascendence","email":"connect@mindmentors.net"},"homepage":"https://github.com/Ascendence-AI-Technology-Pvt-Ltd/cortena-extensions#readme","_resolved":"/private/var/folders/yz/wdclk8jx2l3c4bkzs3wg_0z80000gp/T/71eaedf3e33cfbbc50ae6c481edc65ea/ascendenceai-cortena-extensions-shared-web-0.4.0.tgz","_integrity":"sha512-Plb+UzOs1ZNzXneFtq1VmDoMaIGx8O/NPE38gBCkedHLcuOqhjaRP0HrCHeGcthWxEK/ZklpsXAWlijBR8hqpw==","repository":{"url":"git+https://github.com/Ascendence-AI-Technology-Pvt-Ltd/cortena-extensions.git","type":"git","directory":"packages/shared-web"},"_npmVersion":"11.9.0","description":"The web half of the extension boilerplate: URL-first list state, so view, filter, sort, page and selection survive navigation and reload (§8).","directories":{},"maintainers":[{"name":"amit_ascendence","email":"connect@mindmentors.net"}],"_nodeVersion":"25.6.1","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"zod":"^3.24.0","jsdom":"^25.0.1","react":"^19.1.0","vitest":"^3.0.0","react-dom":"^19.1.0","typescript":"^5.7.0","@types/react":"^19.1.0","react-router":"^7.6.0","@types/react-dom":"^19.1.0","@testing-library/react":"^16.3.2"},"peerDependencies":{"zod":"^3.24.0","react":">=18.0.0","react-router":">=7.0.0"},"peerDependenciesMeta":{"react-router":{"optional":true}},"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/cortena-extensions-shared-web_0.4.0_1790491386176_0.06168779709321681"}}},"time":{"created":"2026-09-07T12:53:41.760Z","modified":"2026-09-27T06:43:06.434Z","0.1.0":"2026-09-07T12:53:42.057Z","0.4.0":"2026-09-27T06:43:06.255Z"},"bugs":{"url":"https://github.com/Ascendence-AI-Technology-Pvt-Ltd/cortena-extensions/issues"},"license":"SEE LICENSE IN LICENSE","homepage":"https://github.com/Ascendence-AI-Technology-Pvt-Ltd/cortena-extensions#readme","repository":{"url":"git+https://github.com/Ascendence-AI-Technology-Pvt-Ltd/cortena-extensions.git","type":"git","directory":"packages/shared-web"},"description":"The web half of the extension boilerplate: URL-first list state, so view, filter, sort, page and selection survive navigation and reload (§8).","maintainers":[{"name":"amit_ascendence","email":"connect@mindmentors.net"}],"readme":"# @ascendenceai/cortena-extensions-shared-web\n\nThe web half of the extension boilerplate. `@ascendenceai/cortena-extensions-shared` is the\nbackend half: it has an Express peer, a Drizzle peer and a `cortena-openapi`\nbinary, and its tarball is installed by `extensions/*/functions`. React does not\nbelong in it, and no web app imports it today. So the browser code lives here,\nnext to `@ascendenceai/cortena-extensions-web-auth`, with React as a peer and react-router as\nan optional one.\n\nToday it carries §8 of the protocol: **a list's view mode, filters, sort, page\nand selection survive navigation, Back, reload and being pasted into another\ntab.**\n\n```\npnpm add @ascendenceai/cortena-extensions-shared-web\n```\n\n## The rule\n\nThe example that produced it: open Tasks, switch to List, open a task, close it\n— and you land on Board. One small bug, on every single interaction.\n\n* **URL first.** View mode, filters, sort, page and everything else that fits go\n  in the search params, so the URL *is* the view. Nothing that survives a\n  navigation lives in component state.\n* **The list route owns the state.** The detail opens as a nested route over the\n  list (`/tasks?view=list` → `/tasks/42?view=list`), so the list is never\n  unmounted and the URL is still shareable. A Sheet over the list is allowed;\n  the nested route is the default.\n* **Never `navigate(-1)`.** It breaks deep links, and after a delete it lands the\n  user on the thing they just deleted. `useReturnToList()` builds a real href.\n* **Only what cannot fit falls back to the browser.** Selection can run to\n  hundreds of ids, so it is kept per user, per route, in `localStorage` under\n  `cortena.list-state.<routeKey>`, and the URL carries a short marker.\n\n## `useListState(routeKey, schema)`\n\n```tsx\nimport { useListState } from '@ascendenceai/cortena-extensions-shared-web';\nimport { z } from 'zod';\n\nconst listState = z.object({\n  view: z.enum(['board', 'list']).default('board'),\n  q: z.string().default(''),\n  sort: z.string().default(''),                       // \"name,-createdAt\"\n  page: z.coerce.number().int().min(0).default(0),    // zero-based, like TableQuery\n  pageSize: z.coerce.number().int().min(1).max(200).default(50),\n  selected: z.array(z.string()).default([]),\n});\n\nexport function TasksPage() {\n  const [state, setState] = useListState('tasks', listState, {\n    store: { selected: 'local' },\n  });\n  …\n}\n```\n\n`setState` **merges**: `setState({ page: 0 })` leaves the filters where they\nare. It also takes an updater — `setState((s) => ({ page: s.page + 1 }))` — and\na per-call history mode.\n\nThe schema is deliberately the same shape as `TableQuery` (§10), so a list's URL\nand its server query are one object rather than two that drift.\n\n### Options\n\n| option | what it does |\n| --- | --- |\n| `store` | `{ selected: 'local' }` keeps a field in `localStorage` instead of the URL. `.meta({ store: 'local' })` on the field says the same thing where the zod version in use carries metadata. |\n| `history` | `'replace'` (default) or `'push'` for every write. Replace keeps a filter keystroke out of the history stack, so Back leaves the list rather than unwinding typing one character at a time. Pass `{ history: 'push' }` on a call the user should be able to undo with Back. |\n| `adapter` | The router. Defaults to the provider's, then to `window.location` + `history`. |\n| `storage` | Injectable for tests; `null` turns persistence off. |\n| `onInvalid` | Called with the names of params the schema refused, for a log line. |\n| `markers` | Whether a `local` field leaves `<key>=~<n>` in the URL. On by default. |\n\n### The encoding\n\nA list URL is a public interface, so the same state produces the same string in\nevery build:\n\n```\n/tasks?view=list&q=alpha+beta&sort=name,-createdAt&page=2&selected=~3\n```\n\n* A field equal to its default is **absent** — a clean URL is the default view,\n  and a shared link carries only what the user actually changed.\n* Strings ride raw. Numbers are digits. Booleans are `1` / `0` (`true`, `yes`,\n  `on` are read too, because people type those by hand).\n* Arrays are a comma list, an item's own comma escaped `\\,`. This is the\n  encoding §10's `parseTableQuery` already reads.\n* Anything else is JSON.\n* A field held in the browser carries only `~<n>`, the count. Opening that link\n  in another browser gives the field's default, not an error.\n* Keys come out in schema order, and any param the schema does not own is kept\n  after them in the order it arrived.\n* A value the schema refuses falls back to that field's default and **the rest of\n  the URL survives** — one stale link must never blank a screen. When the whole\n  object is refused and the schema has a field with no default (a scoped list's\n  `orgId: z.string()`), there is no valid empty state to fall back to, so the\n  individually-valid fields are handed back and every one of them is reported to\n  `onInvalid`. Decoding a URL never throws out of a render.\n\n### `usePersistedViewState(routeKey, schema)`\n\nThe half that has no business in a link — column visibility, density, panel\nwidths. Per user, per route, in the browser store, with nothing at all in the\nURL.\n\n## Routers\n\nEvery Cortena extension web app is on react-router, so mount the adapter once,\ninside the router and above the routes:\n\n```tsx\nimport { ReactRouterListState } from '@ascendenceai/cortena-extensions-shared-web/react-router';\n\n<BrowserRouter>\n  <ReactRouterListState>\n    <Routes>…</Routes>\n  </ReactRouterListState>\n</BrowserRouter>\n```\n\nWithout a provider the hooks fall back to `window.location` + `history`, which\nworks in an app with no router at all. Anything that can read a location and\nwrite one is an adapter — `{ pathname, search, push, replace, subscribe? }` — so\na test can pass a stub.\n\n## Opening a detail\n\n```tsx\n<Route path=\"tasks\" element={<TasksList />}>       {/* renders <Outlet /> */}\n  <Route path=\":taskId\" element={<TaskDetail />} />\n</Route>\n```\n\n```tsx\nimport { Link } from 'react-router';\nimport { ListStateLink, useReturnToList } from '@ascendenceai/cortena-extensions-shared-web';\n\n// in the list — the whole list state comes along\n<ListStateLink to={`/tasks/${row.id}`} as={Link}>{row.title}</ListStateLink>\n\n// in the detail — the way back, and the post-delete redirect\nconst back = useReturnToList();          // href, to, search, navigate()\n<Button onClick={back.navigate}>Close</Button>\nawait deleteTask(id); back.navigate();\n```\n\n`useReturnToList()` with no argument goes up one path segment, which is the\nnested-route case. A detail that has to be a **sibling** route takes the origin\nin the link (`<ListStateLink to=\"/task/42\" params={{ from: '/my-tasks' }}>`) and\n`useReturnToList()` reads `?from`; or you pass the list path explicitly. What it\nnever does is `navigate(-1)`.\n\n`?from` is attacker-controlled — it arrives in a link somebody else wrote — so\nit is honoured **only** when it is a same-origin path: one leading slash, not\nfollowed by another slash or a backslash (`isSameOriginPath`). `//evil.com` is a\nprotocol-relative URL and `/\\evil.com` is the same thing after the browser\nnormalises the backslash; both look like paths to a `startsWith('/')` check and\nboth leave the origin, which is an open redirect on a Close button. Anything\nthat fails the check is dropped and the parent segment wins.\n\n`ListStateLink` with no `as` renders a real anchor: middle-click and \"open in a\nnew tab\" give a working URL, which is the entire point of holding the state\nthere. A plain left click goes through the adapter, so the list is not torn down\nand rebuilt.\n\n## DataTable\n\n`DataTable`'s server source (§10) takes the same three fields this hook holds,\nso the list's URL and its query are one object:\n\n```tsx\nconst [state, setState] = useListState('tasks', listState, { store: { selected: 'local' } });\n\n<DataTable\n  dataSource={{ kind: 'server', fetch: (query, signal) => api.tasksTable(query, signal) }}\n  query={{ q: state.q, sort: parseSort(state.sort), page: state.page, pageSize: state.pageSize }}\n  onQueryChange={(query) => setState({ q: query.q ?? '', sort: formatSort(query.sort), page: query.page, pageSize: query.pageSize })}\n  rowSelection={state.selected}\n  onRowSelectionChange={(selected) => setState({ selected })}\n/>\n```\n\n* `sort` rides as `name,-createdAt` and `parseTableQuery` on the server reads\n  exactly that, so `parseSort` / `formatSort` are the only translation, and a\n  schema field of `z.array(z.object({ id: z.string(), desc: z.boolean() }))`\n  removes even that at the cost of a JSON-encoded param.\n* Reset `page: 0` in the same `setState` as a filter change; one write, one\n  history entry, no flash of page 7 of a 2-page result.\n* Selection goes through the same hook, which is what keeps it out of the link.\n\n## Test recipe\n\n`src/__tests__` is the recipe, and it is meant to be copied into an extension:\nset the view, open a detail, close it, assert unchanged; press Back, assert the\nexact prior state; reload, assert unchanged; paste the URL into a new tab,\nassert the same view, filter, sort and page; and a URL the schema refuses falls\nback to the defaults rather than an empty screen. Audit rule **P-09** checks the\nsame thing from the outside.\n\n## Runtime configuration (§19.2, P-27)\n\n```ts\n// src/config.ts\nimport { z } from 'zod';\nimport { readRuntimeConfig } from '@ascendenceai/cortena-extensions-shared-web';\n\nexport const config = readRuntimeConfig(\n  z.object({\n    AUTH_SERVICE_URL: z.string().url(),\n    API_BASE_URL: z.string().default('/v1'),\n  }),\n  // `pnpm dev` only — and `allowFallback` is what says so out loud.\n  { fallback: { AUTH_SERVICE_URL: 'http://localhost:3200' }, allowFallback: import.meta.env.DEV },\n);\n```\n\nThe backend serves `/runtime-config.js` — one assignment to\n`window.__CORTENA_RUNTIME_CONFIG__`, read out of the pod's environment, which is\nthe ConfigMap (`serveRuntimeConfig` in `@ascendenceai/cortena-extensions-shared`; the Helm\nsnippet and the `checksum/config` annotation are in that package's README).\n`index.html` loads it **before** the module bundle.\n\nWhy a schema rather than reading the global directly: the failure it replaces is\nsilent. A `VITE_` variable that was never set becomes the string `undefined` in\nthe bundle, and the first symptom is a fetch to `undefined/v1/orgs/…` three\nscreens in, blamed on the screen. This throws at boot, naming the key and the\nConfigMap it comes from.\n\nWhy `fallback` rather than reading `import.meta.env`: `pnpm dev` has no backend\nserving the file, so something has to fill the gap — but reading\n`import.meta.env` here would put the build-time substitution back, and it would\nwork in production too, which is how it would survive review. A `fallback` is\nplainly a development default, in source, where a reviewer sees it, and the\nserved file wins over it every time.\n\nWhy `allowFallback` is a second, separate option: an empty\n`window.__CORTENA_RUNTIME_CONFIG__` is `pnpm dev` **and** a pod that lost its\n`<script>` tag, its ConfigMap or its `serveRuntimeConfig` route. A `fallback`\nalone cannot tell them apart, so the second case boots looking healthy and talks\nto `localhost` from a real browser. An empty global therefore **throws** unless\nthe caller passes `{ allowFallback: true }`. It gates only the empty case: a\nserved file that is missing a key throws either way, because the deployment\nplainly meant to configure the app and got it wrong.\n","readmeFilename":"README.md"}