@stacksjs/defaults 0.75.92 → 0.75.93

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.
@@ -1,14 +1,16 @@
1
1
  import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions/runtime'
3
3
  import { db, getDatabaseDialect, sqlHelpers } from '@stacksjs/database/runtime'
4
+ import { removeCardAttachments, storageDisks } from './card-attachments'
4
5
  import { kanbanActionError, kanbanError } from './kanban-response'
5
6
 
6
7
  /**
7
8
  * `DELETE /api/dashboard/kanban/boards/:id`.
8
9
  *
9
10
  * Hard delete with cascade: removes the board, its columns, its cards,
10
- * its labels, and the relevant pivot rows in `card_labels` and
11
- * `card_assignees`. No DB-level FK cascade to lean on — the existing
11
+ * its labels, the relevant pivot rows in `card_labels` and
12
+ * `card_assignees`, the cards' attachments (files and rows), and the
13
+ * feedback links issued for the board. No DB-level FK cascade to lean on — the existing
12
14
  * Stacks migration convention is app-layer integrity, so the cascade
13
15
  * lives in this action.
14
16
  *
@@ -31,11 +33,18 @@ export default new Action({
31
33
  }
32
34
 
33
35
  try {
36
+ const openDisk = await storageDisks()
34
37
  await db.transaction(async (rawTrx) => {
35
38
  const qb = rawTrx as unknown as typeof db
36
39
  // Postgres numbers its parameters, so the `?` these carried bound
37
40
  // nothing there (stacksjs/stacks#2846).
38
41
  const { param } = sqlHelpers(getDatabaseDialect())
42
+ // Attachments first, files and rows (stacksjs/stacks#2881), so a disk
43
+ // that refuses leaves every card here whole.
44
+ await removeCardAttachments(qb, {
45
+ sql: `card_id IN (SELECT id FROM cards WHERE board_id = ${param(1)})`,
46
+ params: [id],
47
+ }, openDisk)
39
48
  // Pivots + card-scoped children first — they reference cards.
40
49
  await qb.unsafe(
41
50
  `DELETE FROM card_labels WHERE card_id IN (SELECT id FROM cards WHERE board_id = ${param(1)})`,
@@ -53,6 +62,9 @@ export default new Action({
53
62
  ).execute()
54
63
  // Cards (denormalised board_id avoids the column join).
55
64
  await qb.deleteFrom('cards').where('board_id', '=', id).execute()
65
+ // Feedback links name this board. Left behind they are tokens for a
66
+ // board that no longer exists, each carrying who it was issued to.
67
+ await qb.deleteFrom('feedback_tokens').where('board_id', '=', id).execute()
56
68
  // Columns + labels — both directly reference board_id.
57
69
  await qb.deleteFrom('board_columns').where('board_id', '=', id).execute()
58
70
  await qb.deleteFrom('labels').where('board_id', '=', id).execute()
@@ -1,14 +1,16 @@
1
1
  import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions/runtime'
3
- import { db } from '@stacksjs/database/runtime'
3
+ import { db, getDatabaseDialect, sqlHelpers } from '@stacksjs/database/runtime'
4
+ import { removeCardAttachments, storageDisks } from './card-attachments'
4
5
  import { kanbanActionError, kanbanError } from './kanban-response'
5
6
 
6
7
  /**
7
8
  * `DELETE /api/dashboard/kanban/cards/:id`.
8
9
  *
9
10
  * Hard-deletes a card and clears its pivot rows in `card_labels` /
10
- * `card_assignees`. The columns and board are untouched — only the
11
- * single card row + its pivot dependencies go.
11
+ * `card_assignees`, its comments, and its attachments - the files on the
12
+ * disk as well as their rows (stacksjs/stacks#2881). The columns and board
13
+ * are untouched — only the single card row + its dependencies go.
12
14
  *
13
15
  * Soft-archive (the more common ask) is a PATCH with
14
16
  * `{ archived: true }` — the card stays in the DB but drops out of
@@ -26,8 +28,12 @@ export default new Action({
26
28
  }
27
29
 
28
30
  try {
31
+ const openDisk = await storageDisks()
29
32
  await db.transaction(async (rawTrx) => {
30
33
  const qb = rawTrx as unknown as typeof db
34
+ const { param } = sqlHelpers(getDatabaseDialect())
35
+ // First, so a disk that refuses leaves the card whole.
36
+ await removeCardAttachments(qb, { sql: `card_id = ${param(1)}`, params: [id] }, openDisk)
31
37
  await qb.deleteFrom('card_labels').where('card_id', '=', id).execute()
32
38
  await qb.deleteFrom('card_assignees').where('card_id', '=', id).execute()
33
39
  // Card comments are card-scoped children.
@@ -1,13 +1,15 @@
1
1
  import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions/runtime'
3
3
  import { db, getDatabaseDialect, sqlHelpers } from '@stacksjs/database/runtime'
4
+ import { removeCardAttachments, storageDisks } from './card-attachments'
4
5
  import { kanbanActionError, kanbanError } from './kanban-response'
5
6
 
6
7
  /**
7
8
  * `DELETE /api/dashboard/kanban/columns/:id`.
8
9
  *
9
10
  * Hard-deletes the column and cascade-cleans every card inside it
10
- * (plus the pivot rows referencing those cards). Same convention as
11
+ * (plus the pivot rows referencing those cards, and their attachments,
12
+ * files included). Same convention as
11
13
  * BoardDestroyAction — Stacks doesn't lean on DB-level FK cascade, so
12
14
  * the cascade lives in this action.
13
15
  *
@@ -27,11 +29,18 @@ export default new Action({
27
29
  }
28
30
 
29
31
  try {
32
+ const openDisk = await storageDisks()
30
33
  await db.transaction(async (rawTrx) => {
31
34
  const qb = rawTrx as unknown as typeof db
32
35
  // Postgres numbers its parameters, so the `?` these carried bound
33
36
  // nothing there (stacksjs/stacks#2846).
34
37
  const { param } = sqlHelpers(getDatabaseDialect())
38
+ // Attachments first, files and rows (stacksjs/stacks#2881), so a disk
39
+ // that refuses leaves every card here whole.
40
+ await removeCardAttachments(qb, {
41
+ sql: `card_id IN (SELECT id FROM cards WHERE column_id = ${param(1)})`,
42
+ params: [id],
43
+ }, openDisk)
35
44
  await qb.unsafe(
36
45
  `DELETE FROM card_labels WHERE card_id IN (SELECT id FROM cards WHERE column_id = ${param(1)})`,
37
46
  [id],
@@ -1,7 +1,10 @@
1
- import { afterAll, afterEach, describe, expect, it, spyOn } from 'bun:test'
2
- import { readFileSync } from 'node:fs'
1
+ import { afterAll, afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'
2
+ import { Database } from 'bun:sqlite'
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
3
5
  import { join } from 'node:path'
4
- import { CARD_ATTACHMENT_URL_TTL_SECONDS, shapeCardAttachment, signCardAttachmentUrl } from './card-attachments'
6
+ import { createLocalStorage } from '@stacksjs/storage'
7
+ import { CARD_ATTACHMENT_URL_TTL_SECONDS, removeCardAttachments, shapeCardAttachment, signCardAttachmentUrl } from './card-attachments'
5
8
 
6
9
  const row = {
7
10
  id: 7,
@@ -101,6 +104,117 @@ describe('signing one attachment', () => {
101
104
  })
102
105
  })
103
106
 
107
+ /**
108
+ * stacksjs/stacks#2881. Deleting a card, column or board removed the card rows
109
+ * and left every screenshot on the disk, with an orphaned row pointing at it.
110
+ * Asserting the row is gone is what made that look done, so these assert the
111
+ * bytes: a real SQLite table and a real local disk.
112
+ */
113
+ describe('removing a card\'s attachments', () => {
114
+ let dir = ''
115
+ let sqlite: Database
116
+ const disk = () => createLocalStorage({ root: join(dir, 'disk') })
117
+ const runner = {
118
+ unsafe: (sql: string, params: unknown[] = []) => ({
119
+ execute: async () => sqlite.query(sql).all(...(params as never[])),
120
+ }),
121
+ }
122
+
123
+ function attach(id: number, cardId: number, path: string): void {
124
+ mkdirSync(join(dir, 'disk', path, '..'), { recursive: true })
125
+ writeFileSync(join(dir, 'disk', path), 'a picture of somebody\'s screen')
126
+ sqlite.query('INSERT INTO card_attachments (id, card_id, disk, path) VALUES (?, ?, ?, ?)').run(id, cardId, 'local', path)
127
+ }
128
+ const onDisk = (path: string) => existsSync(join(dir, 'disk', path))
129
+ const rowsFor = (cardId: number) => sqlite.query('SELECT id FROM card_attachments WHERE card_id = ?').all(cardId).length
130
+
131
+ beforeEach(() => {
132
+ dir = mkdtempSync(join(tmpdir(), 'card-attachments-'))
133
+ sqlite = new Database(':memory:')
134
+ sqlite.run('CREATE TABLE card_attachments (id INTEGER PRIMARY KEY, card_id INTEGER, disk TEXT, path TEXT)')
135
+ })
136
+ afterEach(() => {
137
+ sqlite.close()
138
+ rmSync(dir, { recursive: true, force: true })
139
+ })
140
+
141
+ it('deletes the bytes and the rows of the cards it is given, and no others', async () => {
142
+ attach(1, 42, 'feedback/42/a.png')
143
+ attach(2, 42, 'feedback/42/b.png')
144
+ attach(3, 7, 'feedback/7/c.png')
145
+
146
+ const removed = await removeCardAttachments(runner, { sql: 'card_id = ?', params: [42] }, () => disk())
147
+
148
+ expect(removed).toBe(2)
149
+ expect(onDisk('feedback/42/a.png')).toBe(false)
150
+ expect(onDisk('feedback/42/b.png')).toBe(false)
151
+ expect(rowsFor(42)).toBe(0)
152
+ expect(onDisk('feedback/7/c.png')).toBe(true)
153
+ expect(rowsFor(7)).toBe(1)
154
+ })
155
+
156
+ it('opens the disk each row names', async () => {
157
+ attach(1, 42, 'feedback/42/a.png')
158
+ const asked: string[] = []
159
+ await removeCardAttachments(runner, { sql: 'card_id = ?', params: [42] }, (name) => {
160
+ asked.push(name)
161
+ return disk()
162
+ })
163
+ expect(asked).toEqual(['local'])
164
+ })
165
+
166
+ it('treats a file that is already gone as deleted, so a second run finishes', async () => {
167
+ attach(1, 42, 'feedback/42/a.png')
168
+ rmSync(join(dir, 'disk', 'feedback/42/a.png'))
169
+
170
+ expect(await removeCardAttachments(runner, { sql: 'card_id = ?', params: [42] }, () => disk())).toBe(1)
171
+ expect(rowsFor(42)).toBe(0)
172
+ })
173
+
174
+ it('touches no row when a disk refuses, so the rows still say where the files are', async () => {
175
+ attach(1, 42, 'feedback/42/a.png')
176
+ attach(2, 42, 'feedback/42/b.png')
177
+
178
+ const run = removeCardAttachments(runner, { sql: 'card_id = ?', params: [42] }, () => ({
179
+ deleteFile: async () => { throw new Error('S3 is unreachable') },
180
+ }))
181
+
182
+ await expect(run).rejects.toThrow('could not delete attachment 1 (local:feedback/42/a.png), so nothing was deleted: S3 is unreachable')
183
+ expect(rowsFor(42)).toBe(2)
184
+ })
185
+
186
+ it('is a no-op for a card with no attachments', async () => {
187
+ let opened = false
188
+ const removed = await removeCardAttachments(runner, { sql: 'card_id = ?', params: [42] }, () => {
189
+ opened = true
190
+ return disk()
191
+ })
192
+ expect(removed).toBe(0)
193
+ expect(opened).toBe(false)
194
+ })
195
+ })
196
+
197
+ describe('the destroy actions', () => {
198
+ const source = (file: string) => readFileSync(join(import.meta.dir, file), 'utf8')
199
+
200
+ for (const [file, scope] of [['CardDestroyAction.ts', 'card_id = '], ['ColumnDestroyAction.ts', 'WHERE column_id = '], ['BoardDestroyAction.ts', 'WHERE board_id = ']] as const) {
201
+ it(`${file} removes attachments, files included, before any card row`, () => {
202
+ const text = source(file)
203
+ const removal = text.indexOf('await removeCardAttachments(qb,')
204
+ expect(removal).toBeGreaterThan(-1)
205
+ expect(text.slice(removal, removal + 200)).toContain(scope)
206
+ // Inside the transaction, ahead of every delete, so a refusing disk
207
+ // rolls the whole thing back.
208
+ expect(removal).toBeGreaterThan(text.indexOf('await db.transaction('))
209
+ expect(removal).toBeLessThan(text.indexOf('DELETE FROM') === -1 ? text.indexOf('deleteFrom(') : Math.min(text.indexOf('DELETE FROM'), text.indexOf('deleteFrom(')))
210
+ })
211
+ }
212
+
213
+ it('BoardDestroyAction removes the board\'s feedback links', () => {
214
+ expect(source('BoardDestroyAction.ts')).toContain(`deleteFrom('feedback_tokens').where('board_id', '=', id)`)
215
+ })
216
+ })
217
+
104
218
  describe('the card dialog', () => {
105
219
  const dialog = readFileSync(
106
220
  join(import.meta.dir, '../../../../resources/components/Dashboard/Kanban/KanbanCardDialog.stx'),
@@ -105,3 +105,62 @@ export async function signCardAttachmentUrl(
105
105
  return null
106
106
  }
107
107
  }
108
+
109
+ /** What `removeCardAttachments` runs its two statements through: `db`, or a transaction. */
110
+ export interface AttachmentStatementRunner {
111
+ unsafe: (sql: string, params?: unknown[]) => { execute: () => Promise<unknown> }
112
+ }
113
+
114
+ /**
115
+ * Remove every attachment of the cards `cardsWhere` selects: the bytes, then the rows.
116
+ *
117
+ * Deleting a card, a column or a board used to leave both behind. The cascade
118
+ * those actions run lives in the application, not in a foreign key (SQLite
119
+ * does not enforce one unless asked), and none of them knew about
120
+ * `card_attachments`. So every screenshot on a deleted card stayed on the disk
121
+ * with a row pointing at it that nothing could reach (stacksjs/stacks#2881).
122
+ * A feedback screenshot is a picture of a stranger's screen, which is exactly
123
+ * what a deletion is supposed to remove.
124
+ *
125
+ * Bytes first, then rows, so it can be run again. A file already gone counts
126
+ * as deleted (that is `deleteFile`'s contract on every disk), and any other
127
+ * failure throws before a row is touched. Run inside the caller's transaction,
128
+ * that means a disk it cannot reach leaves the card exactly as it was, with
129
+ * every row still saying where its file is, rather than a card that is gone
130
+ * and a file nothing can find any more.
131
+ *
132
+ * `cardsWhere` is a predicate over `card_id`, for example
133
+ * `card_id IN (SELECT id FROM cards WHERE board_id = $1)`, with its params.
134
+ * Returns how many attachments went.
135
+ */
136
+ export async function removeCardAttachments(
137
+ handle: AttachmentStatementRunner,
138
+ cardsWhere: { sql: string, params: unknown[] },
139
+ openDisk: (name: string) => Pick<StorageAdapter, 'deleteFile'>,
140
+ ): Promise<number> {
141
+ const rows = await handle.unsafe(
142
+ `SELECT id, disk, path FROM card_attachments WHERE ${cardsWhere.sql}`,
143
+ cardsWhere.params,
144
+ ).execute() as Array<Pick<CardAttachmentRow, 'id' | 'disk' | 'path'>>
145
+
146
+ if (!rows?.length)
147
+ return 0
148
+
149
+ for (const row of rows) {
150
+ try {
151
+ await openDisk(row.disk).deleteFile(row.path)
152
+ }
153
+ catch (err) {
154
+ throw new Error(`could not delete attachment ${row.id} (${row.disk}:${row.path}), so nothing was deleted: ${err instanceof Error ? err.message : String(err)}`)
155
+ }
156
+ }
157
+
158
+ await handle.unsafe(`DELETE FROM card_attachments WHERE ${cardsWhere.sql}`, cardsWhere.params).execute()
159
+ return rows.length
160
+ }
161
+
162
+ /** The real disks, for `removeCardAttachments`. */
163
+ export async function storageDisks(): Promise<(name: string) => Pick<StorageAdapter, 'deleteFile'>> {
164
+ const { Storage } = await import('@stacksjs/storage')
165
+ return name => Storage.disk(name as never)
166
+ }
@@ -25,13 +25,20 @@ import { schema } from '@stacksjs/validation/runtime'
25
25
  * stranger's upload landing under `public/` would be world-readable at a
26
26
  * path the dashboard also prints.
27
27
  *
28
- * ## Erasure removes the row, not yet the bytes
28
+ * ## Removing a screenshot removes the file
29
29
  *
30
- * The `gdpr` trait deletes this row, and a screenshot is likely to contain
31
- * personal data - it is a picture of somebody's screen. Deleting the row
32
- * leaves the file behind, because nothing here sweeps a disk. Stated rather
33
- * than implied, and tracked separately: a half-done erasure that looks done
34
- * is worse than one that says what it did.
30
+ * A screenshot is likely to contain personal data - it is a picture of
31
+ * somebody's screen - so deleting one has to delete the bytes, not only this
32
+ * row. The card, column and board destroy actions do both, file first, through
33
+ * `removeCardAttachments` (stacksjs/stacks#2881). They cannot lean on the
34
+ * cascade declared below, which SQLite does not enforce, and a row deleted
35
+ * without its file leaves bytes on the disk that nothing can find again.
36
+ *
37
+ * `buddy gdpr:erase` does not reach these rows, and the `gdpr` trait here is
38
+ * the processing-register entry rather than an erasure path. The submitter of
39
+ * a feedback card is not an account: the card is filed with no user, so no
40
+ * data subject's erasure can match it. An external reviewer's request is met
41
+ * by deleting the card they filed, which removes the screenshot with it.
35
42
  *
36
43
  * No `useApi`. These rows are written by the feedback intake action and read
37
44
  * by the dashboard's own board actions; a REST surface over them would
@@ -2,7 +2,7 @@
2
2
  "publisher": "Stacks",
3
3
  "name": "vscode-stacks",
4
4
  "displayName": "Stacks",
5
- "version": "0.75.92",
5
+ "version": "0.75.93",
6
6
  "description": "Stacks, built into VS Code: stx templates, pickier linting and formatting, encrypted env files, the dev server preview and every buddy command. No other extensions required.",
7
7
  "license": "MIT",
8
8
  "funding": "https://github.com/sponsors/chrisbbreuer",
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/defaults",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.75.92",
5
+ "version": "0.75.93",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/stacksjs/stacks.git",
@@ -55,7 +55,7 @@
55
55
  "dependencies": {
56
56
  "@iconify-json/f7": "^1.2.2",
57
57
  "@iconify-json/hugeicons": "^1.2.35",
58
- "@stacksjs/mobile": "^0.75.92",
58
+ "@stacksjs/mobile": "^0.75.93",
59
59
  "@stacksjs/sanitizer": "^0.2.395",
60
60
  "@xterm/addon-fit": "^0.11.0",
61
61
  "@xterm/xterm": "^6.0.0",
@@ -10,7 +10,7 @@ const { tabBar = false } = defineProps<NativeAppShellProps>()
10
10
  </script>
11
11
 
12
12
  <script client>
13
- import { deepLinks as deepLinkService, device, followDeepLinks, isNativeMobile, whenNativeMobile } from '@stacksjs/mobile'
13
+ import { deepLinks as deepLinkService, device, followDeepLinks, isNativeMobile, splash, whenNativeMobile } from '@stacksjs/mobile'
14
14
 
15
15
  const className = useReactiveProp<string>('class', '')
16
16
  // A bare attribute arrives as an empty string.
@@ -23,6 +23,14 @@ onMount(() => {
23
23
  })
24
24
  onDestroy(() => stopDeepLinks?.())
25
25
 
26
+ // Craft holds its launch splash over the page until the page is ready. The
27
+ // first screen is: once it has painted (the second frame after mounting), the
28
+ // splash fades and the app goes from launch to content with nothing blank in
29
+ // between. Later screens call this too, which the shell ignores.
30
+ onMount(() => {
31
+ requestAnimationFrame(() => requestAnimationFrame(() => splash.hide()))
32
+ })
33
+
26
34
  // Asked twice on purpose. Craft installs its bridge after the document starts,
27
35
  // so on a phone the answer at setup is often `false`, and every rule hanging
28
36
  // off `.native-mobile` would never apply. whenNativeMobile() answers once the
@@ -124,6 +132,19 @@ const shellClass = derived(() => `${nativeMobile() ? 'native-mobile ' : ''}${cla
124
132
  padding-bottom: calc(5rem + var(--native-safe-area-bottom));
125
133
  }
126
134
 
135
+ /* In Craft's iOS shell the phone draws the tab bar natively, floating over
136
+ the page, and says how much of the screen it covers in
137
+ --craft-tab-bar-height. Content scrolls under it and clears it at the end. */
138
+ html[data-craft-chrome] .native-app-shell:has(.native-app-tab-slot) {
139
+ --native-tab-bar-height: var(--craft-tab-bar-height, 6rem);
140
+ }
141
+ html[data-craft-chrome] .native-app-shell:has(.native-app-tab-slot) .native-app-content {
142
+ padding-bottom: calc(var(--craft-tab-bar-height, 6rem) + 0.75rem);
143
+ }
144
+ html[data-craft-chrome] .native-app-shell:has([data-native-hide-tab-bar]) .native-app-content {
145
+ padding-bottom: var(--native-safe-area-bottom);
146
+ }
147
+
127
148
  .native-app-sticky-top {
128
149
  top: var(--native-safe-area-top);
129
150
  }
@@ -14,6 +14,23 @@ describe('Native components', () => {
14
14
  expect(shell).toContain('followDeepLinks(path => navigate(path), deepLinkService)')
15
15
  })
16
16
 
17
+ it('hands the tab bar to the phone inside Craft, and draws the glass capsule elsewhere', () => {
18
+ const bar = read('NativeTabBar')
19
+ // The links stay as the native bar's model; the router keeps them current.
20
+ expect(bar).toContain('stopMirror = mirrorTabBar(nav)')
21
+ expect(bar).toContain('html[data-craft-chrome] .native-tab-bar { display: none; }')
22
+ expect(bar).toContain('border-radius: 999px;')
23
+ const item = read('NativeTabItem')
24
+ expect(item).toContain('data-native-tab="{{ label }}"')
25
+ expect(item).toContain('data-native-tab-symbol="{{ symbol }}"')
26
+ const shell = read('NativeAppShell')
27
+ expect(shell).toContain('padding-bottom: calc(var(--craft-tab-bar-height, 6rem) + 0.75rem);')
28
+ })
29
+
30
+ it('lifts the launch splash once the first screen has painted', () => {
31
+ expect(read('NativeAppShell')).toContain('requestAnimationFrame(() => requestAnimationFrame(() => splash.hide()))')
32
+ })
33
+
17
34
  it('keeps a tab lit on the paths it names, and never drags a tab', () => {
18
35
  const item = read('NativeTabItem')
19
36
  expect(item).toContain('activeMatch="{{ match }}"')
@@ -7,17 +7,32 @@ const { label = 'App navigation' } = defineProps<NativeTabBarProps>()
7
7
  </script>
8
8
 
9
9
  <script client>
10
- import { haptics } from '@stacksjs/mobile'
10
+ import { haptics, mirrorTabBar } from '@stacksjs/mobile'
11
+
12
+ // Inside Craft's iOS shell the phone draws this bar natively (Liquid Glass on
13
+ // iOS 26 and later) and these links stay as its model: the router keeps the
14
+ // current one marked, and a native tap comes back as a click on one of them.
15
+ const bar = useRef('nativeTabBar')
16
+ let mirrored = false
17
+ let stopMirror: (() => void) | null = null
18
+ onMount(() => {
19
+ const nav = bar.current as HTMLElement | null
20
+ if (!nav) return
21
+ stopMirror = mirrorTabBar(nav)
22
+ mirrored = stopMirror !== null
23
+ })
24
+ onDestroy(() => stopMirror?.())
11
25
 
12
26
  function selectTab(): void {
13
- void haptics.selection()
27
+ // The native bar has already played its own.
28
+ if (!mirrored) void haptics.selection()
14
29
  }
15
30
  </script>
16
31
 
17
32
  <!-- `data-stx-sticky-active`: each tab owns the screens opened from it, as on
18
33
  iOS, so a workout opened from Today keeps Today lit; a tab's `match`
19
34
  decides only for a screen reached with no tab current yet (a deep link). -->
20
- <nav aria-label="{{ label }}" class="native-tab-bar" data-stx-sticky-active @click="selectTab()">
35
+ <nav ref="nativeTabBar" aria-label="{{ label }}" class="native-tab-bar" data-stx-sticky-active @click="selectTab()">
21
36
  <slot />
22
37
  </nav>
23
38
 
@@ -28,18 +43,33 @@ function selectTab(): void {
28
43
  box steps aside, so the link is the grid item and centres in the bar. */
29
44
  .native-tab-bar > [data-stx-scope]:has(> .native-tab-item) { display: contents; }
30
45
 
46
+ /* The floating glass capsule iOS 26 draws, for a browser: inset from the
47
+ screen's edges, above the home indicator, the content showing through. */
31
48
  .native-tab-bar {
32
49
  display: grid;
33
50
  grid-auto-flow: column;
34
51
  grid-auto-columns: 1fr;
35
- min-height: calc(4.5rem + env(safe-area-inset-bottom, 0px));
36
- padding: 0 env(safe-area-inset-right, 0px) env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px);
37
- border-top: 1px solid rgb(148 163 184 / 0.22);
38
- background: rgb(255 255 255 / 0.9);
39
- backdrop-filter: blur(24px) saturate(160%);
40
- -webkit-backdrop-filter: blur(24px) saturate(160%);
52
+ gap: 0.125rem;
53
+ max-width: 32rem;
54
+ min-height: 3.875rem;
55
+ margin: 0 max(1rem, env(safe-area-inset-right, 0px)) calc(0.375rem + env(safe-area-inset-bottom, 0px)) max(1rem, env(safe-area-inset-left, 0px));
56
+ padding: 0.25rem;
57
+ border: 1px solid rgb(255 255 255 / 0.55);
58
+ border-radius: 999px;
59
+ background: rgb(255 255 255 / 0.68);
60
+ box-shadow: 0 12px 32px -14px rgb(15 23 42 / 0.4), inset 0 1px 0 rgb(255 255 255 / 0.7);
61
+ backdrop-filter: blur(22px) saturate(180%);
62
+ -webkit-backdrop-filter: blur(22px) saturate(180%);
41
63
  }
42
64
 
65
+ @media (min-width: 34rem) {
66
+ .native-tab-bar { margin-right: auto; margin-left: auto; }
67
+ }
68
+
69
+ /* In Craft's iOS shell the phone draws the bar; this one stays in the
70
+ document, hidden, as the native one's model. */
71
+ html[data-craft-chrome] .native-tab-bar { display: none; }
72
+
43
73
  .native-tab-item {
44
74
  display: flex;
45
75
  flex-direction: column;
@@ -47,12 +77,13 @@ function selectTab(): void {
47
77
  align-items: center;
48
78
  justify-content: center;
49
79
  min-width: 2.75rem;
50
- color: rgb(100 116 139);
80
+ border-radius: 999px;
81
+ color: rgb(15 23 42);
51
82
  font-size: 0.6875rem;
52
- font-weight: 600;
83
+ font-weight: 500;
53
84
  line-height: 1;
54
85
  text-decoration: none;
55
- transition: color 150ms ease, transform 150ms ease;
86
+ transition: color 150ms ease, background-color 200ms ease, transform 150ms ease;
56
87
  -webkit-tap-highlight-color: transparent;
57
88
  /* No link preview or callout on a long press: a tab is not a link to share. */
58
89
  -webkit-touch-callout: none;
@@ -75,16 +106,24 @@ function selectTab(): void {
75
106
  }
76
107
 
77
108
  .native-tab-item:active { transform: scale(0.96); }
78
- .native-tab-item.is-active { color: var(--native-tab-active, rgb(5 150 105)); }
109
+ .native-tab-item.is-active {
110
+ color: var(--native-tab-active, rgb(15 23 42));
111
+ background: rgb(15 23 42 / 0.07);
112
+ font-weight: 600;
113
+ }
79
114
  .native-tab-icon { width: 1.35rem; height: 1.35rem; }
80
115
 
81
116
  .dark .native-tab-bar {
82
- border-color: rgb(71 85 105 / 0.55);
83
- background: rgb(15 23 42 / 0.9);
117
+ border-color: rgb(255 255 255 / 0.12);
118
+ background: rgb(30 41 59 / 0.62);
119
+ box-shadow: 0 12px 32px -14px rgb(0 0 0 / 0.7), inset 0 1px 0 rgb(255 255 255 / 0.08);
84
120
  }
85
121
 
86
- .dark .native-tab-item { color: rgb(148 163 184); }
87
- .dark .native-tab-item.is-active { color: var(--native-tab-active-dark, rgb(52 211 153)); }
122
+ .dark .native-tab-item { color: rgb(226 232 240); }
123
+ .dark .native-tab-item.is-active {
124
+ color: var(--native-tab-active-dark, rgb(255 255 255));
125
+ background: rgb(255 255 255 / 0.1);
126
+ }
88
127
 
89
128
  @media (prefers-reduced-transparency: reduce) {
90
129
  .native-tab-bar {
@@ -3,6 +3,12 @@ interface NativeTabItemProps {
3
3
  label: string
4
4
  href: string
5
5
  icon: string
6
+ /**
7
+ * The SF Symbol the phone's native tab bar draws for this tab, such as
8
+ * `calendar`; the current tab gets its `.fill` variant when there is one.
9
+ * `icon` is the web bar's.
10
+ */
11
+ symbol?: string
6
12
  /** A count on the icon. Zero and empty draw nothing. */
7
13
  badge?: string | number
8
14
  /**
@@ -14,7 +20,7 @@ interface NativeTabItemProps {
14
20
  match?: string
15
21
  }
16
22
 
17
- const { href, icon, label, badge = '', match = '' } = defineProps<NativeTabItemProps>()
23
+ const { href, icon, label, symbol = 'circle', badge = '', match = '' } = defineProps<NativeTabItemProps>()
18
24
  const hasBadge = badge !== '' && badge !== 0 && badge !== '0'
19
25
  </script>
20
26
 
@@ -24,7 +30,9 @@ const hasBadge = badge !== '' && badge !== 0 && badge !== '0'
24
30
  the current one is idle, so switching tabs is served from the router's
25
31
  cache, as instant as a native tab bar. `data-stx-transition="none"`: and
26
32
  swapped without the router's cross-fade, which iOS never draws between
27
- tabs (it read as a quarter-second lag on every tap). -->
33
+ tabs (it read as a quarter-second lag on every tap). `data-native-tab`
34
+ and `data-native-tab-symbol` describe the tab to the phone's native bar
35
+ (@stacksjs/mobile mirrorTabBar). -->
28
36
  <StxLink
29
37
  to="{{ href }}"
30
38
  aria-label="{{ label }}"
@@ -34,6 +42,8 @@ const hasBadge = badge !== '' && badge !== 0 && badge !== '0'
34
42
  draggable="false"
35
43
  data-stx-prefetch="eager"
36
44
  data-stx-transition="none"
45
+ data-native-tab="{{ label }}"
46
+ data-native-tab-symbol="{{ symbol }}"
37
47
  >
38
48
  <span class="native-tab-glyph">
39
49
  <i aria-hidden="true" class="{{ icon }} native-tab-icon"></i>
@@ -83,9 +83,9 @@ jobs:
83
83
  persist-credentials: true
84
84
 
85
85
  - name: Setup Pantry
86
- uses: pantry-pm/pantry/packages/action@d899150a11cd12157ea360e21f6800a85f900446 # v0.11.71
86
+ uses: pantry-pm/pantry/packages/action@cdb82193cb344c2b881a88ea79f88243f5522e0f # v0.11.75
87
87
  with:
88
- version: '0.11.71'
88
+ version: '0.11.75'
89
89
  install: 'false'
90
90
 
91
91
  - name: Install Dependencies
@@ -30,7 +30,7 @@ jobs:
30
30
  uses: actions/checkout@v4
31
31
 
32
32
  - name: Setup Pantry
33
- uses: pantry-pm/pantry/packages/action@d899150a11cd12157ea360e21f6800a85f900446 # v0.11.71
33
+ uses: pantry-pm/pantry/packages/action@cdb82193cb344c2b881a88ea79f88243f5522e0f # v0.11.75
34
34
  with:
35
35
  install: 'false'
36
36
 
@@ -63,7 +63,7 @@ jobs:
63
63
  uses: actions/checkout@v4
64
64
 
65
65
  - name: Setup Pantry
66
- uses: pantry-pm/pantry/packages/action@d899150a11cd12157ea360e21f6800a85f900446 # v0.11.71
66
+ uses: pantry-pm/pantry/packages/action@cdb82193cb344c2b881a88ea79f88243f5522e0f # v0.11.75
67
67
  with:
68
68
  install: 'false'
69
69
 
@@ -99,7 +99,7 @@ jobs:
99
99
  uses: actions/checkout@v4
100
100
 
101
101
  - name: Setup Pantry
102
- uses: pantry-pm/pantry/packages/action@d899150a11cd12157ea360e21f6800a85f900446 # v0.11.71
102
+ uses: pantry-pm/pantry/packages/action@cdb82193cb344c2b881a88ea79f88243f5522e0f # v0.11.75
103
103
  with:
104
104
  install: 'false'
105
105
 
@@ -21,7 +21,7 @@ jobs:
21
21
  fetch-depth: 0
22
22
 
23
23
  - name: Setup Pantry
24
- uses: pantry-pm/pantry/packages/action@d899150a11cd12157ea360e21f6800a85f900446 # v0.11.71
24
+ uses: pantry-pm/pantry/packages/action@cdb82193cb344c2b881a88ea79f88243f5522e0f # v0.11.75
25
25
  with:
26
26
  install: 'false'
27
27