showpane 0.4.1 → 0.4.2
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.
- package/README.md +14 -1
- package/bundle/meta/scaffold-manifest.json +73 -0
- package/bundle/scaffold/VERSION +1 -0
- package/bundle/scaffold/__dot__env.example +24 -0
- package/bundle/scaffold/__dot__gitignore +41 -0
- package/bundle/scaffold/docker/Caddyfile +3 -0
- package/bundle/scaffold/docker/Dockerfile +30 -0
- package/bundle/scaffold/docker-compose.yml +53 -0
- package/bundle/scaffold/next.config.ts +20 -0
- package/bundle/scaffold/package-lock.json +5843 -0
- package/bundle/scaffold/package.json +42 -0
- package/bundle/scaffold/postcss.config.js +6 -0
- package/bundle/scaffold/prisma/migrations/20260408000000_init/migration.sql +143 -0
- package/bundle/scaffold/prisma/migrations/20260408010000_add_visitor_tracking/migration.sql +6 -0
- package/bundle/scaffold/prisma/migrations/20260409040000_add_portal_file_checksum/migration.sql +2 -0
- package/bundle/scaffold/prisma/migrations/migration_lock.toml +3 -0
- package/bundle/scaffold/prisma/schema.local.prisma +131 -0
- package/bundle/scaffold/prisma/schema.prisma +128 -0
- package/bundle/scaffold/prisma/seed.ts +49 -0
- package/bundle/scaffold/public/example-avatar.svg +4 -0
- package/bundle/scaffold/public/example-logo.svg +4 -0
- package/bundle/scaffold/public/robots.txt +2 -0
- package/bundle/scaffold/scripts/backup.sh +19 -0
- package/bundle/scaffold/scripts/e2e-verify.sh +487 -0
- package/bundle/scaffold/scripts/prisma-db-push.mjs +7 -0
- package/bundle/scaffold/scripts/prisma-generate.mjs +3 -0
- package/bundle/scaffold/scripts/prisma-schema.mjs +74 -0
- package/bundle/scaffold/scripts/restore.sh +31 -0
- package/bundle/scaffold/src/__tests__/client-portals.test.ts +80 -0
- package/bundle/scaffold/src/__tests__/portal-contracts.test.ts +32 -0
- package/bundle/scaffold/src/app/(portal)/client/[slug]/page.tsx +79 -0
- package/bundle/scaffold/src/app/(portal)/client/[slug]/s/[token]/route.ts +22 -0
- package/bundle/scaffold/src/app/(portal)/client/example/example-client.tsx +372 -0
- package/bundle/scaffold/src/app/(portal)/client/example/page.tsx +5 -0
- package/bundle/scaffold/src/app/(portal)/client/layout.tsx +7 -0
- package/bundle/scaffold/src/app/(portal)/client/page.tsx +18 -0
- package/bundle/scaffold/src/app/api/client-auth/route.ts +82 -0
- package/bundle/scaffold/src/app/api/client-auth/share/route.ts +30 -0
- package/bundle/scaffold/src/app/api/client-events/route.ts +87 -0
- package/bundle/scaffold/src/app/api/client-files/[...path]/route.ts +80 -0
- package/bundle/scaffold/src/app/api/client-files/client-upload/route.ts +118 -0
- package/bundle/scaffold/src/app/api/client-files/route.ts +37 -0
- package/bundle/scaffold/src/app/api/client-files/upload/route.ts +131 -0
- package/bundle/scaffold/src/app/api/health/route.ts +19 -0
- package/bundle/scaffold/src/app/globals.css +7 -0
- package/bundle/scaffold/src/app/layout.tsx +25 -0
- package/bundle/scaffold/src/app/page.tsx +171 -0
- package/bundle/scaffold/src/components/portal-login.tsx +169 -0
- package/bundle/scaffold/src/components/portal-shell.tsx +373 -0
- package/bundle/scaffold/src/lib/abuse-controls.ts +43 -0
- package/bundle/scaffold/src/lib/branding.ts +50 -0
- package/bundle/scaffold/src/lib/client-auth.ts +98 -0
- package/bundle/scaffold/src/lib/client-portals.ts +134 -0
- package/bundle/scaffold/src/lib/control-plane.ts +100 -0
- package/bundle/scaffold/src/lib/db.ts +7 -0
- package/bundle/scaffold/src/lib/files.ts +124 -0
- package/bundle/scaffold/src/lib/load-app-env.ts +42 -0
- package/bundle/scaffold/src/lib/portal-contracts.ts +69 -0
- package/bundle/scaffold/src/lib/prisma-client.ts +5 -0
- package/bundle/scaffold/src/lib/runtime-state.ts +69 -0
- package/bundle/scaffold/src/lib/storage.ts +204 -0
- package/bundle/scaffold/src/lib/token.ts +186 -0
- package/bundle/scaffold/src/lib/utils.ts +6 -0
- package/bundle/scaffold/src/middleware.ts +61 -0
- package/bundle/scaffold/tailwind.config.ts +15 -0
- package/bundle/scaffold/tests/__dot__gitkeep +0 -0
- package/bundle/scaffold/tsconfig.json +23 -0
- package/bundle/scaffold/vitest.config.ts +13 -0
- package/bundle/toolchain/VERSION +1 -0
- package/bundle/toolchain/bin/check-slug.ts +59 -0
- package/bundle/toolchain/bin/create-deploy-bundle.ts +93 -0
- package/bundle/toolchain/bin/create-portal.ts +71 -0
- package/bundle/toolchain/bin/delete-portal.ts +48 -0
- package/bundle/toolchain/bin/export-file-manifest.ts +84 -0
- package/bundle/toolchain/bin/export-runtime-state.ts +90 -0
- package/bundle/toolchain/bin/generate-share-link.ts +68 -0
- package/bundle/toolchain/bin/list-portals.ts +53 -0
- package/bundle/toolchain/bin/materialize-file.ts +35 -0
- package/bundle/toolchain/bin/query-analytics.ts +88 -0
- package/bundle/toolchain/bin/rotate-credentials.ts +57 -0
- package/bundle/toolchain/bin/showpane-config +63 -0
- package/bundle/toolchain/bin/tsconfig.json +13 -0
- package/bundle/toolchain/skills/VERSION +1 -0
- package/bundle/toolchain/skills/portal-analytics/SKILL.md +263 -0
- package/bundle/toolchain/skills/portal-create/SKILL.md +341 -0
- package/bundle/toolchain/skills/portal-credentials/SKILL.md +274 -0
- package/bundle/toolchain/skills/portal-delete/SKILL.md +265 -0
- package/bundle/toolchain/skills/portal-deploy/SKILL.md +721 -0
- package/bundle/toolchain/skills/portal-dev/SKILL.md +301 -0
- package/bundle/toolchain/skills/portal-list/SKILL.md +253 -0
- package/bundle/toolchain/skills/portal-onboard/SKILL.md +277 -0
- package/bundle/toolchain/skills/portal-preview/SKILL.md +257 -0
- package/bundle/toolchain/skills/portal-setup/SKILL.md +309 -0
- package/bundle/toolchain/skills/portal-share/SKILL.md +234 -0
- package/bundle/toolchain/skills/portal-status/SKILL.md +268 -0
- package/bundle/toolchain/skills/portal-update/SKILL.md +348 -0
- package/bundle/toolchain/skills/portal-upgrade/SKILL.md +235 -0
- package/bundle/toolchain/skills/portal-verify/SKILL.md +265 -0
- package/bundle/toolchain/skills/shared/bin/check-portal-guard.sh +49 -0
- package/bundle/toolchain/skills/shared/platform-constraints.md +33 -0
- package/bundle/toolchain/skills/shared/preamble.md +137 -0
- package/bundle/toolchain/templates/consulting/consulting-client.tsx +205 -0
- package/bundle/toolchain/templates/onboarding/onboarding-client.tsx +237 -0
- package/bundle/toolchain/templates/sales-followup/sales-followup-client.tsx +283 -0
- package/dist/index.js +873 -166
- package/package.json +3 -2
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: portal-analytics
|
|
3
|
+
description: |
|
|
4
|
+
View portal engagement analytics in the terminal. Use when asked to "show analytics",
|
|
5
|
+
"portal stats", "how is the portal doing", "engagement", or "view counts". (showpane)
|
|
6
|
+
allowed-tools: [Bash, Read, Glob, Grep]
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Preamble (run first)
|
|
10
|
+
|
|
11
|
+
Before doing anything else, execute this block in a Bash tool call:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
CONFIG="$HOME/.showpane/config.json"
|
|
15
|
+
if [ ! -f "$CONFIG" ]; then
|
|
16
|
+
echo "Showpane not configured. Run /portal setup first."
|
|
17
|
+
exit 1
|
|
18
|
+
fi
|
|
19
|
+
APP_PATH=$(cat "$CONFIG" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('app_path',''))" 2>/dev/null)
|
|
20
|
+
DEPLOY_MODE=$(cat "$CONFIG" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('deploy_mode','docker'))" 2>/dev/null)
|
|
21
|
+
ORG_SLUG=$(cat "$CONFIG" | python3 -c "import sys,json; d=json.loads(sys.stdin.read()); print(d.get('orgSlug','') or d.get('org_slug',''))" 2>/dev/null)
|
|
22
|
+
APP_PATH="${SHOWPANE_APP_PATH:-$APP_PATH}"
|
|
23
|
+
if [ -f "$APP_PATH/.env" ]; then set -a && source "$APP_PATH/.env" && set +a; fi
|
|
24
|
+
DATABASE_URL="${DATABASE_URL:-}"
|
|
25
|
+
if [ ! -d "$APP_PATH/node_modules/.prisma" ]; then
|
|
26
|
+
echo "App dependencies not installed. Run: cd $APP_PATH && npm install"
|
|
27
|
+
exit 1
|
|
28
|
+
fi
|
|
29
|
+
SKILL_DIR="${SHOWPANE_TOOLCHAIN_DIR:-$HOME/.showpane/current}"
|
|
30
|
+
SKILL_VERSION=$(cat "$SKILL_DIR/VERSION" 2>/dev/null || echo "unknown")
|
|
31
|
+
echo "SHOWPANE: v$SKILL_VERSION | MODE: $DEPLOY_MODE | APP: $APP_PATH"
|
|
32
|
+
LEARN_FILE="$HOME/.showpane/learnings.jsonl"
|
|
33
|
+
[ -f "$LEARN_FILE" ] && echo "LEARNINGS: $(wc -l < "$LEARN_FILE" | tr -d ' ') loaded" || echo "LEARNINGS: 0"
|
|
34
|
+
|
|
35
|
+
# Predictive next-skill suggestion
|
|
36
|
+
if [ -f "$HOME/.showpane/timeline.jsonl" ]; then
|
|
37
|
+
_RECENT=$(grep '"event":"completed"' "$HOME/.showpane/timeline.jsonl" 2>/dev/null | tail -3 | grep -o '"skill":"[^"]*"' | sed 's/"skill":"//;s/"//' | tr '\n' ',' | sed 's/,$//')
|
|
38
|
+
[ -n "$_RECENT" ] && echo "RECENT_SKILLS: $_RECENT"
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
# Search relevant learnings
|
|
42
|
+
LEARN_FILE="$HOME/.showpane/learnings.jsonl"
|
|
43
|
+
if [ -f "$LEARN_FILE" ]; then
|
|
44
|
+
_LEARN_COUNT=$(wc -l < "$LEARN_FILE" 2>/dev/null | tr -d ' ')
|
|
45
|
+
echo "LEARNINGS: $_LEARN_COUNT entries"
|
|
46
|
+
if [ "$_LEARN_COUNT" -gt 0 ] 2>/dev/null; then
|
|
47
|
+
echo "RECENT_LEARNINGS:"
|
|
48
|
+
tail -5 "$LEARN_FILE" 2>/dev/null
|
|
49
|
+
fi
|
|
50
|
+
fi
|
|
51
|
+
|
|
52
|
+
# Track skill execution
|
|
53
|
+
SHOWPANE_TIMELINE="$HOME/.showpane/timeline.jsonl"
|
|
54
|
+
mkdir -p "$(dirname "$SHOWPANE_TIMELINE")"
|
|
55
|
+
echo '{"skill":"portal-analytics","event":"started","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'"}' >> "$SHOWPANE_TIMELINE" 2>/dev/null
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
If RECENT_SKILLS is shown, suggest the likely next skill:
|
|
59
|
+
- After portal-create → suggest /portal-preview
|
|
60
|
+
- After portal-preview → suggest /portal-deploy or /portal-share
|
|
61
|
+
- After portal-deploy → suggest /portal-status or /portal-verify
|
|
62
|
+
- After portal-setup → suggest /portal-create
|
|
63
|
+
- After portal-credentials → suggest /portal-share
|
|
64
|
+
- After portal-update → suggest /portal-deploy
|
|
65
|
+
|
|
66
|
+
If RECENT_LEARNINGS is shown, review them before proceeding. Past learnings may contain
|
|
67
|
+
relevant warnings or tips for this operation. Apply them where relevant but don't
|
|
68
|
+
mention them unless they directly affect the current task.
|
|
69
|
+
|
|
70
|
+
## Overview
|
|
71
|
+
|
|
72
|
+
This skill queries the analytics backend for a specific portal or all portals in the organization and presents engagement data as a formatted ASCII dashboard in the terminal. It uses the `query-analytics.ts` bin script which reads event data from the database via Prisma.
|
|
73
|
+
|
|
74
|
+
Analytics give the portal owner a quick read on whether clients are actually engaging with the content. Low views or stale activity flags portals that need attention -- a nudge to follow up, refresh content, or check that credentials are still working.
|
|
75
|
+
|
|
76
|
+
## Steps
|
|
77
|
+
|
|
78
|
+
### Step 1: Determine the target portal
|
|
79
|
+
|
|
80
|
+
Check whether the user specified a portal slug:
|
|
81
|
+
|
|
82
|
+
- If a slug was provided (e.g., "show analytics for whzan"), use that slug directly.
|
|
83
|
+
- If no slug was provided, the skill will query analytics for ALL portals in the organization. Let the user know you are pulling org-wide analytics.
|
|
84
|
+
|
|
85
|
+
Capture the slug (if any) and the ORG_SLUG from the preamble output for the next step.
|
|
86
|
+
|
|
87
|
+
### Step 2: Query the analytics data
|
|
88
|
+
|
|
89
|
+
Run the analytics query script. For a specific portal:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
cd $APP_PATH && NODE_PATH="$APP_PATH/node_modules" npx tsx --tsconfig $APP_PATH/tsconfig.json $SKILL_DIR/bin/query-analytics.ts --slug <slug> --org-id <org_id>
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
For all portals (omit the `--slug` flag):
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
cd $APP_PATH && NODE_PATH="$APP_PATH/node_modules" npx tsx --tsconfig $APP_PATH/tsconfig.json $SKILL_DIR/bin/query-analytics.ts --org-id <org_id>
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The script returns JSON on stdout. Expected shape for a single portal:
|
|
102
|
+
|
|
103
|
+
```json
|
|
104
|
+
{
|
|
105
|
+
"ok": true,
|
|
106
|
+
"portal": {
|
|
107
|
+
"slug": "whzan",
|
|
108
|
+
"companyName": "Whzan Digital Health",
|
|
109
|
+
"views": 45,
|
|
110
|
+
"tabSwitches": 123,
|
|
111
|
+
"lastActivity": "2026-04-07T10:30:00Z",
|
|
112
|
+
"mostViewedTab": "Services overview",
|
|
113
|
+
"previousPeriodViews": 33
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
For all portals, the response includes a `portals` array with the same fields per entry plus an `orgSummary` object.
|
|
119
|
+
|
|
120
|
+
If the script exits with a non-zero code, read stderr for the error JSON. Common errors:
|
|
121
|
+
|
|
122
|
+
- `portal_not_found`: The slug does not exist in this organization. Suggest running `/portal list` to see available portals.
|
|
123
|
+
- `no_analytics_data`: The portal exists but has zero recorded events. This is normal for brand-new portals.
|
|
124
|
+
|
|
125
|
+
### Step 3: Format the output as an ASCII table
|
|
126
|
+
|
|
127
|
+
Present the data in a clean, scannable terminal format. For a single portal:
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
Portal: whzan (Whzan Digital Health)
|
|
131
|
+
Period: Last 30 days
|
|
132
|
+
════════════════════════════════════════
|
|
133
|
+
Views: 45
|
|
134
|
+
Tab switches: 123
|
|
135
|
+
Last activity: 2026-04-07 10:30
|
|
136
|
+
Most viewed tab: "Services overview"
|
|
137
|
+
════════════════════════════════════════
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
For multiple portals, use a summary table:
|
|
141
|
+
|
|
142
|
+
```
|
|
143
|
+
ANALYTICS (Demo Company) — Last 30 days
|
|
144
|
+
════════════════════════════════════════════════════════════
|
|
145
|
+
Slug Company Views Tab Switches Last Activity
|
|
146
|
+
─────────── ──────────────────── ─────── ───────────── ──────────────
|
|
147
|
+
whzan Whzan Digital Health 45 123 7 Apr 10:30
|
|
148
|
+
acme Acme Corp 12 34 5 Apr 14:22
|
|
149
|
+
example Example Portal 0 0 Never
|
|
150
|
+
════════════════════════════════════════════════════════════
|
|
151
|
+
Total views: 57 | Active portals: 2/3
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Step 4: Show trends when previous period data is available
|
|
155
|
+
|
|
156
|
+
If the analytics response includes `previousPeriodViews` (or equivalent previous-period fields), calculate and display the delta:
|
|
157
|
+
|
|
158
|
+
```
|
|
159
|
+
Views: 45 (+12 vs previous 30d)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Use a `+` prefix for increases and `-` for decreases. If the previous period had zero views and the current has some, show it as `(new activity)` rather than a percentage.
|
|
163
|
+
|
|
164
|
+
For the multi-portal table, append a trend column:
|
|
165
|
+
|
|
166
|
+
```
|
|
167
|
+
Slug Views Trend
|
|
168
|
+
─────────── ─────── ──────────
|
|
169
|
+
whzan 45 +12 (+36%)
|
|
170
|
+
acme 12 -3 (-20%)
|
|
171
|
+
example 0 --
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Step 5: Provide actionable commentary
|
|
175
|
+
|
|
176
|
+
After the table, add a brief note if any portals need attention:
|
|
177
|
+
|
|
178
|
+
- **Zero views in 30 days**: "Portal 'example' has had no views. Consider checking credentials or sending a reminder to the client."
|
|
179
|
+
- **Declining trend**: "Portal 'acme' views dropped 20%. The client may need a content refresh or follow-up call."
|
|
180
|
+
- **Strong engagement**: "Portal 'whzan' is your most active portal. Good engagement."
|
|
181
|
+
|
|
182
|
+
Keep commentary to 1-2 sentences maximum. Do not over-explain. The numbers speak for themselves.
|
|
183
|
+
|
|
184
|
+
## Conventions
|
|
185
|
+
|
|
186
|
+
- Always format dates as `DD Mon YYYY` or `DD Mon HH:MM` for recency (e.g., "7 Apr 10:30").
|
|
187
|
+
- Use double-line box drawing (`═`) for outer borders and single-line (`─`) for internal dividers.
|
|
188
|
+
- Right-align numeric columns in multi-portal tables for scannability.
|
|
189
|
+
- If the user asks for a specific time period (e.g., "last 7 days"), pass `--period 7d` to the script if supported. Default is 30 days.
|
|
190
|
+
- Never expose raw JSON to the user. Always format as the ASCII table described above.
|
|
191
|
+
- If learnings indicate the user prefers a specific portal or checks analytics frequently, mention the most relevant portal first.
|
|
192
|
+
|
|
193
|
+
## Analytics Events
|
|
194
|
+
|
|
195
|
+
The analytics system tracks the following event types. Understanding what is tracked helps interpret the numbers:
|
|
196
|
+
|
|
197
|
+
- **page_view**: Recorded when a client loads the portal page. One view per page load, not per session. If a client refreshes the page, that counts as a second view.
|
|
198
|
+
- **tab_switch**: Recorded when a client clicks a different tab within the portal. This is the best engagement signal -- it means the client is actively exploring content, not just landing on the page and leaving.
|
|
199
|
+
- **file_download**: Recorded when a client downloads a document from the Documents tab. This indicates high-intent engagement.
|
|
200
|
+
- **share_link_access**: Recorded when someone accesses the portal via a share link rather than username/password login.
|
|
201
|
+
|
|
202
|
+
When reading the analytics output, tab switches relative to views gives you a rough engagement depth. A portal with 10 views and 30 tab switches means clients are exploring (3 tabs per visit on average). A portal with 10 views and 2 tab switches means clients are landing and leaving.
|
|
203
|
+
|
|
204
|
+
## Time Periods
|
|
205
|
+
|
|
206
|
+
The default analytics period is 30 days. The script supports the following periods via the `--period` flag:
|
|
207
|
+
|
|
208
|
+
- `7d` -- last 7 days (useful for checking recent activity after sharing a portal)
|
|
209
|
+
- `30d` -- last 30 days (default, good general-purpose view)
|
|
210
|
+
- `90d` -- last 90 days (useful for long-running client relationships)
|
|
211
|
+
- `all` -- all time (useful for seeing total engagement since portal creation)
|
|
212
|
+
|
|
213
|
+
When the user asks for a custom period, map their language to the closest supported option:
|
|
214
|
+
- "this week" or "recent" -> `7d`
|
|
215
|
+
- "this month" -> `30d`
|
|
216
|
+
- "this quarter" -> `90d`
|
|
217
|
+
- "total" or "all time" or "since we started" -> `all`
|
|
218
|
+
|
|
219
|
+
## Interpreting Zero Activity
|
|
220
|
+
|
|
221
|
+
A portal with zero views is not necessarily a problem. Context matters:
|
|
222
|
+
|
|
223
|
+
- **New portal (created in last 7 days)**: Zero views is expected if credentials have not been shared yet. Note: "Portal was created recently. Share credentials with the client to start tracking engagement."
|
|
224
|
+
- **Portal with credentials but no views**: The client may not have received the credentials, or the email landed in spam. Suggest: "Credentials exist but no one has logged in. Consider resending or generating a share link."
|
|
225
|
+
- **Portal that previously had views but now has zero**: The client relationship may have gone cold. This is the most actionable signal -- suggest a follow-up.
|
|
226
|
+
- **Portal with credentials and share link but no views**: The client may not have used the link yet, or it may have been buried in email. Suggest resending the share link with `/portal share`.
|
|
227
|
+
|
|
228
|
+
## Telemetry
|
|
229
|
+
|
|
230
|
+
If telemetry is enabled, the analytics skill records a minimal event after each query. The telemetry payload does not include any analytics data (view counts, portal slugs, or company names). It only records that the skill was invoked and how long the query took:
|
|
231
|
+
|
|
232
|
+
```json
|
|
233
|
+
{"skill":"portal-analytics","ts":"2026-04-07T12:00:00Z","duration_s":2,"outcome":"success"}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## Error Handling
|
|
237
|
+
|
|
238
|
+
- If the preamble fails (no config, no Prisma), stop and show the error. Do not attempt to query analytics.
|
|
239
|
+
- If `query-analytics.ts` returns a non-zero exit code, display the error message from stderr and suggest a fix.
|
|
240
|
+
- If DATABASE_URL is empty, tell the user: "No database configured. Run /portal setup to connect your database."
|
|
241
|
+
- If the analytics query times out (large dataset), suggest narrowing the period: "Query took too long. Try a shorter period: /portal analytics whzan --period 7d"
|
|
242
|
+
|
|
243
|
+
## Learnings Integration
|
|
244
|
+
|
|
245
|
+
After displaying analytics, check the learnings file for patterns that add context:
|
|
246
|
+
|
|
247
|
+
- If a learning records when credentials were last shared for this portal, note the time gap: "Credentials were shared 3 days ago. Give the client a few more days before following up."
|
|
248
|
+
- If a learning records that the user prefers weekly analytics checks, tailor the output to highlight week-over-week changes rather than 30-day totals.
|
|
249
|
+
- Do not write new learnings from this skill. Analytics is a read-only operation.
|
|
250
|
+
|
|
251
|
+
## Completion
|
|
252
|
+
|
|
253
|
+
As a final step, log skill completion:
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
echo '{"skill":"portal-analytics","event":"completed","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'"}' >> "$HOME/.showpane/timeline.jsonl" 2>/dev/null
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
## Related Skills
|
|
260
|
+
|
|
261
|
+
- `/portal status` -- broader health dashboard with scoring (uses analytics as one input)
|
|
262
|
+
- `/portal list` -- see all portals without analytics data
|
|
263
|
+
- `/portal share` -- generate a share link for a portal with strong engagement
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: portal-create
|
|
3
|
+
description: |
|
|
4
|
+
Scaffold a new client portal from a meeting transcript, template, or description.
|
|
5
|
+
Trigger phrases: "create a portal", "new portal", "set up a client page", "make a portal for". (showpane)
|
|
6
|
+
allowed-tools: [Bash, Read, Write, Edit, Glob, Grep]
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
If the user asks for unsupported hosted behavior, risky upload types, or cloud-specific
|
|
10
|
+
capabilities, read `skills/shared/platform-constraints.md` and apply the relevant limits.
|
|
11
|
+
|
|
12
|
+
## Preamble (run first)
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
# Read config
|
|
16
|
+
CONFIG="$HOME/.showpane/config.json"
|
|
17
|
+
if [ ! -f "$CONFIG" ]; then
|
|
18
|
+
echo "Showpane not configured. Run /portal setup first."
|
|
19
|
+
exit 1
|
|
20
|
+
fi
|
|
21
|
+
APP_PATH=$(python3 -c "import json; d=json.load(open('$CONFIG')); print(d.get('app_path',''))" 2>/dev/null)
|
|
22
|
+
DEPLOY_MODE=$(python3 -c "import json; d=json.load(open('$CONFIG')); print(d.get('deploy_mode','docker'))" 2>/dev/null)
|
|
23
|
+
ORG_SLUG=$(python3 -c "import json; d=json.load(open('$CONFIG')); print(d.get('orgSlug','') or d.get('org_slug',''))" 2>/dev/null)
|
|
24
|
+
APP_PATH="${SHOWPANE_APP_PATH:-$APP_PATH}"
|
|
25
|
+
if [ -f "$APP_PATH/.env" ]; then set -a && source "$APP_PATH/.env" && set +a; fi
|
|
26
|
+
DATABASE_URL="${DATABASE_URL:-}"
|
|
27
|
+
if [ ! -d "$APP_PATH/node_modules/.prisma" ]; then
|
|
28
|
+
echo "App dependencies not installed. Run: cd $APP_PATH && npm install"
|
|
29
|
+
exit 1
|
|
30
|
+
fi
|
|
31
|
+
SKILL_DIR="${SHOWPANE_TOOLCHAIN_DIR:-$HOME/.showpane/current}"
|
|
32
|
+
SKILL_VERSION=$(cat "$SKILL_DIR/VERSION" 2>/dev/null || echo "unknown")
|
|
33
|
+
echo "SHOWPANE: v$SKILL_VERSION | MODE: $DEPLOY_MODE | APP: $APP_PATH"
|
|
34
|
+
LEARN_FILE="$HOME/.showpane/learnings.jsonl"
|
|
35
|
+
[ -f "$LEARN_FILE" ] && echo "LEARNINGS: $(wc -l < "$LEARN_FILE" | tr -d ' ') loaded" || echo "LEARNINGS: 0"
|
|
36
|
+
|
|
37
|
+
# Predictive next-skill suggestion
|
|
38
|
+
if [ -f "$HOME/.showpane/timeline.jsonl" ]; then
|
|
39
|
+
_RECENT=$(grep '"event":"completed"' "$HOME/.showpane/timeline.jsonl" 2>/dev/null | tail -3 | grep -o '"skill":"[^"]*"' | sed 's/"skill":"//;s/"//' | tr '\n' ',' | sed 's/,$//')
|
|
40
|
+
[ -n "$_RECENT" ] && echo "RECENT_SKILLS: $_RECENT"
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
# Search relevant learnings
|
|
44
|
+
LEARN_FILE="$HOME/.showpane/learnings.jsonl"
|
|
45
|
+
if [ -f "$LEARN_FILE" ]; then
|
|
46
|
+
_LEARN_COUNT=$(wc -l < "$LEARN_FILE" 2>/dev/null | tr -d ' ')
|
|
47
|
+
echo "LEARNINGS: $_LEARN_COUNT entries"
|
|
48
|
+
if [ "$_LEARN_COUNT" -gt 0 ] 2>/dev/null; then
|
|
49
|
+
echo "RECENT_LEARNINGS:"
|
|
50
|
+
tail -5 "$LEARN_FILE" 2>/dev/null
|
|
51
|
+
fi
|
|
52
|
+
fi
|
|
53
|
+
|
|
54
|
+
# Track skill execution
|
|
55
|
+
SHOWPANE_TIMELINE="$HOME/.showpane/timeline.jsonl"
|
|
56
|
+
mkdir -p "$(dirname "$SHOWPANE_TIMELINE")"
|
|
57
|
+
echo '{"skill":"portal-create","event":"started","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'"}' >> "$SHOWPANE_TIMELINE" 2>/dev/null
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
If RECENT_SKILLS is shown, suggest the likely next skill:
|
|
61
|
+
- After portal-create → suggest /portal-preview
|
|
62
|
+
- After portal-preview → suggest /portal-deploy or /portal-share
|
|
63
|
+
- After portal-deploy → suggest /portal-status or /portal-verify
|
|
64
|
+
- After portal-setup → suggest /portal-create
|
|
65
|
+
- After portal-credentials → suggest /portal-share
|
|
66
|
+
- After portal-update → suggest /portal-deploy
|
|
67
|
+
|
|
68
|
+
If RECENT_LEARNINGS is shown, review them before proceeding. Past learnings may contain
|
|
69
|
+
relevant warnings or tips for this operation. Apply them where relevant but don't
|
|
70
|
+
mention them unless they directly affect the current task.
|
|
71
|
+
|
|
72
|
+
## Steps
|
|
73
|
+
|
|
74
|
+
### Step 1: Determine the portal slug
|
|
75
|
+
|
|
76
|
+
If the user provided a slug (e.g., `/portal create acme-health`), use it. Otherwise, infer from context — the company name mentioned in conversation, a meeting transcript, or ask the user directly.
|
|
77
|
+
|
|
78
|
+
Validate the slug by running:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
cd "$APP_PATH" && NODE_PATH="$APP_PATH/node_modules" npx tsx --tsconfig "$APP_PATH/tsconfig.json" "$SKILL_DIR/bin/check-slug.ts" --slug <slug> --org-id <org_id>
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The script returns `{"valid":true}` or `{"valid":false,"reason":"...","message":"..."}`. If invalid:
|
|
85
|
+
- `reason: "format"` — slug must be lowercase alphanumeric + hyphens, 2-50 chars, no leading/trailing hyphens
|
|
86
|
+
- `reason: "reserved"` — reserved names: api, client, s, admin, static, _next, health, example
|
|
87
|
+
- `reason: "taken"` — a portal with this slug already exists for this org
|
|
88
|
+
|
|
89
|
+
If invalid, explain the issue and ask for a different slug.
|
|
90
|
+
|
|
91
|
+
Also ask for the client's website domain (e.g., "acme-health.com"). This is optional but enables auto-branding:
|
|
92
|
+
- If provided, the client logo will be fetched via `getLogoUrl(domain)` and stored in `ClientPortal.logoUrl`
|
|
93
|
+
- If not provided, an initial-based logo is generated via `getInitialLogo(companyName)` and stored as a data URI
|
|
94
|
+
|
|
95
|
+
### Step 2: Granola MCP integration (optional)
|
|
96
|
+
|
|
97
|
+
Try to use the Granola MCP `list_meetings` tool to fetch recent meetings. This is a convenience, not a requirement.
|
|
98
|
+
|
|
99
|
+
**If Granola MCP is available:**
|
|
100
|
+
1. Call `list_meetings` to get recent meetings
|
|
101
|
+
2. Present the list to the user: date, title, participants
|
|
102
|
+
3. Ask which meeting to use (or "none — I'll describe the portal manually")
|
|
103
|
+
4. If a meeting is selected, call `get_meeting_transcript` to retrieve the full transcript
|
|
104
|
+
5. Store the transcript for content analysis in Step 4
|
|
105
|
+
|
|
106
|
+
**If Granola MCP is NOT available** (tool-not-found error):
|
|
107
|
+
- Skip gracefully. Do not mention Granola or show an error.
|
|
108
|
+
- Ask: "Do you have a meeting transcript to paste, or shall I work from a description?"
|
|
109
|
+
- If the user pastes a transcript, store it for analysis
|
|
110
|
+
- If no transcript, proceed to template selection with manual content
|
|
111
|
+
|
|
112
|
+
Never fail or block because Granola is unavailable. It is purely additive.
|
|
113
|
+
|
|
114
|
+
### Step 3: Template selection
|
|
115
|
+
|
|
116
|
+
Ask which template to use as a starting point:
|
|
117
|
+
|
|
118
|
+
1. **sales-followup** — Meeting notes, next steps, documents. Best after a sales call.
|
|
119
|
+
2. **consulting** — Project overview, deliverables, timeline. Best for ongoing engagements.
|
|
120
|
+
3. **onboarding** — Welcome, setup steps, resources. Best for new client onboarding.
|
|
121
|
+
4. **blank** — Start from scratch with just an overview tab.
|
|
122
|
+
|
|
123
|
+
Read the chosen template file from `$SKILL_DIR/templates/` for structural inspiration:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
cat "$SKILL_DIR/templates/sales-followup/sales-followup-client.tsx"
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Always also read the example portal as your quality and style reference:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
cat "$APP_PATH/src/app/(portal)/client/example/example-client.tsx"
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The template provides content structure. The example provides quality and styling. Match the example's patterns: card styles, typography, spacing, responsive breakpoints. Templates are inspiration, not rigid scaffolds. Adapt the structure to fit the actual content.
|
|
136
|
+
|
|
137
|
+
### Step 4: Analyze transcript (if available)
|
|
138
|
+
|
|
139
|
+
If a transcript was provided (from Granola or pasted), analyze it to extract:
|
|
140
|
+
|
|
141
|
+
| Signal Found | Tab to Generate | Content Pattern |
|
|
142
|
+
|---|---|---|
|
|
143
|
+
| Meeting discussion topics | "Meetings" | Collapsible `<details>` sections per meeting with bullet points |
|
|
144
|
+
| Action items, next steps, follow-ups | "Next Steps" | Numbered timeline with status indicators (done/pending) |
|
|
145
|
+
| Documents mentioned (contracts, NDAs, proposals) | "Documents" | Download cards with file type icons from lucide-react |
|
|
146
|
+
| Service descriptions, capabilities discussed | "Services" | Grid of cards with title and description |
|
|
147
|
+
| Pricing, costs, tiers discussed | "Pricing" | Comparison table or tier cards |
|
|
148
|
+
| Project phases, milestones | "Timeline" | Vertical timeline with phase markers |
|
|
149
|
+
|
|
150
|
+
Always generate at minimum:
|
|
151
|
+
- An **overview/welcome tab** (first tab, always)
|
|
152
|
+
- At least **one additional tab** based on content
|
|
153
|
+
|
|
154
|
+
If the transcript is rich, generate up to 5-6 tabs. Do not exceed 6 tabs total.
|
|
155
|
+
|
|
156
|
+
Extract from the transcript:
|
|
157
|
+
- **Company name** and contact details (for PortalShell props)
|
|
158
|
+
- **Key discussion points** (for meeting notes)
|
|
159
|
+
- **Agreed actions** (for next steps timeline)
|
|
160
|
+
- **Mentioned documents** (for documents tab)
|
|
161
|
+
- **Services or products discussed** (for services/overview content)
|
|
162
|
+
|
|
163
|
+
### Step 5: Generate the portal files
|
|
164
|
+
|
|
165
|
+
Create two files in `$APP_PATH/src/app/(portal)/client/<slug>/`:
|
|
166
|
+
|
|
167
|
+
#### File 1: `page.tsx` (server component)
|
|
168
|
+
|
|
169
|
+
```tsx
|
|
170
|
+
import { <SlugName>PortalClient } from "./<slug>-client";
|
|
171
|
+
|
|
172
|
+
export const metadata = {
|
|
173
|
+
title: "<Company Name> | Portal",
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
export default function <SlugName>Portal() {
|
|
177
|
+
return <<SlugName>PortalClient />;
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Convert the slug to PascalCase for the component name (e.g., `acme-health` becomes `AcmeHealth`).
|
|
182
|
+
|
|
183
|
+
#### File 2: `<slug>-client.tsx` (client component)
|
|
184
|
+
|
|
185
|
+
This is the main file. Follow these conventions exactly:
|
|
186
|
+
|
|
187
|
+
**Imports:**
|
|
188
|
+
```tsx
|
|
189
|
+
"use client";
|
|
190
|
+
|
|
191
|
+
import { type ReactNode } from "react";
|
|
192
|
+
import { /* icons from lucide-react */ } from "lucide-react";
|
|
193
|
+
import { cn } from "@/lib/utils";
|
|
194
|
+
import { PortalShell } from "@/components/portal-shell";
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
**Structure:**
|
|
198
|
+
- Define each tab's content as a separate function component within the file (e.g., `function OverviewTab()`, `function DocumentsTab()`)
|
|
199
|
+
- Export a single named component: `export function <SlugName>PortalClient()`
|
|
200
|
+
- The exported component returns `<PortalShell>` with all required props
|
|
201
|
+
|
|
202
|
+
**PortalShell props (all required):**
|
|
203
|
+
- `companyName` — the org's company name (from config/DB)
|
|
204
|
+
- `companyLogo` — a `<span>` with the first letter of the company name, white text
|
|
205
|
+
- `clientName` — the client's company name (from transcript or user input)
|
|
206
|
+
- `clientLogoSrc` — if client domain was provided: use `getLogoUrl(domain)` from `app/src/lib/branding.ts`. If not: use `getInitialLogo(clientName)` to generate an SVG data URI. Store the chosen URL in the ClientPortal record's `logoUrl` field
|
|
207
|
+
- `clientLogoAlt` — the client company name
|
|
208
|
+
- `lastUpdated` — today's date formatted as "7 April 2026"
|
|
209
|
+
- `contact` — object with `name`, `title`, `avatarSrc`, `email` (from org config)
|
|
210
|
+
- `tabs` — array of tab objects with `id`, `label`, `icon`, `content`, and optional `badge`
|
|
211
|
+
- `hideFooterOnTab` — set to `"overview"` (hides the contact footer on the first tab since it typically has contact info inline)
|
|
212
|
+
|
|
213
|
+
**Styling conventions (match the example portal exactly):**
|
|
214
|
+
- Cards: `rounded-2xl border bg-white shadow-sm`
|
|
215
|
+
- Card padding: `p-5 sm:p-6`
|
|
216
|
+
- Section headings: `text-base font-bold tracking-tight text-gray-900`
|
|
217
|
+
- Body text: `text-sm leading-relaxed text-gray-600`
|
|
218
|
+
- Small text: `text-xs text-gray-500`
|
|
219
|
+
- Bullet points: use `<span>` dots with `h-1.5 w-1.5 rounded-full` for bullet markers
|
|
220
|
+
- Status badges: `rounded-full px-2 py-0.5 text-[11px] font-medium` with color variants
|
|
221
|
+
- Buttons: `rounded-lg bg-gray-900 px-5 py-2 text-xs font-semibold text-white`
|
|
222
|
+
- Grid layouts: `grid gap-3 sm:grid-cols-2` for card grids
|
|
223
|
+
- Spacing between sections: `mt-6` with `mb-4` for section headings
|
|
224
|
+
- Responsive: mobile-first, use `sm:` breakpoints for wider layouts
|
|
225
|
+
|
|
226
|
+
**Icon usage:**
|
|
227
|
+
Import only the icons you need from `lucide-react`. Common choices:
|
|
228
|
+
- `Presentation` for overview/services
|
|
229
|
+
- `CalendarDays` for meetings
|
|
230
|
+
- `FileText` for documents
|
|
231
|
+
- `BarChart3` for analytics/strategy
|
|
232
|
+
- `ListChecks` for next steps
|
|
233
|
+
- `Download` for download buttons
|
|
234
|
+
- `ChevronDown` for collapsible sections
|
|
235
|
+
- `Clock` for timeline
|
|
236
|
+
- `DollarSign` for pricing
|
|
237
|
+
|
|
238
|
+
**For collapsible meeting sections**, use the same `<details>` pattern as the example:
|
|
239
|
+
```tsx
|
|
240
|
+
<details open={defaultOpen} className="group">
|
|
241
|
+
<summary className="flex cursor-pointer list-none items-center gap-1.5 text-left">
|
|
242
|
+
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-gray-400 transition-transform group-open:rotate-180" />
|
|
243
|
+
<h4 className="text-sm font-semibold text-gray-900">{title}</h4>
|
|
244
|
+
</summary>
|
|
245
|
+
<div className="mt-2 pl-5">{children}</div>
|
|
246
|
+
</details>
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### Step 6: Create database record
|
|
250
|
+
|
|
251
|
+
Run the create-portal script to register the portal in the database:
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
cd "$APP_PATH" && NODE_PATH="$APP_PATH/node_modules" npx tsx --tsconfig "$APP_PATH/tsconfig.json" "$SKILL_DIR/bin/create-portal.ts" --slug <slug> --company "<client_company_name>" --org-id <org_id>
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
This creates the `ClientPortal` record with the slug, company name, and links it to the Organization. It does NOT create credentials — that is a separate step via `/portal credentials`.
|
|
258
|
+
|
|
259
|
+
### Step 7: Self-review
|
|
260
|
+
|
|
261
|
+
After generating the files, read them back and verify:
|
|
262
|
+
|
|
263
|
+
1. **PortalShell used?** The client component must use `<PortalShell>` as its root element.
|
|
264
|
+
2. **Minimum 2 tabs?** Check the `tabs` array has at least 2 entries.
|
|
265
|
+
3. **Contact info in props?** The `contact` prop must have `name`, `title`, `avatarSrc`, `email`.
|
|
266
|
+
4. **"use client" directive?** Must be the first line of the client component.
|
|
267
|
+
5. **Imports correct?** `cn` from `@/lib/utils`, `PortalShell` from `@/components/portal-shell`.
|
|
268
|
+
6. **No hardcoded localhost URLs?** Links should be relative or use placeholders.
|
|
269
|
+
7. **Responsive patterns?** Check for `sm:` breakpoints on grids and padding.
|
|
270
|
+
8. **Tab content functions?** Each tab should have its own function, not inline JSX.
|
|
271
|
+
|
|
272
|
+
If any check fails, fix the issue before proceeding.
|
|
273
|
+
|
|
274
|
+
### Step 8: Open preview
|
|
275
|
+
|
|
276
|
+
Check if the dev server is running:
|
|
277
|
+
|
|
278
|
+
```bash
|
|
279
|
+
lsof -i :3000 -sTCP:LISTEN -t 2>/dev/null
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
If running, open the portal in the browser:
|
|
283
|
+
|
|
284
|
+
```bash
|
|
285
|
+
open "http://localhost:3000/client/<slug>"
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
If not running, suggest:
|
|
289
|
+
|
|
290
|
+
> "Start the dev server with `/portal dev` to preview your portal at http://localhost:3000/client/<slug>"
|
|
291
|
+
|
|
292
|
+
### Step 9: Summary and next steps
|
|
293
|
+
|
|
294
|
+
Print a summary:
|
|
295
|
+
|
|
296
|
+
```
|
|
297
|
+
Portal created: <slug>
|
|
298
|
+
|
|
299
|
+
Client: <company_name>
|
|
300
|
+
Tabs: Overview, Next Steps, Documents (3 tabs)
|
|
301
|
+
Files: src/app/(portal)/client/<slug>/page.tsx
|
|
302
|
+
src/app/(portal)/client/<slug>/<slug>-client.tsx
|
|
303
|
+
|
|
304
|
+
Next steps:
|
|
305
|
+
1. Create login credentials: /portal credentials <slug>
|
|
306
|
+
2. Preview the portal: /portal preview <slug>
|
|
307
|
+
3. Edit content: /portal update <slug>
|
|
308
|
+
4. Deploy: /portal deploy
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
### Step 10: Record learning
|
|
312
|
+
|
|
313
|
+
Append a learning about the portal creation for future reference:
|
|
314
|
+
|
|
315
|
+
```bash
|
|
316
|
+
echo '{"skill":"portal-create","key":"portal-created","insight":"Created portal <slug> for <company>. Template: <template>. Tabs: <tab_list>.","confidence":8,"ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'"}' >> "$HOME/.showpane/learnings.jsonl"
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
## Completion
|
|
320
|
+
|
|
321
|
+
As a final step, log skill completion:
|
|
322
|
+
|
|
323
|
+
```bash
|
|
324
|
+
echo '{"skill":"portal-create","event":"completed","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'"}' >> "$HOME/.showpane/timeline.jsonl" 2>/dev/null
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
## Conventions
|
|
328
|
+
|
|
329
|
+
- Always use `"use client"` as the first line of the client component
|
|
330
|
+
- Import `PortalShell` from `@/components/portal-shell`
|
|
331
|
+
- Import `cn` from `@/lib/utils`
|
|
332
|
+
- Import icons from `lucide-react`
|
|
333
|
+
- Minimum 2 tabs, maximum 6
|
|
334
|
+
- First tab is always overview/welcome
|
|
335
|
+
- Tab content as separate function components within the file
|
|
336
|
+
- Cards: `rounded-2xl border bg-white shadow-sm`
|
|
337
|
+
- Text: `text-sm` for body, `text-base font-bold` for headings
|
|
338
|
+
- Spacing: consistent `p-5 sm:p-6` for card padding
|
|
339
|
+
- Scope lock: only create files in `$APP_PATH/src/app/(portal)/client/<slug>/`
|
|
340
|
+
- Never modify shared components, other portals, or lib files during portal creation
|
|
341
|
+
- The example portal at `$APP_PATH/src/app/(portal)/client/example/` is the gold standard — when in doubt, match its patterns
|