@stacksjs/defaults 0.74.39 → 0.74.41
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,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: stacks-composables
|
|
3
|
-
description: Use when creating or using reactive composables in STX templates -
|
|
3
|
+
description: Use when creating or using reactive composables in STX templates - 154 composables for state management, DOM interaction, sensors, animation, browser APIs, async operations, or the complete list of auto-imported composables. Covers @stacksjs/composables.
|
|
4
4
|
license: MIT
|
|
5
5
|
compatibility: Bun >= 1.3.0, TypeScript
|
|
6
6
|
allowed-tools: Read Edit Write Bash Grep Glob
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: stacks-dashboard
|
|
3
|
-
description: Use when building or customizing the Stacks admin dashboard, including dashboard pages, model management views, analytics widgets, commerce dashboards, content management, settings panels, deployment monitoring, job/queue management, or the
|
|
3
|
+
description: Use when building or customizing the Stacks admin dashboard, including dashboard pages, model management views, analytics widgets, commerce dashboards, content management, settings panels, deployment monitoring, job/queue management, or the 400 built-in dashboard components. Covers the dashboard system at storage/framework/defaults/.
|
|
4
4
|
license: MIT
|
|
5
5
|
compatibility: Bun >= 1.3.0, TypeScript
|
|
6
6
|
allowed-tools: Read Edit Write Bash Grep Glob
|
package/ide/vscode/package.json
CHANGED
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/defaults",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.74.
|
|
5
|
+
"version": "0.74.41",
|
|
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.27",
|
|
58
|
-
"@stacksjs/mobile": "^0.74.
|
|
58
|
+
"@stacksjs/mobile": "^0.74.41",
|
|
59
59
|
"@stacksjs/sanitizer": "^0.2.113",
|
|
60
60
|
"ts-qr-codes": "^0.1.8"
|
|
61
61
|
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
<script client>
|
|
2
|
+
/**
|
|
3
|
+
* Cookie consent banner (stacksjs/stacks#365).
|
|
4
|
+
*
|
|
5
|
+
* The decision logic is `useCookieConsent` in `@stacksjs/composables`, which
|
|
6
|
+
* is where the rule that matters lives: every optional category is declined
|
|
7
|
+
* until the visitor says otherwise, and there is no way to ask for a
|
|
8
|
+
* permissive default.
|
|
9
|
+
*
|
|
10
|
+
* This is the surface. Three things about it are compliance decisions rather
|
|
11
|
+
* than design ones, and each is easy to get wrong:
|
|
12
|
+
*
|
|
13
|
+
* - **"Decline" is as prominent as "Accept".** A banner where refusing is a
|
|
14
|
+
* greyed-out link and accepting is the primary button is the pattern
|
|
15
|
+
* regulators call out, and it is what most implementations ship.
|
|
16
|
+
* - **Nothing is preselected.** The per-category toggles start off, because
|
|
17
|
+
* the stored decision starts empty.
|
|
18
|
+
* - **The banner does not block the page.** It is a dialog in the
|
|
19
|
+
* accessibility sense but not a modal that traps the visitor until they
|
|
20
|
+
* choose - consent obtained by making the site unusable is not freely given.
|
|
21
|
+
*
|
|
22
|
+
* `onChange` is where an app loads what a category gates, and where it records
|
|
23
|
+
* a `ConsentEvent` if it wants the audit trail.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { ConsentCategory } from '@stacksjs/composables'
|
|
27
|
+
import { useCookieConsent } from '@stacksjs/composables'
|
|
28
|
+
|
|
29
|
+
interface CookieConsentProps {
|
|
30
|
+
/**
|
|
31
|
+
* Bump this when the cookie policy changes. Every stored decision under a
|
|
32
|
+
* previous version is discarded and the banner asks again, because consent
|
|
33
|
+
* to a previous policy is not consent to this one.
|
|
34
|
+
* @default '1.0'
|
|
35
|
+
*/
|
|
36
|
+
policyVersion?: string
|
|
37
|
+
/** Where "read our policy" points. */
|
|
38
|
+
policyUrl?: string
|
|
39
|
+
/** Heading text. */
|
|
40
|
+
title?: string
|
|
41
|
+
/** Body text. Keep it about what is collected and why. */
|
|
42
|
+
message?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const {
|
|
46
|
+
policyVersion = '1.0',
|
|
47
|
+
policyUrl = '/privacy',
|
|
48
|
+
title = 'Cookies',
|
|
49
|
+
message = 'We use necessary cookies to make this site work. With your permission we would also like to use optional ones to understand how it is used and to improve it.',
|
|
50
|
+
} = defineProps<CookieConsentProps>()
|
|
51
|
+
|
|
52
|
+
const { needsDecision, allows, acceptAll, declineAll, accept } = useCookieConsent({ policyVersion })
|
|
53
|
+
|
|
54
|
+
// Per-category toggles for the "choose" panel, starting from what is actually
|
|
55
|
+
// consented - which on a first visit is nothing.
|
|
56
|
+
const showChoices = state(false)
|
|
57
|
+
const preferences = state(allows('preferences'))
|
|
58
|
+
const analytics = state(allows('analytics'))
|
|
59
|
+
const marketing = state(allows('marketing'))
|
|
60
|
+
|
|
61
|
+
function saveChoices() {
|
|
62
|
+
const chosen: ConsentCategory[] = []
|
|
63
|
+
if (preferences()) chosen.push('preferences')
|
|
64
|
+
if (analytics()) chosen.push('analytics')
|
|
65
|
+
if (marketing()) chosen.push('marketing')
|
|
66
|
+
accept(chosen)
|
|
67
|
+
}
|
|
68
|
+
</script>
|
|
69
|
+
|
|
70
|
+
<template>
|
|
71
|
+
@if(needsDecision)
|
|
72
|
+
<div
|
|
73
|
+
role="region"
|
|
74
|
+
aria-label="Cookie consent"
|
|
75
|
+
class="fixed bottom-0 inset-x-0 z-50 p-4 sm:p-6"
|
|
76
|
+
>
|
|
77
|
+
<div class="mx-auto max-w-3xl rounded-lg border border-gray-200 bg-white p-4 shadow-lg dark:border-neutral-700 dark:bg-neutral-900 sm:p-6">
|
|
78
|
+
<h2 class="text-base font-semibold text-gray-900 dark:text-white">{{ title }}</h2>
|
|
79
|
+
|
|
80
|
+
<p class="mt-2 text-sm text-gray-600 dark:text-neutral-300">
|
|
81
|
+
{{ message }}
|
|
82
|
+
<a :href="policyUrl" class="underline hover:no-underline">Read our policy</a>.
|
|
83
|
+
</p>
|
|
84
|
+
|
|
85
|
+
@if(showChoices())
|
|
86
|
+
<fieldset class="mt-4 space-y-3">
|
|
87
|
+
<legend class="sr-only">Cookie categories</legend>
|
|
88
|
+
|
|
89
|
+
<label class="flex items-start gap-3 text-sm">
|
|
90
|
+
<input type="checkbox" checked disabled class="mt-0.5" />
|
|
91
|
+
<span>
|
|
92
|
+
<span class="font-medium text-gray-900 dark:text-white">Necessary</span>
|
|
93
|
+
<span class="block text-gray-600 dark:text-neutral-400">Required for the site to work. Cannot be turned off.</span>
|
|
94
|
+
</span>
|
|
95
|
+
</label>
|
|
96
|
+
|
|
97
|
+
<label class="flex items-start gap-3 text-sm">
|
|
98
|
+
<input type="checkbox" :checked="preferences()" @change="preferences.set(!preferences())" class="mt-0.5" />
|
|
99
|
+
<span>
|
|
100
|
+
<span class="font-medium text-gray-900 dark:text-white">Preferences</span>
|
|
101
|
+
<span class="block text-gray-600 dark:text-neutral-400">Remembers choices such as language and theme.</span>
|
|
102
|
+
</span>
|
|
103
|
+
</label>
|
|
104
|
+
|
|
105
|
+
<label class="flex items-start gap-3 text-sm">
|
|
106
|
+
<input type="checkbox" :checked="analytics()" @change="analytics.set(!analytics())" class="mt-0.5" />
|
|
107
|
+
<span>
|
|
108
|
+
<span class="font-medium text-gray-900 dark:text-white">Analytics</span>
|
|
109
|
+
<span class="block text-gray-600 dark:text-neutral-400">Helps us understand how the site is used.</span>
|
|
110
|
+
</span>
|
|
111
|
+
</label>
|
|
112
|
+
|
|
113
|
+
<label class="flex items-start gap-3 text-sm">
|
|
114
|
+
<input type="checkbox" :checked="marketing()" @change="marketing.set(!marketing())" class="mt-0.5" />
|
|
115
|
+
<span>
|
|
116
|
+
<span class="font-medium text-gray-900 dark:text-white">Marketing</span>
|
|
117
|
+
<span class="block text-gray-600 dark:text-neutral-400">Used to measure and target campaigns.</span>
|
|
118
|
+
</span>
|
|
119
|
+
</label>
|
|
120
|
+
</fieldset>
|
|
121
|
+
@endif
|
|
122
|
+
|
|
123
|
+
{{-- Decline is a peer of Accept, not a lesser link. --}}
|
|
124
|
+
<div class="mt-4 flex flex-col gap-2 sm:flex-row sm:justify-end">
|
|
125
|
+
@if(showChoices())
|
|
126
|
+
<button type="button" @click="saveChoices()" class="rounded-md bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700 dark:bg-white dark:text-gray-900 dark:hover:bg-neutral-200">
|
|
127
|
+
Save choices
|
|
128
|
+
</button>
|
|
129
|
+
@else
|
|
130
|
+
<button type="button" @click="showChoices.set(true)" class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-900 hover:bg-gray-50 dark:border-neutral-600 dark:text-white dark:hover:bg-neutral-800">
|
|
131
|
+
Choose
|
|
132
|
+
</button>
|
|
133
|
+
@endif
|
|
134
|
+
|
|
135
|
+
<button type="button" @click="declineAll()" class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-900 hover:bg-gray-50 dark:border-neutral-600 dark:text-white dark:hover:bg-neutral-800">
|
|
136
|
+
Decline
|
|
137
|
+
</button>
|
|
138
|
+
|
|
139
|
+
<button type="button" @click="acceptAll()" class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-900 hover:bg-gray-50 dark:border-neutral-600 dark:text-white dark:hover:bg-neutral-800">
|
|
140
|
+
Accept all
|
|
141
|
+
</button>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
@endif
|
|
146
|
+
</template>
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The banner's compliance contract (stacksjs/stacks#365).
|
|
7
|
+
*
|
|
8
|
+
* These are the properties a reviewer would check, and each is the kind of
|
|
9
|
+
* thing a later styling pass removes without noticing: the decline button
|
|
10
|
+
* becomes a text link, a checkbox picks up `checked`, the banner gains a
|
|
11
|
+
* backdrop. Asserted against the source because they are facts about the
|
|
12
|
+
* markup, not about the render.
|
|
13
|
+
*
|
|
14
|
+
* The decision logic itself is tested in
|
|
15
|
+
* `core/composables/tests/use-cookie-consent.test.ts`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const source = readFileSync(join(import.meta.dir, 'CookieConsent.stx'), 'utf8')
|
|
19
|
+
|
|
20
|
+
describe('CookieConsent', () => {
|
|
21
|
+
it('gives Decline the same treatment as Accept', () => {
|
|
22
|
+
// A banner where refusing is a greyed-out link and accepting is the
|
|
23
|
+
// primary button is the pattern regulators call out.
|
|
24
|
+
const classesOf = (label: string): string => {
|
|
25
|
+
const match = source.match(new RegExp(`<button[^>]*class="([^"]*)"[^>]*>\\s*${label}`, 's'))
|
|
26
|
+
?? source.match(new RegExp(`<button[^>]*class="([^"]*)"[^>]*>\\s*\\n\\s*${label}`, 's'))
|
|
27
|
+
return match?.[1] ?? ''
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const decline = classesOf('Decline')
|
|
31
|
+
const accept = classesOf('Accept all')
|
|
32
|
+
|
|
33
|
+
expect(decline).not.toBe('')
|
|
34
|
+
expect(decline).toBe(accept)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('preselects no optional category', () => {
|
|
38
|
+
// Every optional checkbox binds to state that starts from what is
|
|
39
|
+
// consented, which on a first visit is nothing. A literal `checked` on one
|
|
40
|
+
// of them would silently opt the visitor in.
|
|
41
|
+
const optional = [...source.matchAll(/<input type="checkbox"([^>]*)\/>/g)]
|
|
42
|
+
.map(match => match[1]!)
|
|
43
|
+
.filter(attrs => !attrs.includes('disabled'))
|
|
44
|
+
|
|
45
|
+
expect(optional.length).toBe(3)
|
|
46
|
+
for (const attrs of optional) {
|
|
47
|
+
expect(attrs).toContain(':checked=')
|
|
48
|
+
expect(attrs).not.toMatch(/\schecked[\s/]/)
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('marks the necessary category as checked AND disabled', () => {
|
|
53
|
+
// It cannot be declined, so showing it as a live choice would be a lie.
|
|
54
|
+
expect(source).toMatch(/<input type="checkbox" checked disabled/)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('does not trap the visitor in a modal', () => {
|
|
58
|
+
// Consent obtained by making the site unusable is not freely given, so
|
|
59
|
+
// this is a region rather than a modal dialog with a backdrop.
|
|
60
|
+
expect(source).toContain('role="region"')
|
|
61
|
+
expect(source).not.toContain('role="dialog"')
|
|
62
|
+
expect(source).not.toContain('aria-modal')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('labels the region and the category group for screen readers', () => {
|
|
66
|
+
expect(source).toContain('aria-label="Cookie consent"')
|
|
67
|
+
expect(source).toContain('<legend class="sr-only">')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('links to the policy', () => {
|
|
71
|
+
expect(source).toContain(':href="policyUrl"')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('renders nothing once a decision exists', () => {
|
|
75
|
+
// The whole banner is behind `needsDecision`; a visitor who has decided
|
|
76
|
+
// must not see it again on every page.
|
|
77
|
+
expect(source).toMatch(/@if\(needsDecision\)/)
|
|
78
|
+
})
|
|
79
|
+
})
|