@stacksjs/defaults 0.75.91 → 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.
- package/app/Actions/Dashboard/Kanban/BoardDestroyAction.ts +14 -2
- package/app/Actions/Dashboard/Kanban/CardDestroyAction.ts +9 -3
- package/app/Actions/Dashboard/Kanban/ColumnDestroyAction.ts +10 -1
- package/app/Actions/Dashboard/Kanban/card-attachments.test.ts +117 -3
- package/app/Actions/Dashboard/Kanban/card-attachments.ts +59 -0
- package/app/Models/CardAttachment.ts +13 -6
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/resources/components/NativeAppShell.stx +22 -1
- package/resources/components/NativeComponents.test.ts +17 -0
- package/resources/components/NativeTabBar.stx +56 -17
- package/resources/components/NativeTabItem.stx +12 -2
- package/vcs/github/workflows/buddy-bot.yml +2 -2
- package/vcs/github/workflows/ci.yml +3 -3
- package/vcs/github/workflows/release.yml +1 -1
|
@@ -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,
|
|
11
|
-
* `card_assignees
|
|
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
|
|
11
|
-
*
|
|
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
|
|
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 {
|
|
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 {
|
|
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
|
-
* ##
|
|
28
|
+
* ## Removing a screenshot removes the file
|
|
29
29
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
package/ide/vscode/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"publisher": "Stacks",
|
|
3
3
|
"name": "vscode-stacks",
|
|
4
4
|
"displayName": "Stacks",
|
|
5
|
-
"version": "0.75.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
80
|
+
border-radius: 999px;
|
|
81
|
+
color: rgb(15 23 42);
|
|
51
82
|
font-size: 0.6875rem;
|
|
52
|
-
font-weight:
|
|
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 {
|
|
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(
|
|
83
|
-
background: rgb(
|
|
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(
|
|
87
|
-
.dark .native-tab-item.is-active {
|
|
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@
|
|
86
|
+
uses: pantry-pm/pantry/packages/action@cdb82193cb344c2b881a88ea79f88243f5522e0f # v0.11.75
|
|
87
87
|
with:
|
|
88
|
-
version: '0.11.
|
|
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@
|
|
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@
|
|
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@
|
|
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@
|
|
24
|
+
uses: pantry-pm/pantry/packages/action@cdb82193cb344c2b881a88ea79f88243f5522e0f # v0.11.75
|
|
25
25
|
with:
|
|
26
26
|
install: 'false'
|
|
27
27
|
|