@rubytech/create-maxy-code 0.1.484 → 0.1.486

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 (92) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/docs/superpowers/plans/2026-07-21-graph-caption-resolver.md +124 -0
  3. package/payload/platform/docs/superpowers/plans/2026-07-21-task-1877-bash-schema-enforcement.md +107 -0
  4. package/payload/platform/docs/superpowers/plans/2026-07-21-task-1887-reconcile-allowed-top-level.md +205 -0
  5. package/payload/platform/lib/graph-style/dist/index.d.ts +4 -1
  6. package/payload/platform/lib/graph-style/dist/index.d.ts.map +1 -1
  7. package/payload/platform/lib/graph-style/dist/index.js +109 -1
  8. package/payload/platform/lib/graph-style/dist/index.js.map +1 -1
  9. package/payload/platform/lib/graph-style/src/__tests__/caption-coverage.test.ts +117 -0
  10. package/payload/platform/lib/graph-style/src/index.ts +81 -1
  11. package/payload/platform/plugins/admin/hooks/__tests__/fs-schema-guard-bash.test.sh +159 -0
  12. package/payload/platform/plugins/admin/hooks/__tests__/fs-schema-guard.test.sh +18 -6
  13. package/payload/platform/plugins/admin/hooks/fs-schema-guard-bash-post.sh +141 -0
  14. package/payload/platform/plugins/admin/hooks/fs-schema-guard-bash-pre.sh +66 -0
  15. package/payload/platform/plugins/admin/hooks/fs-schema-guard.sh +33 -1
  16. package/payload/platform/plugins/admin/skills/professional-document/SKILL.md +3 -3
  17. package/payload/platform/plugins/cloudflare/PLUGIN.md +2 -0
  18. package/payload/platform/plugins/cloudflare/bin/portal-enrol.mjs +23 -2
  19. package/payload/platform/plugins/cloudflare/mcp/__tests__/authorize.test.ts +2 -2
  20. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-directory-listing.test.ts +49 -2
  21. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-enrol.test.ts +57 -3
  22. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts +44 -2
  23. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-session-grant.test.ts +67 -0
  24. package/payload/platform/plugins/cloudflare/mcp/__tests__/template-config.test.ts +26 -0
  25. package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +42 -3
  26. package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +10 -1
  27. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/session.ts +19 -5
  28. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +19 -2
  29. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts +8 -2
  30. package/payload/platform/plugins/cloudflare/skills/data-portal/template/index.html +44 -13
  31. package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.css +220 -6
  32. package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.js +216 -17
  33. package/payload/platform/plugins/cloudflare/skills/data-portal/template/wrangler.toml +4 -3
  34. package/payload/platform/plugins/email/.claude-plugin/plugin.json +1 -1
  35. package/payload/platform/plugins/email/PLUGIN.md +5 -1
  36. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-select.test.d.ts +2 -0
  37. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-select.test.d.ts.map +1 -0
  38. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-select.test.js +50 -0
  39. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-select.test.js.map +1 -0
  40. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-text.test.d.ts +2 -0
  41. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-text.test.d.ts.map +1 -0
  42. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-text.test.js +51 -0
  43. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-text.test.js.map +1 -0
  44. package/payload/platform/plugins/email/mcp/dist/__tests__/email-fetch-attachment.test.d.ts +2 -0
  45. package/payload/platform/plugins/email/mcp/dist/__tests__/email-fetch-attachment.test.d.ts.map +1 -0
  46. package/payload/platform/plugins/email/mcp/dist/__tests__/email-fetch-attachment.test.js +89 -0
  47. package/payload/platform/plugins/email/mcp/dist/__tests__/email-fetch-attachment.test.js.map +1 -0
  48. package/payload/platform/plugins/email/mcp/dist/index.js +28 -0
  49. package/payload/platform/plugins/email/mcp/dist/index.js.map +1 -1
  50. package/payload/platform/plugins/email/mcp/dist/lib/attachment-text.d.ts +22 -0
  51. package/payload/platform/plugins/email/mcp/dist/lib/attachment-text.d.ts.map +1 -0
  52. package/payload/platform/plugins/email/mcp/dist/lib/attachment-text.js +55 -0
  53. package/payload/platform/plugins/email/mcp/dist/lib/attachment-text.js.map +1 -0
  54. package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts +32 -0
  55. package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts.map +1 -1
  56. package/payload/platform/plugins/email/mcp/dist/lib/imap.js +75 -1
  57. package/payload/platform/plugins/email/mcp/dist/lib/imap.js.map +1 -1
  58. package/payload/platform/plugins/email/mcp/dist/tools/email-fetch-attachment.d.ts +19 -0
  59. package/payload/platform/plugins/email/mcp/dist/tools/email-fetch-attachment.d.ts.map +1 -0
  60. package/payload/platform/plugins/email/mcp/dist/tools/email-fetch-attachment.js +49 -0
  61. package/payload/platform/plugins/email/mcp/dist/tools/email-fetch-attachment.js.map +1 -0
  62. package/payload/platform/plugins/email/references/email-reference.md +7 -1
  63. package/payload/platform/plugins/memory/references/schema-construction.md +18 -12
  64. package/payload/platform/plugins/memory/references/schema-estate-agent.md +11 -6
  65. package/payload/platform/scripts/__tests__/account-schema-owned-dirs.test.sh +75 -6
  66. package/payload/platform/scripts/lib/account-schema-owned-dirs.py +111 -22
  67. package/payload/platform/scripts/lib/provision-account-dir.sh +8 -1
  68. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.d.ts +39 -0
  69. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.d.ts.map +1 -0
  70. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.js +299 -0
  71. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.js.map +1 -0
  72. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
  73. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +1 -0
  74. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
  75. package/payload/platform/services/claude-session-manager/dist/index.js +15 -0
  76. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  77. package/payload/platform/services/claude-session-manager/dist/system-prompt.d.ts.map +1 -1
  78. package/payload/platform/services/claude-session-manager/dist/system-prompt.js +4 -0
  79. package/payload/platform/services/claude-session-manager/dist/system-prompt.js.map +1 -1
  80. package/payload/platform/templates/account-schema/SCHEMA.md +17 -0
  81. package/payload/platform/templates/specialists/agents/personal-assistant.md +1 -1
  82. package/payload/server/{chunk-XGNRSM57.js → chunk-HN4HFTXE.js} +6 -0
  83. package/payload/server/maxy-edge.js +1 -1
  84. package/payload/server/public/assets/{admin-rScqRq-R.js → admin-BoVCP4Mw.js} +1 -1
  85. package/payload/server/public/assets/{data-TOWuhMFb.js → data-CtOxa94P.js} +1 -1
  86. package/payload/server/public/assets/{graph-labels-Axa04w8A.js → graph-labels-C8-sIKuv.js} +1 -1
  87. package/payload/server/public/assets/{graph-BEGs_P2m.js → graph-mJ9ky5dc.js} +1 -1
  88. package/payload/server/public/assets/{page-6CZ8R57f.js → page-Cv0qUwMG.js} +1 -1
  89. package/payload/server/public/data.html +3 -3
  90. package/payload/server/public/graph.html +2 -2
  91. package/payload/server/public/index.html +3 -3
  92. package/payload/server/server.js +125 -34
@@ -144,3 +144,29 @@ describe('device binding', () => {
144
144
  expect(text).toMatch(/accountId\s+TEXT NOT NULL DEFAULT ''/)
145
145
  })
146
146
  })
147
+
148
+ const portalJs = () => readFileSync(join(TEMPLATE, 'portal.js'), 'utf8')
149
+ const indexHtml = () => readFileSync(join(TEMPLATE, 'index.html'), 'utf8')
150
+
151
+ describe('login error surfacing and passcode reveal (Task 1872)', () => {
152
+ it('maps each auth failure to a distinct message, not one opaque string', () => {
153
+ const js = portalJs()
154
+ expect(js).not.toMatch(/That did not work\./)
155
+ expect(js).toContain('Too many attempts. Try again later.')
156
+ expect(js).toContain('Enter both your name and your passcode.')
157
+ expect(js).toContain('That name and passcode did not match.')
158
+ expect(js).toContain('Something went wrong. Please try again in a moment.')
159
+ expect(js).toContain('Could not reach the server. Check your connection and try again.')
160
+ })
161
+
162
+ it('logs the status and error on the auth failure path', () => {
163
+ expect(portalJs()).toMatch(/console\.error\(\s*'\[data-portal\] auth failed'/)
164
+ })
165
+
166
+ it('ships a reveal control bound to the passcode field', () => {
167
+ const html = indexHtml()
168
+ expect(html).toMatch(/id="dp-reveal"/)
169
+ expect(html).toMatch(/aria-controls="dp-passcode"/)
170
+ expect(portalJs()).toMatch(/dp-reveal/)
171
+ })
172
+ })
@@ -48,7 +48,7 @@ For each person:
48
48
 
49
49
  ```bash
50
50
  node platform/plugins/cloudflare/bin/portal-enrol.mjs --owner <ownerId> --name <name> \
51
- --account <accountId> | npx wrangler d1 execute <portalDbName> --remote --file=-
51
+ --account <accountId> [--folders <a,b,c>] | npx wrangler d1 execute <portalDbName> --remote --file=-
52
52
  ```
53
53
 
54
54
  The script mints the passcode and prints it **once to stderr**, so it reaches the operator's terminal
@@ -66,6 +66,15 @@ a sub-account that genuinely has nothing to share, so the script refuses rather
66
66
  fails silently later. Anyone enrolled before this existed has no account bound and sees nothing; run
67
67
  enrolment again for them, which the upsert makes a re-run rather than a migration.
68
68
 
69
+ `--folders` is optional and narrows what one person sees to a named set of top-level folders, comma
70
+ separated (`--folders quotes,output`). Each name is a folder, never a path, and must match
71
+ `[A-Za-z0-9_-]+`. Omitting it, or passing an empty value, grants the account's full exposed set,
72
+ which is the existing behaviour, so people enrolled before this existed are unaffected. Two people on
73
+ one account can hold different grants and each sees only their own. Re-enrolment replaces the grant,
74
+ the same upsert that rotates the passcode. The grant lives only in the portal's D1, so it narrows
75
+ below the account's exposed set and never widens past it: a portal compromise returns a person to the
76
+ account set, which the install re-derives and enforces regardless.
77
+
69
78
  **A passcode is never stored in plaintext** — not in `data-portal.json`, not in a log line, nowhere
70
79
  on the device. The operator conveys it to the person out of band. Rotating a passcode is a re-run of
71
80
  the script for that person; there is no separate editor.
@@ -122,8 +131,16 @@ The portal shows the sub-account's deliverable folders as well as what the clien
122
131
  folder names and sizes live in D1, so the tree renders whether or not the device is up; the bytes do
123
132
  not, so opening a file has to reach the install. That needs two things the template cannot know.
124
133
 
125
- Fill `__PORTAL_INSTALL_ORIGIN__` in `wrangler.toml` with the install's public origin
126
- (`https://<tunnel-hostname>`), then mint one secret and set it on both ends:
134
+ The shared tree has two row kinds. `renderEntries` in `portal.js` gives a `directory` entry a folder
135
+ icon in the accent colour and a file entry a document icon in the muted grey, both inline lucide SVG,
136
+ so a client tells a folder from a file at a glance.
137
+
138
+ Fill `__PORTAL_INSTALL_ORIGIN__` in `wrangler.toml` with the install's PUBLIC origin
139
+ (`https://<public-tunnel-hostname>`), never the admin or operator hostname. The admin
140
+ origin is behind the remote-auth gate, so the edge's signed HEAD probe of a device file
141
+ gets a 401 there and every download 503s. The public host admits `/api/portal/fetch` by
142
+ allowlist and the route verifies the signature itself. Then mint one secret and set it on
143
+ both ends:
127
144
 
128
145
  ```bash
129
146
  SECRET="$(node -e 'console.log([...crypto.getRandomValues(new Uint8Array(32))].map(b=>b.toString(16).padStart(2,"0")).join(""))')"
@@ -177,6 +194,28 @@ the folders you expect:
177
194
  `exposed=none` on an account that has deliverables means the schema was missing or its ontology
178
195
  region was absent — not that the walk found nothing.
179
196
 
197
+ ## How a client browses, downloads and uploads
198
+
199
+ Signed in, the panel has a pinned header that stays put while the file lists
200
+ scroll under it. The header carries the folder breadcrumbs, an up control that
201
+ moves to the parent folder (disabled at the root), and an upload icon. Filenames
202
+ render on one line: the stem truncates with an ellipsis and the extension stays
203
+ pinned, so `.pdf` and `.html` on two same-named orders are still told apart.
204
+
205
+ A shared (device-held) file downloads three ways, all through the same signed
206
+ `/api/download?path=` route: a left click, a right-click that opens a one-item
207
+ Download menu, and a drag out of the browser onto the desktop. The drag uses
208
+ `/api/download?path=...&redirect=1`, which 302s to the signed link so the browser
209
+ saves the file rather than a JSON body. The three download methods share the one
210
+ `op=fetch-through` log line, so a broken method shows as a missing line under the
211
+ same signal.
212
+
213
+ Upload opens from the header icon into a dismissable modal holding the file
214
+ picker and Upload button. It posts to `/api/upload` exactly as before; a kept
215
+ upload refreshes the list and closes the modal. Dropping a file onto the modal's
216
+ upload area uploads the same way. The modal dismisses on the close button, a
217
+ click on the backdrop, or Escape.
218
+
180
219
  ## Deploy through site-deploy
181
220
 
182
221
  `storage-pages-deploy` refuses a project with no owner on record, so record the ownership first. In
@@ -24,13 +24,22 @@
24
24
  -- predates it — but an existing table will NOT gain the column, and a person
25
25
  -- whose row has the empty default sees an empty folder list. Re-enrol those
26
26
  -- people; the upsert on ownerId makes that a re-run, not a migration.
27
+ --
28
+ -- `folders` is a per-person allowlist of top-level folder names, comma
29
+ -- separated, that narrows what this person sees below the account's exposed
30
+ -- set. Empty (the default) means the full set, so an existing person keeps
31
+ -- today's behaviour. Like `accountId`, IF NOT EXISTS will NOT add this column
32
+ -- to a `people` table that predates it, and enrolment's INSERT names `folders`,
33
+ -- so it fails loudly on such a table. Add it by hand in that case:
34
+ -- ALTER TABLE people ADD COLUMN folders TEXT NOT NULL DEFAULT '';
27
35
  CREATE TABLE IF NOT EXISTS people (
28
36
  ownerId TEXT PRIMARY KEY,
29
37
  name TEXT NOT NULL,
30
38
  salt TEXT NOT NULL,
31
39
  hash TEXT NOT NULL,
32
40
  createdAt TEXT NOT NULL,
33
- accountId TEXT NOT NULL DEFAULT ''
41
+ accountId TEXT NOT NULL DEFAULT '',
42
+ folders TEXT NOT NULL DEFAULT ''
34
43
  );
35
44
 
36
45
  -- `pcCheck` is a short digest of the person's passcode hash at mint time, and
@@ -54,11 +54,11 @@ export async function resolveSession(
54
54
  db: D1Database,
55
55
  sessionId: string,
56
56
  nowMs: number,
57
- ): Promise<{ ownerId: string; accountId: string } | null> {
57
+ ): Promise<{ ownerId: string; accountId: string; folders: string[] } | null> {
58
58
  if (!sessionId) return null
59
59
  const row = await db
60
60
  .prepare(
61
- `SELECT s.ownerId AS ownerId, p.accountId AS accountId
61
+ `SELECT s.ownerId AS ownerId, p.accountId AS accountId, p.folders AS folders
62
62
  FROM sessions s
63
63
  JOIN people p ON p.ownerId = s.ownerId
64
64
  WHERE s.sessionId = ?
@@ -66,12 +66,26 @@ export async function resolveSession(
66
66
  AND s.pcCheck = substr(p.hash, 1, 16)`,
67
67
  )
68
68
  .bind(sessionId, nowMs)
69
- .first<{ ownerId: string; accountId: string }>()
69
+ .first<{ ownerId: string; accountId: string; folders: string }>()
70
70
  if (!row || typeof row.ownerId !== 'string') return null
71
71
  // A `people` row predating the accountId column resolves it as absent. Treat
72
- // that as "no folders", never as "all folders" the empty string reaches the
72
+ // that as "no folders", never as "all folders": the empty string reaches the
73
73
  // listing, which returns nothing rather than falling back to some default.
74
- return { ownerId: row.ownerId, accountId: typeof row.accountId === 'string' ? row.accountId : '' }
74
+ const accountId = typeof row.accountId === 'string' ? row.accountId : ''
75
+ // `folders` is a comma-separated allowlist of top-level names. Absent or empty
76
+ // means no grant, which the two client surfaces read as no filter (full set).
77
+ const folders =
78
+ typeof row.folders === 'string' ? row.folders.split(',').map((s) => s.trim()).filter(Boolean) : []
79
+ return { ownerId: row.ownerId, accountId, folders }
80
+ }
81
+
82
+ /** Whether a person's folder grant admits `relPath`. An empty grant admits
83
+ * everything (the full set, today's behaviour); otherwise the path's top-level
84
+ * segment must be named in the grant. The test is on the whole segment, so
85
+ * `quote` never matches `quotes`. One definition so the listing and the
86
+ * download surface can never disagree on what is granted. */
87
+ export function grantAllows(grant: string[], relPath: string): boolean {
88
+ return grant.length === 0 || grant.includes(relPath.split('/')[0])
75
89
  }
76
90
 
77
91
  export function readSessionCookie(header: string | null): string | null {
@@ -1,5 +1,5 @@
1
1
  import type { Handler, Logger, PortalEnv, R2ObjectBody } from './_lib/types'
2
- import { readSessionCookie, resolveSession } from './_lib/session'
2
+ import { grantAllows, readSessionCookie, resolveSession } from './_lib/session'
3
3
  import { q } from './_lib/log'
4
4
  import { authorizeKey } from './_lib/authorize'
5
5
  import { buildSignedUrl, SIGNED_TTL_MS } from './_lib/portal-sign.mjs'
@@ -73,6 +73,16 @@ export async function processIndexedDownload(
73
73
  return { status: 404, payload: { ok: false, error: 'not found' } }
74
74
  }
75
75
 
76
+ if (!grantAllows(session.folders, relPath)) {
77
+ // Narrowed below the account set by the person's grant. Refused before the
78
+ // directory read and the device probe, so an out-of-grant path costs no
79
+ // work and is distinguishable in the log from a path simply not indexed.
80
+ log(
81
+ `[data-portal] op=fetch-through owner=${q(session.ownerId)} path=${q(relPath)} result=not-granted`,
82
+ )
83
+ return { status: 404, payload: { ok: false, error: 'not found' } }
84
+ }
85
+
76
86
  const row = await env.DB.prepare('SELECT relPath FROM directory WHERE accountId = ? AND relPath = ?')
77
87
  .bind(session.accountId, relPath)
78
88
  .first<{ relPath: string }>()
@@ -135,7 +145,7 @@ export async function onRequestGet(context: PagesContext): Promise<Response> {
135
145
  // below is untouched.
136
146
  const indexedPath = params.get('path')
137
147
  if (indexedPath) {
138
- const { status, payload } = await processIndexedDownload(
148
+ const { status, payload, url } = await processIndexedDownload(
139
149
  sessionId,
140
150
  indexedPath,
141
151
  context.env,
@@ -143,6 +153,13 @@ export async function onRequestGet(context: PagesContext): Promise<Response> {
143
153
  Date.now(),
144
154
  fetch as unknown as HeadFetch,
145
155
  )
156
+ // Drag-out cannot read a JSON body: the browser follows a DownloadURL to a
157
+ // real response. When the file resolved, 302 the drag to the signed url so
158
+ // the browser downloads it; otherwise fall through to the same JSON the
159
+ // click path returns. Same op=fetch-through line for every method.
160
+ if (params.get('redirect') === '1' && status === 200 && url) {
161
+ return Response.redirect(url, 302)
162
+ }
146
163
  return Response.json(payload, { status })
147
164
  }
148
165
 
@@ -1,5 +1,5 @@
1
1
  import type { Handler, Logger, PortalEnv } from './_lib/types'
2
- import { readSessionCookie, resolveSession } from './_lib/session'
2
+ import { grantAllows, readSessionCookie, resolveSession } from './_lib/session'
3
3
  import { q } from './_lib/log'
4
4
 
5
5
  interface DirRow {
@@ -86,17 +86,23 @@ export async function processFiles(
86
86
  // means no folders: an enrolment predating that column lands here, and the
87
87
  // empty `account=` field in the log line is what says so.
88
88
  let entries: DirEntry[] = []
89
+ const grant = session.folders
89
90
  if (session.accountId) {
90
91
  const dir = await env.DB.prepare(
91
92
  'SELECT relPath, sizeBytes, modifiedAt FROM directory WHERE accountId = ?',
92
93
  )
93
94
  .bind(session.accountId)
94
95
  .all<DirRow>()
95
- entries = entriesUnder(dir.results ?? [], prefix)
96
+ // The grant narrows below the account's exposed set: keep only rows the
97
+ // grant admits. An empty grant admits everything, which is the full set and
98
+ // today's behaviour.
99
+ const rows = (dir.results ?? []).filter((row) => grantAllows(grant, row.relPath))
100
+ entries = entriesUnder(rows, prefix)
96
101
  }
97
102
 
98
103
  log(
99
104
  `[data-portal] op=list owner=${q(session.ownerId)} account=${q(session.accountId)} ` +
105
+ `granted=${grant.length === 0 ? 'full' : grant.length} ` +
100
106
  `prefix=${q(prefix)} files=${files.length} entries=${entries.length} result=ok`,
101
107
  )
102
108
  return { status: 200, payload: { ok: true, files, entries } }
@@ -18,22 +18,39 @@
18
18
  <label class="dp-label" for="dp-owner">Your name</label>
19
19
  <input class="dp-input" id="dp-owner" name="owner" autocomplete="username" required />
20
20
  <label class="dp-label" for="dp-passcode">Passcode</label>
21
- <input
22
- class="dp-input"
23
- id="dp-passcode"
24
- name="passcode"
25
- type="password"
26
- autocomplete="current-password"
27
- required
28
- />
21
+ <div class="dp-field">
22
+ <input
23
+ class="dp-input"
24
+ id="dp-passcode"
25
+ name="passcode"
26
+ type="password"
27
+ autocomplete="current-password"
28
+ required
29
+ />
30
+ <button
31
+ type="button"
32
+ class="dp-reveal"
33
+ id="dp-reveal"
34
+ aria-controls="dp-passcode"
35
+ >Show passcode</button>
36
+ </div>
29
37
  <button class="dp-btn" type="submit">Sign in</button>
30
38
  </form>
31
39
 
32
40
  <section id="dp-panel" class="dp-panel" hidden>
33
- <form id="dp-upload" class="dp-upload">
34
- <input class="dp-file" id="dp-file" type="file" required />
35
- <button class="dp-btn" type="submit">Upload</button>
36
- </form>
41
+ <!-- Pinned nav. Stays put while the lists below scroll, so the folder
42
+ path and the upload control are always in reach. The crumbs live
43
+ here rather than inside #dp-shared so the header is one row. -->
44
+ <header class="dp-header">
45
+ <button class="dp-iconbtn" id="dp-up" type="button" aria-label="Up one level" disabled>
46
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12 7-7 7 7"/><path d="M12 19V5"/></svg>
47
+ </button>
48
+ <nav id="dp-crumbs" class="dp-crumbs" aria-label="Folder path"></nav>
49
+ <button class="dp-iconbtn" id="dp-upload-open" type="button" aria-label="Upload a file">
50
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" x2="12" y1="3" y2="15"/></svg>
51
+ </button>
52
+ </header>
53
+
37
54
  <ul id="dp-files" class="dp-files"></ul>
38
55
 
39
56
  <!-- Files we have prepared for you, published from the device. Hidden
@@ -41,9 +58,23 @@
41
58
  shows the upload list alone rather than an empty second heading. -->
42
59
  <section id="dp-shared" class="dp-shared" hidden>
43
60
  <h2 class="dp-subtitle">Shared with you</h2>
44
- <nav id="dp-crumbs" class="dp-crumbs" aria-label="Folder path"></nav>
45
61
  <ul id="dp-entries" class="dp-files"></ul>
46
62
  </section>
63
+
64
+ <!-- Upload moved off the always-inline row into a modal opened by the
65
+ header icon, so browsing is not crowded by the picker. Hidden via
66
+ the `hidden` attribute, which [hidden]{display:none!important} keeps
67
+ authoritative over .dp-modal's display:flex. -->
68
+ <div class="dp-modal" id="dp-modal" hidden>
69
+ <div class="dp-modal-panel" role="dialog" aria-modal="true" aria-label="Upload a file">
70
+ <button class="dp-modal-close" id="dp-modal-close" type="button" aria-label="Close">&times;</button>
71
+ <h2 class="dp-subtitle">Upload a file</h2>
72
+ <form id="dp-upload" class="dp-upload">
73
+ <input class="dp-file" id="dp-file" type="file" required />
74
+ <button class="dp-btn" type="submit">Upload</button>
75
+ </form>
76
+ </div>
77
+ </div>
47
78
  </section>
48
79
  </main>
49
80
  <script src="/portal.js"></script>
@@ -29,11 +29,11 @@ body {
29
29
  padding: 2rem 1rem;
30
30
  background: var(--dp-paper);
31
31
  color: var(--dp-ink);
32
- font: 16px/1.5 system-ui, -apple-system, 'Segoe UI', sans-serif;
32
+ font: 14px/1.5 system-ui, -apple-system, 'Segoe UI', sans-serif;
33
33
  }
34
34
 
35
35
  .dp-card {
36
- max-width: 34rem;
36
+ max-width: 56rem;
37
37
  margin: 0 auto;
38
38
  }
39
39
 
@@ -98,16 +98,45 @@ body {
98
98
  border-bottom: 1px solid var(--dp-rule);
99
99
  }
100
100
 
101
+ /* One line per name, never wrapped. The name splits into a stem that
102
+ ellipsis-truncates and an extension that never shrinks, so `.pdf` vs `.html`
103
+ on two same-named orders stays visible. flex:1 + min-width:0 lets the name
104
+ give up width to the Delete button beside it. */
101
105
  .dp-file-name {
102
106
  color: var(--dp-accent);
103
107
  text-decoration: none;
104
- word-break: break-all;
108
+ display: flex;
109
+ align-items: center;
110
+ gap: 0.5rem;
111
+ flex: 1;
112
+ min-width: 0;
113
+ overflow: hidden;
105
114
  }
106
115
 
107
116
  .dp-file-name:hover {
108
117
  text-decoration: underline;
109
118
  }
110
119
 
120
+ /* The two-span name block. No gap between stem and extension so they read as
121
+ one filename; the stem clips with an ellipsis, the extension is pinned. */
122
+ .dp-name {
123
+ display: flex;
124
+ min-width: 0;
125
+ overflow: hidden;
126
+ }
127
+
128
+ .dp-name-main {
129
+ overflow: hidden;
130
+ text-overflow: ellipsis;
131
+ white-space: nowrap;
132
+ min-width: 0;
133
+ }
134
+
135
+ .dp-name-ext {
136
+ flex: none;
137
+ white-space: nowrap;
138
+ }
139
+
111
140
  .dp-del {
112
141
  border: 1px solid var(--dp-rule);
113
142
  border-radius: 0.375rem;
@@ -140,10 +169,15 @@ body {
140
169
  color: var(--dp-ink);
141
170
  }
142
171
 
172
+ /* In the pinned header the crumbs are the flexible middle: they take the space
173
+ the up/upload buttons leave and clip on one line when a path runs deep. */
143
174
  .dp-crumbs {
144
175
  font-size: 13px;
145
176
  color: var(--dp-muted);
146
- margin-bottom: 10px;
177
+ flex: 1;
178
+ min-width: 0;
179
+ overflow: hidden;
180
+ white-space: nowrap;
147
181
  }
148
182
 
149
183
  /* Crumbs and folder rows are buttons (navigation is a fetch, not a page load),
@@ -166,10 +200,19 @@ body {
166
200
 
167
201
  .dp-folder-name {
168
202
  font-weight: 600;
203
+ display: flex;
204
+ align-items: center;
205
+ gap: 0.5rem;
206
+ flex: 1;
207
+ min-width: 0;
208
+ overflow: hidden;
169
209
  }
170
210
 
171
- /* The file rows inside the tree are buttons too — same reset, but they must
172
- match .dp-file-name's weight so the two lists read as one surface. */
211
+ /* The file rows inside the tree are buttons too — same reset. The layout (flex,
212
+ flex:1, min-width:0, overflow) comes from the base .dp-file-name, so these
213
+ truncate the same way; only the button chrome and left-align are added here.
214
+ The row-kind icon plus its colour (below) is what tells a folder from a file,
215
+ so these no longer match .dp-folder-name's weight. */
173
216
  .dp-shared .dp-file-name {
174
217
  background: none;
175
218
  border: 0;
@@ -178,3 +221,174 @@ body {
178
221
  cursor: pointer;
179
222
  text-align: left;
180
223
  }
224
+
225
+ /* Row-kind icon: sized to the text, never shrinks, stroke follows the span's
226
+ colour. Folder takes the accent, file the muted grey — both existing vars, so
227
+ the :root rewrite in portal-brand-css.mjs leaves the distinction intact. */
228
+ .dp-icon {
229
+ flex: none;
230
+ display: inline-flex;
231
+ width: 1em;
232
+ height: 1em;
233
+ }
234
+
235
+ .dp-icon svg {
236
+ width: 1em;
237
+ height: 1em;
238
+ display: block;
239
+ }
240
+
241
+ .dp-icon-folder {
242
+ color: var(--dp-accent);
243
+ }
244
+
245
+ .dp-icon-file {
246
+ color: var(--dp-muted);
247
+ }
248
+
249
+ /* Passcode field with its reveal control. The passcode is transcribed out of
250
+ band, so being able to check what was typed is the point of the toggle.
251
+ Reuses existing variables and declares no new :root value, which
252
+ portal-brand-css.mjs would drop. */
253
+ .dp-field {
254
+ display: flex;
255
+ gap: 0.5rem;
256
+ align-items: stretch;
257
+ }
258
+
259
+ .dp-field .dp-input {
260
+ flex: 1;
261
+ }
262
+
263
+ .dp-reveal {
264
+ border: 1px solid var(--dp-rule);
265
+ border-radius: 0.375rem;
266
+ background: transparent;
267
+ color: var(--dp-muted);
268
+ padding: 0.375rem 0.625rem;
269
+ font: inherit;
270
+ cursor: pointer;
271
+ white-space: nowrap;
272
+ }
273
+
274
+ /* Right-click Download menu for a shared file. Reuses existing variables and
275
+ declares no new :root value, which portal-brand-css.mjs would drop. */
276
+ .dp-menu {
277
+ position: fixed;
278
+ z-index: 10;
279
+ background: var(--dp-paper);
280
+ border: 1px solid var(--dp-rule);
281
+ border-radius: 0.375rem;
282
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
283
+ padding: 0.25rem;
284
+ min-width: 8.75rem;
285
+ }
286
+
287
+ .dp-menu-item {
288
+ display: block;
289
+ width: 100%;
290
+ text-align: left;
291
+ padding: 0.375rem 0.625rem;
292
+ border: 0;
293
+ background: transparent;
294
+ border-radius: 0.25rem;
295
+ cursor: pointer;
296
+ font: inherit;
297
+ color: var(--dp-ink);
298
+ }
299
+
300
+ .dp-menu-item:hover,
301
+ .dp-menu-item:focus {
302
+ background: var(--dp-rule);
303
+ }
304
+
305
+ /* The upload area while a file is dragged over it. */
306
+ .dp-upload.dp-dropping {
307
+ outline: 2px dashed var(--dp-accent);
308
+ outline-offset: 3px;
309
+ }
310
+
311
+ /* Pinned nav row. position:sticky keeps it in flow, so it reserves its own
312
+ height and never overlaps the first file row (which position:fixed would). An
313
+ opaque paper background hides the rows that scroll under it. Reuses existing
314
+ variables and declares no new :root value, which portal-brand-css.mjs drops. */
315
+ .dp-header {
316
+ position: sticky;
317
+ top: 0;
318
+ z-index: 5;
319
+ display: flex;
320
+ align-items: center;
321
+ gap: 0.5rem;
322
+ padding: 0.5rem 0;
323
+ margin-bottom: 0.5rem;
324
+ background: var(--dp-paper);
325
+ border-bottom: 1px solid var(--dp-rule);
326
+ }
327
+
328
+ /* The up and upload controls that flank the crumbs. Square, muted chrome so the
329
+ filenames stay the loudest thing on the row. */
330
+ .dp-iconbtn {
331
+ flex: none;
332
+ display: inline-flex;
333
+ align-items: center;
334
+ justify-content: center;
335
+ width: 2rem;
336
+ height: 2rem;
337
+ padding: 0.375rem;
338
+ border: 1px solid var(--dp-rule);
339
+ border-radius: 0.375rem;
340
+ background: transparent;
341
+ color: var(--dp-muted);
342
+ cursor: pointer;
343
+ }
344
+
345
+ .dp-iconbtn svg {
346
+ width: 1.1em;
347
+ height: 1.1em;
348
+ }
349
+
350
+ /* Disabled up control at the tree root: dimmed and inert, so it reads as "no
351
+ parent from here" rather than a dead button. */
352
+ .dp-iconbtn:disabled {
353
+ opacity: 0.4;
354
+ cursor: default;
355
+ }
356
+
357
+ /* Upload modal. [hidden]{display:none!important} keeps this off screen until the
358
+ header icon clears the attribute; display:flex then centres the panel. The
359
+ backdrop is a non-themed literal, same as .dp-menu's shadow. */
360
+ .dp-modal {
361
+ position: fixed;
362
+ inset: 0;
363
+ z-index: 20;
364
+ display: flex;
365
+ align-items: center;
366
+ justify-content: center;
367
+ padding: 1rem;
368
+ background: rgba(0, 0, 0, 0.4);
369
+ }
370
+
371
+ .dp-modal-panel {
372
+ position: relative;
373
+ width: 100%;
374
+ max-width: 24rem;
375
+ padding: 1.5rem;
376
+ background: var(--dp-paper);
377
+ border: 1px solid var(--dp-rule);
378
+ border-radius: 0.5rem;
379
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
380
+ }
381
+
382
+ .dp-modal-close {
383
+ position: absolute;
384
+ top: 0.5rem;
385
+ right: 0.5rem;
386
+ padding: 0.25rem 0.5rem;
387
+ border: 0;
388
+ background: transparent;
389
+ color: var(--dp-muted);
390
+ font: inherit;
391
+ font-size: 1.25rem;
392
+ line-height: 1;
393
+ cursor: pointer;
394
+ }