@fluid-app/fluid-cli-portal 0.1.52 → 0.1.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +3 -0
  2. package/dist/{backend-dev-plugin-B9-S4Is-.mjs → backend-dev-plugin-CY3yxgJ1.mjs} +1 -1
  3. package/dist/{backend-dev-plugin-B9-S4Is-.mjs.map → backend-dev-plugin-CY3yxgJ1.mjs.map} +1 -1
  4. package/dist/build-manifest.d.mts +91 -0
  5. package/dist/build-manifest.d.mts.map +1 -0
  6. package/dist/build-manifest.mjs +180 -0
  7. package/dist/build-manifest.mjs.map +1 -0
  8. package/dist/index.d.mts +657 -741
  9. package/dist/index.d.mts.map +1 -1
  10. package/dist/index.mjs +1627 -1829
  11. package/dist/index.mjs.map +1 -1
  12. package/dist/portal-dev-plugin-CHlxpXc5.mjs +232 -0
  13. package/dist/portal-dev-plugin-CHlxpXc5.mjs.map +1 -0
  14. package/dist/portal-widget-dev-plugin-B53Lf9Ke.mjs +144 -0
  15. package/dist/portal-widget-dev-plugin-B53Lf9Ke.mjs.map +1 -0
  16. package/dist/pull-CUR8tbW9.mjs +1633 -0
  17. package/dist/pull-CUR8tbW9.mjs.map +1 -0
  18. package/dist/sdk.gen-DuPhfdxN.mjs +469 -0
  19. package/dist/sdk.gen-DuPhfdxN.mjs.map +1 -0
  20. package/dist/src-CpH5SpDH.mjs +2157 -0
  21. package/dist/src-CpH5SpDH.mjs.map +1 -0
  22. package/dist/vite-plugin.d.mts +15 -56
  23. package/dist/vite-plugin.d.mts.map +1 -1
  24. package/dist/vite-plugin.mjs +6 -3
  25. package/package.json +12 -3
  26. package/templates/base/.gitignore.template +6 -0
  27. package/templates/base/.oxlintrc.json +1 -2
  28. package/templates/base/AGENTS.md +4 -0
  29. package/templates/base/skills/fluid-portal-authoring/SKILL.md +238 -10
  30. package/templates/starter/.env.example +4 -3
  31. package/templates/starter/README.md.template +14 -1
  32. package/dist/portal-dev-plugin-BHE8Z4yr.mjs +0 -289
  33. package/dist/portal-dev-plugin-BHE8Z4yr.mjs.map +0 -1
  34. package/dist/pull-XOsFaqz2.mjs +0 -1204
  35. package/dist/pull-XOsFaqz2.mjs.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fluid-app/fluid-cli-portal",
3
- "version": "0.1.52",
3
+ "version": "0.1.54",
4
4
  "description": "Fluid CLI plugin for building portal applications",
5
5
  "files": [
6
6
  "dist",
@@ -17,6 +17,10 @@
17
17
  "./vite-plugin": {
18
18
  "import": "./dist/vite-plugin.mjs",
19
19
  "types": "./dist/vite-plugin.d.mts"
20
+ },
21
+ "./build-manifest": {
22
+ "import": "./dist/build-manifest.mjs",
23
+ "types": "./dist/build-manifest.d.mts"
20
24
  }
21
25
  },
22
26
  "publishConfig": {
@@ -40,20 +44,25 @@
40
44
  "@swc/jest": "^0.2.39",
41
45
  "@types/fs-extra": "^11.0.4",
42
46
  "@types/jest": "^29.5.14",
47
+ "@types/node": "24.10.12",
43
48
  "@types/prompts": "^2.4.9",
44
49
  "jest": "^29.7.0",
45
50
  "tsdown": "^0.21.0",
46
51
  "typescript": "^5",
52
+ "@fluid-app/fluid-cli-widget-tooling": "0.1.0",
47
53
  "@fluid-app/api-client-core": "0.1.0",
48
- "@fluid-app/portal-core": "0.1.23",
49
54
  "@fluid-app/fluidos-api-client": "0.1.0",
50
- "@fluid-app/typescript-config": "0.0.0"
55
+ "@fluid-app/portal-core": "0.1.23",
56
+ "@fluid-app/portal-sdk": "0.1.463",
57
+ "@fluid-app/typescript-config": "0.0.0",
58
+ "@fluid-app/portal-widgets": "0.1.22"
51
59
  },
52
60
  "engines": {
53
61
  "node": ">=24.0.0"
54
62
  },
55
63
  "scripts": {
56
64
  "build": "tsdown",
65
+ "postbuild": "node scripts/verify-dist-entries.mjs",
57
66
  "dev": "tsdown --watch",
58
67
  "lint": "oxlint",
59
68
  "lint:fix": "oxlint --fix",
@@ -23,6 +23,12 @@ Thumbs.db
23
23
  # Portal sync metadata (generated by fluid portal pull/push)
24
24
  .portal-sync/
25
25
 
26
+ # Local tooling state — never belongs in the portal repo.
27
+ # .mist-desktop/ holds Mist Desktop's private chats, attachments, and
28
+ # screenshots for this project; .mist is its clone/slug marker.
29
+ .mist
30
+ .mist-desktop/
31
+
26
32
  # Logs
27
33
  *.log
28
34
  pnpm-debug.log*
@@ -2,8 +2,7 @@
2
2
  "$schema": "./node_modules/oxlint/configuration_schema.json",
3
3
  "plugins": ["typescript", "react"],
4
4
  "rules": {
5
- "react/react-in-jsx-scope": "off",
6
- "react/prop-types": "off"
5
+ "react/react-in-jsx-scope": "off"
7
6
  },
8
7
  "ignorePatterns": ["dist/**"]
9
8
  }
@@ -30,6 +30,10 @@ Do not edit `.portal-sync/` by hand. It is generated sync metadata.
30
30
 
31
31
  ## Quality bar
32
32
 
33
+ - **Every screen needs a container root.** A screen's `component_tree` must hold exactly one `ContainerWidget` / `LayoutWidget` / `CardWidget` at the top, with all other widgets inside its `props.children`. Without it the screen still renders, but the admin visual builder has no drop zones and a human cannot drag anything onto the page. See the `fluid-portal-authoring` skill.
34
+ - **Choose the right system before building.** A company portal widget runs in a locked-down worker with no network access, so it can only present Fluid's own data (via capabilities or data sources) or props. Anything needing an API key, OAuth, webhooks, or server-side work is a **Mist app** — a hosted app with its own `public_url`, surfaced through a droplet (identity/credentials) plus a placement record, and embedded by **the Mist's public URL**. The droplet is the app's identity, not the app. See the `fluid-portal-authoring` skill.
35
+ - **Droplet widgets are registered widget types, not embeds.** Use `droplet.<scope>.<dropletId>.<Name>` with real props. Do not point an `EmbedWidget` at a droplet's `/embed` route — that is the `?dri=`-gated admin surface and will render blank.
36
+ - **Do not infer that a widget type does not exist because an API did not list it.** Widget-related endpoints are scoped differently and legitimately return empty. When unsure of a `type` or prop shape, ask the human to drag the widget onto a scratch screen in the admin builder, then `pnpm pull` and read the truth.
33
37
  - Keep portal JSON valid and references consistent.
34
38
  - Preserve stable IDs and slugs unless the change intentionally renames them.
35
39
  - Keep the portal shell thin; do not fork SDK internals into this app.
@@ -60,15 +60,191 @@ Guidelines:
60
60
  - Do not hand-edit `.portal-sync/`; it is sync metadata, not source content.
61
61
  - Do not invent unsupported fields. Match the shapes produced by `pnpm pull`.
62
62
 
63
+ ## Screen structure: every screen needs a container root
64
+
65
+ **Rule: a screen's `component_tree` must hold exactly one container node at the top, and every other widget must be inside its `props.children`.**
66
+
67
+ The container types are `ContainerWidget`, `LayoutWidget`, and `CardWidget`. `ContainerWidget` is the default choice for a page root; the admin builder writes one on every screen it authors.
68
+
69
+ ```json
70
+ {
71
+ "name": "UGC",
72
+ "component_tree": [
73
+ {
74
+ "id": "ContainerWidget-ugc-root",
75
+ "type": "ContainerWidget",
76
+ "props": {
77
+ "gapSize": "md",
78
+ "padding": 4,
79
+ "children": [{ "id": "…", "type": "…", "columnIndex": 0, "props": {} }]
80
+ }
81
+ }
82
+ ]
83
+ }
84
+ ```
85
+
86
+ A screen with bare top-level widgets and no container root **renders correctly in preview and in the live portal**, so nothing you can see locally will tell you it is wrong. What breaks is the admin visual builder: only container widgets receive the edit-mode child-management callbacks (`onAddChild`, `onWidgetSelect`, child delete/duplicate), so a screen without a container root offers **no drop zones**. A human opening that screen in the builder cannot drag anything onto it.
87
+
88
+ This is the single most likely defect in an agent-authored screen, because the failure is invisible from every surface an agent can check.
89
+
90
+ - Put a container root on every screen you create, without exception.
91
+ - Only these three types are containers by default. A `NestedWidget`, `SpacerWidget`, or any other widget at the root does not satisfy this rule.
92
+ - Containers nest. A column layout inside the root is `LayoutWidget` in the root's `children`.
93
+ - When mirroring an existing screen, copy its root container rather than lifting its children out.
94
+
63
95
  ## Add a new page and put it in the menu
64
96
 
65
97
  A page = a **screen** (a `component_tree` of widgets) + a **navigation item** that points at it. A screen with no nav item is unreachable; a nav item whose screen reference doesn't resolve is silently dropped in local preview and refused at push. Both live in `portal/`.
66
98
 
67
- 1. Create `portal/screens/<slug>.json`: `{ "name": "Rewards", "component_tree": [ ... ] }`. Give every node an `id` and a widget `type` from the catalog below. Match the shape `pnpm pull` produced.
68
- 2. Add a navigation item to each profile that should see it: `{ "label": "Rewards", "slug": "rewards", "screen_id": <id> }` (mirror how sibling items in that navigation reference their screens — by `screen_id` or `slug`). Use `children[]` for a nested group. Add to `mobile_navigation` too for mobile.
69
- 3. Link to it with a `ButtonWidget`/`LinkWidget` (or a carousel slide `buttonLink`) using the path `/rewards`.
99
+ 1. Create `portal/screens/<slug>.json`: `{ "name": "Rewards", "component_tree": [ ... ] }`. Give every node an `id` and a widget `type` from the catalog below. The **filename is the resource slug**; an inline `slug` field is optional and is not what cross-resource resolution uses.
100
+ 2. Add a navigation item to each profile that should see it. Local JSON uses the screen **slug**, not a numeric backend id: `{ "label": "Rewards", "slug": "rewards", "screen": "rewards", "source": "user", "position": 2, "children": [] }`. Use `children[]` for a nested group. Add it to the profile's `mobile_navigation` too for mobile.
101
+ 3. Link to it with a `LinkWidget` in screen mode. Use the screen slug rather
102
+ than a URL path so the portal shell handles navigation. `LinkWidget` props for the example above:
103
+
104
+ ```json
105
+ {
106
+ "linkType": "screen",
107
+ "screenSlug": "rewards"
108
+ }
109
+ ```
110
+
111
+ Carousel slides use their separate `buttonLink` property; set that to the
112
+ path `/rewards`.
113
+
70
114
  4. Preview on the profile local dev serves (default/first), then push and activate a version to go live.
71
115
 
116
+ ### Bootstrapping a brand-new empty definition
117
+
118
+ `fluid portal pull` legitimately returns zero screens/themes/navigations/profiles for a newly created portal. Do not search for hidden starter resources and do not move the routes into `src/`. Create a complete minimal definition locally:
119
+
120
+ `portal/screens/home.json`
121
+
122
+ ```json
123
+ {
124
+ "name": "Home",
125
+ "component_tree": [
126
+ {
127
+ "id": "ContainerWidget-home-root",
128
+ "type": "ContainerWidget",
129
+ "props": {
130
+ "background": {
131
+ "type": "solid",
132
+ "color": "background"
133
+ },
134
+ "gapSize": "md",
135
+ "padding": 4,
136
+ "children": [
137
+ {
138
+ "id": "TextWidget-home-welcome",
139
+ "type": "TextWidget",
140
+ "columnIndex": 0,
141
+ "props": {
142
+ "title": "Welcome",
143
+ "titleColor": "foreground",
144
+ "description": "Your portal is ready.",
145
+ "descriptionColor": "foreground",
146
+ "background": {
147
+ "type": "solid",
148
+ "color": "background"
149
+ },
150
+ "padding": 4,
151
+ "borderRadius": "md"
152
+ }
153
+ }
154
+ ]
155
+ }
156
+ }
157
+ ]
158
+ }
159
+ ```
160
+
161
+ `portal/navigations/main.json` (create a parallel `mobile.json` with `"platform": "mobile"`)
162
+
163
+ ```json
164
+ {
165
+ "name": "Main Navigation",
166
+ "platform": "web",
167
+ "navigation_items": [
168
+ {
169
+ "label": "Home",
170
+ "slug": "home",
171
+ "icon": "home",
172
+ "position": 1,
173
+ "screen": "home",
174
+ "source": "user",
175
+ "parent_id": null,
176
+ "children": []
177
+ }
178
+ ]
179
+ }
180
+ ```
181
+
182
+ `portal/profiles/default.json`
183
+
184
+ ```json
185
+ {
186
+ "name": "Default Profile",
187
+ "default": true,
188
+ "permissions": {
189
+ "countries": [],
190
+ "ranks": [],
191
+ "roles": [],
192
+ "platform": []
193
+ },
194
+ "navigation": "main",
195
+ "mobile_navigation": "mobile",
196
+ "themes": []
197
+ }
198
+ ```
199
+
200
+ Create additional screen files and add their slug to both navigations before previewing. `fluid portal push` creates files listed as new, records their backend mappings, then creates navigations and profiles in dependency order. “No mapping found” applies to a file incorrectly treated as changed/deleted, not to a valid new file.
201
+
202
+ If you create `portal/themes/<slug>.json`, it must contain the complete
203
+ `{ "name", "active", "config" }` shape. A theme with only `name` and
204
+ `active` can appear harmless in local preview but the API rejects it. Copy a
205
+ pulled theme's full `config` object and then change its `id`, `name`, and token
206
+ values; do not invent a partial config.
207
+
208
+ ## Decide first: portal widget, or Mist app?
209
+
210
+ Before building anything custom, work out which system the feature belongs to. This is the most expensive decision to get wrong in portal work, and the deciding question is not "internal or external data" — it is **does this need a server that can hold a secret.**
211
+
212
+ ### A company portal widget cannot reach the network
213
+
214
+ Portal widgets run as Remote DOM packages inside a locked-down Web Worker. `fetch`, `XMLHttpRequest`, `WebSocket`, `EventSource`, `localStorage`, `indexedDB` and more are removed before widget code runs, and no capability re-grants them. A portal widget's data comes from exactly three places:
215
+
216
+ - props written into the screen JSON
217
+ - built-in host capabilities — `account`, `store`, `products`, `content`, `mySite`, `todos`, `calendar`, `points`, `localization`, and friends
218
+ - **data sources**, which the host resolves and passes into props (`api` with a Fluid preset, `custom` for hand-picked Fluid resources, `static` for literal data)
219
+
220
+ ### A Mist app is the app; the droplet is its identity
221
+
222
+ A Mist app is a hosted application with its own backend and a `public_url`. It can hold secrets, call any external API, receive webhooks, and do server-side work. Fluid surfaces it through integration records that **all point at that same `public_url`**:
223
+
224
+ | Record | What it is | Per mist |
225
+ | ---------------- | --------------------------------------------------------------------------------------------------------------------------------- | -------- |
226
+ | **Droplet** | Identity and credentials — `FLUID_DROPLET_UUID` / `_SECRET` / `_WEBHOOK_AUTH_TOKEN`, OAuth scopes, install lifecycle and webhooks | one |
227
+ | **Mobile embed** | A placement in the mobile app: `embed_url` plus a required cover image and height | many |
228
+ | **Drop zone** | A placement in an admin page/zone slot | many |
229
+
230
+ **The droplet is not the app.** It is the app's identity and permission record. The Mist app is the thing that runs, and it is what an embed points at.
231
+
232
+ ### The decision
233
+
234
+ 1. **Needs an API key, OAuth, webhooks, background jobs, or any server-side work** → build a **Mist app**. Attach a droplet for credentials and install lifecycle, add a placement surface, and embed the **Mist's public URL**.
235
+ 2. **Presents Fluid's own data** (orders, products, account, shares, subscriptions, content) → build a **company portal widget** and read it through a capability or a preset data source. No hosting, no secrets, no deploy pipeline.
236
+ 3. **Presents public, unauthenticated, CORS-enabled JSON and nothing more** → a portal widget with an `api` data source pointing at an absolute endpoint works. The host fetches it, so it runs in the browser and cannot hold a credential — the moment authentication enters, this becomes case 1.
237
+
238
+ Ask the user which of these their feature is when it is not obvious from the request. A feature that "pulls in data from <third-party service>" is case 1 essentially every time.
239
+
240
+ ### Embedding a Mist app in a portal screen
241
+
242
+ Point the embed at the **Mist's public URL**, or at a purpose-built widget route the Mist app serves. Those routes are built to be iframed and need no installation parameter.
243
+
244
+ **Do not point an embed at the droplet's `/embed` route.** That is the admin dashboard surface and requires a `?dri=` installation parameter; without one it renders "Missing installation" or a blank frame.
245
+
246
+ If the Mist app already publishes registered widget types (see below), prefer those over an iframe embed — they compose properly with the screen and the builder.
247
+
72
248
  ## Choosing a widget (recommend built-ins before custom code)
73
249
 
74
250
  Use the `type` value in a `component_tree` node. Reach for what already exists:
@@ -77,11 +253,47 @@ Use the `type` value in a `component_tree` node. Reach for what already exists:
77
253
  - Hero/media: `CarouselWidget` (rotating hero slides w/ CTA), `ImageWidget`, `VideoWidget`
78
254
  - Content: `TextWidget`, `BulletListWidget`, `CardWidget`, `AlertWidget`, `TableWidget`, `ChartWidget`, `CalendarWidget`
79
255
  - Commerce/member: `ShopWidget`, `PointsWidget` (rewards balance), `RecentActivityWidget`, `ToDoWidget`
80
- - Links/sharing: `LinkWidget`, `ButtonWidget`, `QuickLinksWidget`, `QuickShareWidget`, `ListWidget`
81
- - Platform: `EmbedWidget` (drops a Mist app into a screen), `MySiteWidget`, `CatchUpWidget`
256
+ - Links/sharing: `LinkWidget`, `QuickLinksWidget`, `QuickShareWidget`, `ListWidget`
257
+ - Platform: `EmbedWidget` (iframes an arbitrary URL into a screen), `MySiteWidget`
258
+
259
+ `TextWidget` renders its `title` and `description` as plain text. Do not put
260
+ HTML in either field; markup is escaped and displayed literally.
82
261
 
83
262
  An unregistered `type` renders nothing. Data-driven UI a built-in can't express (a member-specific dashboard) needs a custom widget package — a separate concern from this definition-editing workflow; note it to the user rather than hand-rolling code here.
84
263
 
264
+ ### Droplet widgets are real widget types, not embeds
265
+
266
+ An installed droplet (UGC, and others) contributes **registered widget types**, addressed exactly like any other widget:
267
+
268
+ ```json
269
+ {
270
+ "id": "…",
271
+ "type": "droplet.ugc.drp_fuwamfg3licz1l4yocpkjos12t9vhcrh.MakeAVideoCta",
272
+ "props": {
273
+ "headline": "…",
274
+ "eyebrow": "…",
275
+ "ctaLabel": "…",
276
+ "dri": "",
277
+ "apiBaseUrl": "…"
278
+ }
279
+ }
280
+ ```
281
+
282
+ The shape is `droplet.<scope>.<dropletId>.<WidgetName>`, and the props are ordinary JSON the builder writes.
283
+
284
+ **Do not reach for `EmbedWidget` to render a droplet's UI.** `EmbedWidget` iframes a URL, and a droplet's `/embed` route is the _admin dashboard_ surface, which requires a `?dri=` installation parameter — pointing an `EmbedWidget` at it renders "Missing installation" or a blank frame. That mistake looks reasonable and fails quietly.
285
+
286
+ **Absence from an API is not evidence a widget type does not exist.** These endpoints are scoped differently and will each come back empty or 404 for a droplet widget that is installed and working:
287
+
288
+ | Endpoint | Why it looks empty |
289
+ | ---------------------------------- | -------------------------------------------------------- |
290
+ | `/api/droplets` (`app_extensions`) | Does not list contributed widget types |
291
+ | `/__widget-packages__` | Dev-server route for _unpublished company_ packages only |
292
+ | `/api/app/widget-packages` | Portal-session scoped; 404s for a CLI token |
293
+ | `/api/company/mobile_widgets` | **This one lists installed droplet widgets** |
294
+
295
+ **When you cannot confirm a widget's type or prop shape from an API, ask the human to drag it onto a scratch screen in the admin builder and then `pnpm pull`.** The pulled JSON is ground truth for both the `type` string and the exact props. Do this instead of inferring from absence, and do it early — it costs one message and replaces a chain of confident guesses.
296
+
85
297
  ## System screens and how to reach them
86
298
 
87
299
  The portal ships built-in screens addressed by nav slug: `profile` (alias `account`), `orders`, `subscriptions`, `messaging`, `contacts`, `shop`, `customers`, `my-site`, `share/*`, `app-download`. To expose one, add a navigation item with that slug to the profile — `messaging` and `contacts` only appear when a nav item includes them, and `messages`/`my-site` are rep-only (a non-rep member sees the fallback). These are core surfaces the admin builder keeps; author your own pages alongside them in `portal/` and let the definition stay the source of truth so drag-and-drop editing keeps working.
@@ -111,8 +323,10 @@ Expected result: the portal CLI pulls the definition when `portal/` is missing,
111
323
 
112
324
  Two local-preview rules that read as bugs if you do not know them:
113
325
 
326
+ - `fluid portal dev` injects `portalDevPlugin()` and `backendDevPlugin()` programmatically. Do not add either plugin to `vite.config.ts`; doing so creates scaffold drift and is unnecessary. If the command falls back to bare Vite, fix the dependency/config error that triggered the fallback instead of permanently wiring the plugin.
327
+ - With no `VITE_API_URL` override, the CLI resolves the signed-in company and proxies `/api` to `https://<subdomain>.portal.fluid.app`. Portal member endpoints do **not** live on `api.fluid.app` or `<subdomain>.fluid.app`; repeated 404s from those hosts are a proxy-target error.
114
328
  - The local manifest always serves the profile with `default: true` (or the first profile file if none is default). Profile `permissions` are **not** evaluated locally — edits to any other profile will never appear in the local preview. Permission matching only happens on the deployed portal against the real logged-in member.
115
- - The logged-in session is still real: rep-only screens (`messages`, `my-site`) render the customer fallback when the member is not rep-eligible.
329
+ - A direct localhost preview does **not** have a real portal-member session. The tenant BFF authenticates with an HttpOnly `portal_tenant_user_id` cookie created by the production/Rails handoff, not the Fluid CLI token. Local custom pages, navigation, and definition content still work, but Shop, Orders, Contacts, rep-only screens, and other member-data surfaces may return 401 or remain unauthenticated. A host fix that merely changes an error boundary into permanent skeletons is not successful authentication. Verify signed-in behavior through a real tenant handoff environment.
116
330
 
117
331
  ## Push definition changes
118
332
 
@@ -147,6 +361,11 @@ pnpm exec fluid portal version create --activate
147
361
 
148
362
  Use this only when the pushed definition is ready for users. If activation should be coordinated with a release or content review, stop and ask the project owner before running it.
149
363
 
364
+ For an explicitly approved non-interactive release, use
365
+ `fluid portal push --yes --activate`. It performs the same two stages but
366
+ activates only after every push phase succeeds; malformed or partially-pushed
367
+ definitions exit non-zero and are not activated.
368
+
150
369
  ## Distinguish the three deploy/sync paths
151
370
 
152
371
  Do not mix these up:
@@ -175,10 +394,14 @@ Work these in order and report which one failed:
175
394
 
176
395
  1. Logged in? Auth errors say `Run fluid login first`.
177
396
  2. Right directory? Push errors about missing `portal/` or `.portal-sync/` mean you are not in the pulled project (or never pulled).
178
- 3. Local preview showing stale/missing content? Check for malformed JSON or broken references in the file you last edited (local preview drops them silently), and confirm you edited the profile the local preview serves (`default: true`).
179
- 4. Push refused? Read the cross-reference validation errors — they name the file and the missing slug.
180
- 5. Push partially failed? Fix the reported phase error and rerun; the snapshot only advanced for files that succeeded.
181
- 6. Users do not see the change? Push updates the draft only — create/activate a version.
397
+ 3. Built-in screens returning 404? Confirm the proxy target is `<subdomain>.portal.fluid.app`, not `api.fluid.app` or `<subdomain>.fluid.app`.
398
+ 4. Built-in screens returning 401 or permanent skeletons? Localhost lacks the portal handoff cookie; use a real tenant handoff environment for authenticated-member verification.
399
+ 5. Local preview showing stale/missing definition content? Check for malformed JSON or broken references in the file you last edited (local preview drops them silently), and confirm you edited the profile the local preview serves (`default: true`).
400
+ 6. Push refused? Read the cross-reference validation errors — they name the file and the missing slug.
401
+ 7. Push partially failed? Fix the reported phase error and rerun; the snapshot only advanced for files that succeeded.
402
+ 8. Users do not see the change? Push updates the draft only — create/activate a version.
403
+ 9. Screen renders fine but the admin builder shows no drop zones on it? The screen is missing its container root. See "Screen structure: every screen needs a container root".
404
+ 10. A widget renders blank or says "Missing installation"? You likely used `EmbedWidget` against a droplet's `/embed` route instead of the droplet's registered widget `type`.
182
405
 
183
406
  ## Widget work inside a portal project
184
407
 
@@ -209,4 +432,9 @@ Before considering portal work complete:
209
432
  - [ ] Kept portal structure, routes, and content changes in pulled Fluid OS JSON under `portal/`.
210
433
  - [ ] Ran typecheck/lint/build or the closest available checks.
211
434
  - [ ] Ran push only when the local `portal/` diff was understood.
435
+ - [ ] **Every screen created or modified has a single container root (`ContainerWidget` / `LayoutWidget` / `CardWidget`) with all other widgets in its `props.children`.** Preview cannot detect this; check the JSON.
436
+ - [ ] Chose portal widget vs Mist app deliberately — anything needing a secret, OAuth, webhooks, or server-side work is a Mist app, not a portal widget.
437
+ - [ ] Any embed points at a Mist's public URL or a widget route, never at a droplet's `?dri=`-gated `/embed`.
438
+ - [ ] Used registered widget `type` values for droplet/company widgets, not an `EmbedWidget` pointed at a droplet route.
439
+ - [ ] Confirmed any uncertain widget `type` or prop shape against builder-authored JSON rather than inferring it.
212
440
  - [ ] Created/activated a version only when the definition should go live.
@@ -1,7 +1,8 @@
1
1
  # Fluid API host. LEAVE UNSET for local development: `fluid portal dev`
2
- # forces requests same-origin and proxies /api to this host (default
3
- # https://api.fluid.app) so your local portal/ content is served. Setting an
4
- # absolute URL here only changes which API the dev proxy targets.
2
+ # forces requests same-origin and resolves the signed-in company's tenant BFF
3
+ # (`https://<subdomain>.portal.fluid.app`). Setting an absolute URL here only
4
+ # changes which API the dev proxy targets; it does not create an authenticated
5
+ # portal-member handoff session.
5
6
  # VITE_API_URL=
6
7
 
7
8
  # Optional: override the Fluid CLI auth token for this project.
@@ -35,7 +35,20 @@ pnpm push # fluid portal push
35
35
  pnpm widget:create <name> # scaffold a company-owned portal widget
36
36
  ```
37
37
 
38
- Set `VITE_API_URL` in `.env` if you need a non-default Fluid API host. In `fluid portal dev` this changes the `/api` dev-proxy target — requests stay same-origin so your local `portal/` content is always the one served.
38
+ When `VITE_API_URL` is unset, `fluid portal dev` resolves the signed-in
39
+ company and proxies `/api` to its tenant BFF at
40
+ `https://<subdomain>.portal.fluid.app`. Set `VITE_API_URL` in `.env` only when
41
+ you need a different API host. The override changes routing, not
42
+ authentication.
43
+
44
+ Local preview always serves your local `portal/` manifest, custom pages, and
45
+ navigation. Built-in screens that load member data also require the
46
+ `portal_tenant_user_id` session cookie created by the production/Rails handoff.
47
+ A direct localhost preview cannot create or read that HttpOnly tenant cookie,
48
+ so Shop, Orders, Contacts, and other member-data screens may return HTTP 401 or
49
+ remain in an unauthenticated state. Do not treat persistent skeletons as proof
50
+ that authenticated data works; verify those screens through a real tenant
51
+ handoff environment.
39
52
 
40
53
  ## Project structure
41
54