@atom.io/spa-devtools 0.0.0-stage → 0.0.1

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.
@@ -0,0 +1,289 @@
1
+ main[data-css="spa-devtools"] {
2
+ --fg-color: #ccc;
3
+ --fg-light: #aaa;
4
+ --fg-soft: #888;
5
+ --fg-hint: #4a4a4a;
6
+ --fg-max: #fff;
7
+ --bg-color: #111;
8
+ --bg-accent: #00f;
9
+ --bg-max: #000;
10
+ --bg-tint1: #222;
11
+ --bg-tint2: #333;
12
+ color-scheme: dark;
13
+ @media (prefers-color-scheme: light) {
14
+ --fg-color: #444;
15
+ --fg-light: #777;
16
+ --fg-soft: #888;
17
+ --fg-hint: #ccc;
18
+ --fg-max: #000;
19
+ --bg-color: #ddd;
20
+ --bg-accent: #0ff;
21
+ --bg-max: #fff;
22
+ --bg-tint1: #e3e3e3;
23
+ --bg-tint2: #f3f3f3;
24
+ color-scheme: light;
25
+ }
26
+ position: fixed;
27
+ z-index: 10000;
28
+ right: 0;
29
+ bottom: 0;
30
+ width: min(500px, 100vw);
31
+ height: min(800px, 100dvh);
32
+ display: flex;
33
+ flex-direction: column;
34
+ overflow: hidden;
35
+ border: 2px solid var(--fg-color);
36
+ background: var(--bg-color);
37
+ color: var(--fg-color);
38
+ font: 14px/1 Theia, monospace;
39
+ text-align: left;
40
+ &, * {
41
+ box-sizing: border-box;
42
+ }
43
+ * {
44
+ color: inherit;
45
+ font: inherit;
46
+ margin: 0;
47
+ padding: 0;
48
+ }
49
+ &[hidden] {
50
+ display: none;
51
+ }
52
+ &[data-open="false"] {
53
+ width: 33px;
54
+ height: 28px;
55
+ border-color: transparent;
56
+ background: transparent;
57
+ .toolbar > :not([data-testid="open-close-devtools"]) {
58
+ display: none;
59
+ }
60
+ }
61
+ button, input, select {
62
+ border-radius: 0;
63
+ background: none;
64
+ min-width: 0;
65
+ }
66
+ button {
67
+ height: 21px;
68
+ min-width: 21px;
69
+ border: 0;
70
+ padding: 0 3px;
71
+ cursor: pointer;
72
+ }
73
+ button:hover {
74
+ background: var(--fg-hint);
75
+ }
76
+ button:disabled {
77
+ cursor: default;
78
+ color: var(--fg-soft);
79
+ }
80
+ button:focus-visible, input:focus-visible, select:focus-visible {
81
+ outline: 2px solid var(--fg-max);
82
+ outline-offset: -2px;
83
+ background: var(--bg-max);
84
+ color: var(--fg-max);
85
+ }
86
+ input {
87
+ height: 18px;
88
+ padding: 0 2px;
89
+ border: 1px solid var(--fg-hint);
90
+ }
91
+ input[readonly] {
92
+ border-color: transparent;
93
+ }
94
+ input::selection {
95
+ background: var(--bg-accent);
96
+ }
97
+ input[type="checkbox"] {
98
+ width: 13px;
99
+ height: 13px;
100
+ margin: 0 3px;
101
+ }
102
+ select {
103
+ height: 20px;
104
+ border: 0;
105
+ color: var(--fg-soft);
106
+ flex: 0 0 auto;
107
+ max-width: 76px;
108
+ }
109
+ header, nav, footer {
110
+ display: flex;
111
+ align-items: center;
112
+ gap: 0;
113
+ }
114
+ .toolbar {
115
+ flex: 0 0 28px;
116
+ min-width: 0;
117
+ }
118
+ .toolbar h1 {
119
+ font: 24px/1 Charter, serif;
120
+ margin: 0 4px;
121
+ white-space: nowrap;
122
+ }
123
+ .toolbar nav {
124
+ margin-left: auto;
125
+ align-self: end;
126
+ overflow-x: auto;
127
+ }
128
+ .toolbar nav button {
129
+ height: 24px;
130
+ padding: 0 5px;
131
+ }
132
+ .toolbar nav button[aria-pressed="true"] {
133
+ background: var(--fg-color);
134
+ color: var(--bg-color);
135
+ }
136
+ .drag-handle {
137
+ touch-action: none;
138
+ cursor: move;
139
+ min-width: 16px;
140
+ color: var(--fg-soft);
141
+ }
142
+ .index {
143
+ flex: 1;
144
+ min-height: 0;
145
+ overflow: auto;
146
+ border-top: 1px solid var(--fg-color);
147
+ background: var(--bg-tint1);
148
+ padding-bottom: 24px;
149
+ }
150
+ .index > p {
151
+ padding: 5px;
152
+ font-style: italic;
153
+ color: var(--fg-soft);
154
+ }
155
+ .node {
156
+ border-bottom: 1px solid var(--fg-soft);
157
+ }
158
+ .node > header, .event > header {
159
+ height: 22px;
160
+ background: var(--bg-tint2);
161
+ white-space: nowrap;
162
+ overflow: hidden;
163
+ }
164
+ .node > header h2 {
165
+ padding: 0 3px;
166
+ overflow: hidden;
167
+ text-overflow: ellipsis;
168
+ }
169
+ .node > header small {
170
+ margin-right: auto;
171
+ }
172
+ small {
173
+ color: var(--fg-soft);
174
+ }
175
+ .node > .state-leaf ~ .state-leaf {
176
+ border-top: 1px solid var(--fg-hint);
177
+ }
178
+ .node:has(> header) > .state-leaf {
179
+ margin-left: 9px;
180
+ border-left: 1px solid var(--fg-soft);
181
+ }
182
+ .json-editor {
183
+ min-width: 0;
184
+ }
185
+ .json-editor > header {
186
+ height: 21px;
187
+ min-width: 0;
188
+ background: var(--bg-tint2);
189
+ white-space: nowrap;
190
+ }
191
+ .state-leaf > .json-editor > header {
192
+ height: 22px;
193
+ }
194
+ .carat-spacer {
195
+ width: 21px;
196
+ flex-shrink: 0;
197
+ }
198
+ .field-name {
199
+ padding-right: 4px;
200
+ flex: 0 1 auto;
201
+ min-width: 0;
202
+ overflow: hidden;
203
+ text-overflow: ellipsis;
204
+ }
205
+ .field-name h3 {
206
+ font-weight: inherit;
207
+ overflow: hidden;
208
+ text-overflow: ellipsis;
209
+ }
210
+ .field-name:empty {
211
+ display: none;
212
+ }
213
+ .field-name input {
214
+ max-width: 100%;
215
+ }
216
+ .json-editor > header > code {
217
+ flex: 1;
218
+ min-width: 0;
219
+ overflow: hidden;
220
+ text-overflow: ellipsis;
221
+ color: var(--fg-light);
222
+ padding: 0 3px;
223
+ }
224
+ .primitive {
225
+ flex: 1;
226
+ min-width: 0;
227
+ padding: 0 2px;
228
+ overflow: hidden;
229
+ }
230
+ .primitive input[type="text"] {
231
+ width: 100%;
232
+ }
233
+ .primitive code {
234
+ white-space: nowrap;
235
+ }
236
+ .properties, .impact {
237
+ margin-left: 9px;
238
+ border-left: 1px solid var(--fg-soft);
239
+ }
240
+ .properties > .property {
241
+ border-top: 1px solid var(--fg-hint);
242
+ }
243
+ .property .field-name::after {
244
+ content: ":";
245
+ color: var(--fg-soft);
246
+ padding-right: 3px;
247
+ }
248
+ .properties > footer {
249
+ height: 21px;
250
+ border-top: 1px solid var(--fg-hint);
251
+ }
252
+ .properties > footer button {
253
+ color: var(--fg-soft);
254
+ }
255
+ .event {
256
+ border-top: 1px solid var(--fg-hint);
257
+ }
258
+ .event > header strong {
259
+ padding-right: 5px;
260
+ overflow: hidden;
261
+ text-overflow: ellipsis;
262
+ }
263
+ .event p, .event > code {
264
+ display: block;
265
+ padding: 3px 6px;
266
+ line-height: 18px;
267
+ overflow-wrap: anywhere;
268
+ }
269
+ ins {
270
+ color: var(--fg-color);
271
+ text-decoration: none;
272
+ background: #0a02;
273
+ }
274
+ del {
275
+ color: var(--fg-soft);
276
+ background: #a002;
277
+ }
278
+ .you-are-here {
279
+ background: var(--fg-color);
280
+ color: var(--bg-color);
281
+ text-align: center;
282
+ line-height: 21px;
283
+ }
284
+ [role="alert"] {
285
+ padding: 5px;
286
+ line-height: 18px;
287
+ border-top: 1px solid var(--fg-soft);
288
+ }
289
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export type { AtomIODevtoolsProps } from "./AtomIODevtools.tsx"
2
+ export { AtomIODevtools } from "./AtomIODevtools.tsx"
package/src/store.ts ADDED
@@ -0,0 +1,148 @@
1
+ import type { Accessor } from "@atom.io/spa"
2
+ import { onCleanup, useId, useSetter, useValue } from "@atom.io/spa"
3
+ import type { RegularAtomFamilyToken, RegularAtomToken, Setter } from "atom.io"
4
+ import type { RootStore } from "atom.io/internal"
5
+ import {
6
+ createRegularAtomFamily,
7
+ disposeFromStore,
8
+ findInStore,
9
+ setIntoStore,
10
+ } from "atom.io/internal"
11
+ import type { IntrospectionStates } from "atom.io/introspection"
12
+ import { attachIntrospectionStates } from "atom.io/introspection"
13
+
14
+ const locals = new WeakMap<RootStore, RegularAtomFamilyToken<unknown, string>>()
15
+ const introspection = new WeakMap<RootStore, IntrospectionStates>()
16
+ export type Path = readonly string[]
17
+ export interface Tools extends IntrospectionStates {
18
+ store: RootStore
19
+ expanded: RegularAtomToken<Record<string, boolean>>
20
+ error: RegularAtomToken<string>
21
+ attempt(run: () => void): void
22
+ }
23
+
24
+ export function ownedAtom<T>(store: RootStore, initial: T): RegularAtomToken<T> {
25
+ let family = locals.get(store)
26
+ if (!family) {
27
+ family = createRegularAtomFamily<unknown, string, never>(store, {
28
+ key: `🔍 spa devtools`,
29
+ default: undefined,
30
+ })
31
+ locals.set(store, family)
32
+ }
33
+ const token = findInStore(store, family, useId()) as RegularAtomToken<T>
34
+ setIntoStore(store, token, initial)
35
+ onCleanup(() => {
36
+ disposeFromStore(store, token)
37
+ })
38
+ return token
39
+ }
40
+ export function useLocal<T>(
41
+ store: RootStore,
42
+ initial: T,
43
+ ): [Accessor<T>, (next: T | Setter<T>) => void] {
44
+ const token = ownedAtom(store, initial)
45
+ return [useValue(token) as Accessor<T>, useSetter(token)]
46
+ }
47
+ export function useSaved<T>(
48
+ store: RootStore,
49
+ key: string,
50
+ fallback: T,
51
+ ): [Accessor<T>, (next: T | Setter<T>) => void] {
52
+ let initial = fallback
53
+ try {
54
+ const saved: unknown = JSON.parse(localStorage.getItem(key) ?? `null`)
55
+ if (saved !== null && typeof saved === typeof fallback) initial = saved as T
56
+ } catch {
57
+ /* Storage may be unavailable. */
58
+ }
59
+ const [value, set] = useLocal(store, initial)
60
+ return [
61
+ value,
62
+ (next) => {
63
+ set(next)
64
+ try {
65
+ localStorage.setItem(key, JSON.stringify(value()))
66
+ } catch {
67
+ /* Keep in-memory state when storage is unavailable. */
68
+ }
69
+ },
70
+ ]
71
+ }
72
+ export function useTools(store: RootStore): Tools {
73
+ let states = introspection.get(store)
74
+ if (!states) {
75
+ states = attachIntrospectionStates(store)
76
+ introspection.set(store, states)
77
+ }
78
+ let expanded: Record<string, boolean> = {}
79
+ try {
80
+ const saved: unknown = JSON.parse(
81
+ localStorage.getItem(`🔍 spa expanded`) ?? `{}`,
82
+ )
83
+ if (
84
+ saved &&
85
+ typeof saved === `object` &&
86
+ !Array.isArray(saved) &&
87
+ Object.values(saved).every((value) => typeof value === `boolean`)
88
+ )
89
+ expanded = saved as Record<string, boolean>
90
+ } catch {
91
+ /* Ignore corrupt or unavailable storage. */
92
+ }
93
+ const error = ownedAtom(store, ``)
94
+ const setError = useSetter(error)
95
+ return {
96
+ ...states,
97
+ store,
98
+ error,
99
+ expanded: ownedAtom(store, expanded),
100
+ attempt(run) {
101
+ try {
102
+ run()
103
+ setError(``)
104
+ } catch (caught) {
105
+ setError(caught instanceof Error ? caught.message : String(caught))
106
+ }
107
+ },
108
+ }
109
+ }
110
+ export function useExpanded(
111
+ tools: Tools,
112
+ path: Path,
113
+ initial = false,
114
+ ): [Accessor<boolean>, (event: MouseEvent) => void] {
115
+ const value = useValue(tools.expanded)
116
+ const set = useSetter(tools.expanded)
117
+ const key = JSON.stringify(path)
118
+ const group = `all:${JSON.stringify(path.slice(0, -1))}`
119
+ const read = () => value()[key] ?? value()[group] ?? initial
120
+ return [
121
+ read,
122
+ (event) => {
123
+ const next = !read()
124
+ set((previous) => {
125
+ const result = { ...previous }
126
+ if (event.shiftKey) {
127
+ const parent = JSON.stringify(path.slice(0, -1))
128
+ for (const item of Object.keys(result)) {
129
+ if (item.startsWith(`[`)) {
130
+ const candidate = JSON.parse(item) as string[]
131
+ if (JSON.stringify(candidate.slice(0, -1)) === parent)
132
+ Reflect.deleteProperty(result, item)
133
+ }
134
+ }
135
+ result[group] = next
136
+ } else {
137
+ result[key] = next
138
+ }
139
+ try {
140
+ localStorage.setItem(`🔍 spa expanded`, JSON.stringify(result))
141
+ } catch {
142
+ /* Keep in-memory expansion state. */
143
+ }
144
+ return result
145
+ })
146
+ },
147
+ ]
148
+ }
package/src/value.ts ADDED
@@ -0,0 +1,79 @@
1
+ export type Change = (update: (previous: unknown) => unknown) => void
2
+ export type JsonKind =
3
+ | `string`
4
+ | `number`
5
+ | `boolean`
6
+ | `null`
7
+ | `array`
8
+ | `object`
9
+ export function kind(value: unknown): JsonKind | `other` {
10
+ if (value === null) return `null`
11
+ if (Array.isArray(value)) return `array`
12
+ if (
13
+ typeof value === `object` &&
14
+ (Object.getPrototypeOf(value) === Object.prototype ||
15
+ Object.getPrototypeOf(value) === null)
16
+ )
17
+ return `object`
18
+ if (
19
+ typeof value === `string` ||
20
+ typeof value === `number` ||
21
+ typeof value === `boolean`
22
+ )
23
+ return typeof value as JsonKind
24
+ return `other`
25
+ }
26
+ export function describe(value: unknown): string {
27
+ try {
28
+ const seen = new WeakSet<object>()
29
+ return (
30
+ JSON.stringify(value, (_, item: unknown) => {
31
+ if (typeof item === `bigint`) return `${item}n`
32
+ if (item instanceof Error) return `${item.name}: ${item.message}`
33
+ if (item instanceof Map) return { Map: [...item] }
34
+ if (item instanceof Set) return { Set: [...item] }
35
+ if (typeof item === `object` && item !== null) {
36
+ if (seen.has(item)) return `[Circular]`
37
+ seen.add(item)
38
+ }
39
+ return item
40
+ }) ?? String(value)
41
+ )
42
+ } catch {
43
+ return Object.prototype.toString.call(value)
44
+ }
45
+ }
46
+ export function convert(value: unknown, type: JsonKind): unknown {
47
+ switch (type) {
48
+ case `null`:
49
+ return null
50
+ case `string`:
51
+ return value == null
52
+ ? ``
53
+ : typeof value === `string`
54
+ ? value
55
+ : describe(value)
56
+ case `number`: {
57
+ const next = Array.isArray(value) ? value.length : Number(value)
58
+ return Number.isFinite(next) ? next : 0
59
+ }
60
+ case `boolean`:
61
+ return typeof value === `string` ? value === `true` : Boolean(value)
62
+ case `array`:
63
+ return Array.isArray(value)
64
+ ? value
65
+ : value == null
66
+ ? []
67
+ : typeof value === `object`
68
+ ? Object.entries(value)
69
+ : [value]
70
+ case `object`:
71
+ return kind(value) === `object`
72
+ ? value
73
+ : Array.isArray(value)
74
+ ? Object.fromEntries(value.map((item, index) => [index, item]))
75
+ : value == null
76
+ ? {}
77
+ : { value }
78
+ }
79
+ }