skillet-shared 0.3.0 → 0.5.0

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,19 @@
1
+ ---
2
+ import type { SiteInfo } from '../dist/site-info.js'
3
+
4
+ interface Props {
5
+ site: SiteInfo
6
+ }
7
+ const { site } = Astro.props
8
+ ---
9
+
10
+ <footer class="siteFoot">
11
+ <div class="footIn">
12
+ <nav aria-label="Site">
13
+ {site.footer.map(([href, label]) => (
14
+ <a href={href}>{label}</a>
15
+ ))}
16
+ <a href="#top">Back to top ↑</a>
17
+ </nav>
18
+ </div>
19
+ </footer>
@@ -0,0 +1,28 @@
1
+ ---
2
+ interface Props {
3
+ hint: string
4
+ }
5
+ const { hint: SIGNED_IN_HINT } = Astro.props
6
+ ---
7
+ <script is:inline define:vars={{ SIGNED_IN_HINT }}>
8
+ try {
9
+ if (localStorage.getItem(SIGNED_IN_HINT) === '1') {
10
+ const root = document.documentElement
11
+ root.dataset.home = 'pending'
12
+ for (const host of [
13
+ 'identitytoolkit.googleapis.com',
14
+ 'securetoken.googleapis.com',
15
+ 'firestore.googleapis.com',
16
+ ]) {
17
+ const link = document.createElement('link')
18
+ link.rel = 'preconnect'
19
+ link.href = `https://${host}`
20
+ link.crossOrigin = 'anonymous'
21
+ document.head.append(link)
22
+ }
23
+ setTimeout(() => {
24
+ delete root.dataset.home
25
+ }, 15000)
26
+ }
27
+ } catch {}
28
+ </script>
@@ -0,0 +1,77 @@
1
+ ---
2
+ import type { SiteInfo } from '../dist/site-info.js'
3
+
4
+ interface Props {
5
+ site: SiteInfo
6
+ account?: boolean
7
+ current?: string
8
+ }
9
+ const { site, account = true, current } = Astro.props
10
+ ---
11
+
12
+ <header class="siteBar">
13
+ <div class="barIn">
14
+ <a class="barBrand" href={site.home}>
15
+ <img src={site.icon} width="24" height="24" alt="" />
16
+ <span>{site.name}</span>
17
+ </a>
18
+ <nav class="barNav" aria-label="Site">
19
+ {site.nav.map(([href, label]) => (
20
+ <a href={href} aria-current={href === current ? 'page' : undefined}>
21
+ {label}
22
+ </a>
23
+ ))}
24
+ </nav>
25
+ <div class="barEnd">
26
+ {account && (
27
+ <Fragment>
28
+ {/* The question beside the ask, where the reader meets it. */}
29
+ <button class="why" id="why" type="button">
30
+ Why sign in?
31
+ </button>
32
+ <button
33
+ class="btn slim"
34
+ id="signIn"
35
+ type="button"
36
+ aria-label="Sign in"
37
+ >
38
+ <svg
39
+ class="ico"
40
+ viewBox="0 0 24 24"
41
+ fill="none"
42
+ stroke="currentColor"
43
+ stroke-width="1.7"
44
+ stroke-linecap="round"
45
+ stroke-linejoin="round"
46
+ aria-hidden="true"
47
+ >
48
+ <circle cx="12" cy="8" r="3.6" />
49
+ <path d="M4.8 20a7.2 7.2 0 0 1 14.4 0" />
50
+ </svg>
51
+ <span class="barLabel">Sign in</span>
52
+ </button>
53
+ <div class="acct" id="acct" hidden>
54
+ <button
55
+ class="acctBtn"
56
+ id="acctBtn"
57
+ type="button"
58
+ aria-controls="acctMenu"
59
+ aria-expanded="false"
60
+ >
61
+ <span class="avatar" id="avatar" />
62
+ </button>
63
+ <div class="acctMenu" id="acctMenu" hidden>
64
+ <p class="acctName" id="acctName" />
65
+ <button class="acctOut" id="signOut" type="button">
66
+ Sign out
67
+ </button>
68
+ </div>
69
+ </div>
70
+ </Fragment>
71
+ )}
72
+ <a class="btn primary slim" href={site.app}>
73
+ Open the app →
74
+ </a>
75
+ </div>
76
+ </div>
77
+ </header>
@@ -0,0 +1,32 @@
1
+ ---
2
+ // How a link to this page unfurls when somebody shares it. The values differ
3
+ // per product and per page; the tags do not, so the whole file is shared.
4
+ //
5
+ // The description here is the one a reader meets in a chat window rather than
6
+ // in a search result, so a page may hand in a shorter one than it gives
7
+ // `<meta name="description">`.
8
+ interface Props {
9
+ siteName: string
10
+ url: string
11
+ title: string
12
+ description: string
13
+ image: string
14
+ imageWidth: number
15
+ imageHeight: number
16
+ }
17
+ const { siteName, url, title, description, image, imageWidth, imageHeight } =
18
+ Astro.props
19
+ ---
20
+
21
+ <meta property="og:type" content="website" />
22
+ <meta property="og:site_name" content={siteName} />
23
+ <meta property="og:url" content={url} />
24
+ <meta property="og:title" content={title} />
25
+ <meta property="og:description" content={description} />
26
+ <meta property="og:image" content={image} />
27
+ <meta property="og:image:width" content={imageWidth} />
28
+ <meta property="og:image:height" content={imageHeight} />
29
+ <meta name="twitter:card" content="summary_large_image" />
30
+ <meta name="twitter:title" content={title} />
31
+ <meta name="twitter:description" content={description} />
32
+ <meta name="twitter:image" content={image} />
package/css/footer.css ADDED
@@ -0,0 +1,28 @@
1
+ /* The band at the bottom of every site page, landing, guide and privacy alike. */
2
+ .siteFoot {
3
+ border-top: 1px solid var(--border-faint);
4
+ background: var(--surface);
5
+ }
6
+ .footIn {
7
+ max-width: 63rem;
8
+ margin: 0 auto;
9
+ padding: 1.75rem max(1.15rem, env(safe-area-inset-left))
10
+ calc(2rem + env(safe-area-inset-bottom));
11
+ font-size: 0.86rem;
12
+ }
13
+ .footIn nav {
14
+ display: flex;
15
+ flex-wrap: wrap;
16
+ gap: 0.5rem 1.35rem;
17
+ margin-bottom: 0.9rem;
18
+ }
19
+ .footIn a {
20
+ color: var(--fg3);
21
+ text-decoration: none;
22
+ }
23
+ .footIn a:hover {
24
+ color: var(--fg);
25
+ }
26
+ .footIn small {
27
+ color: var(--fg4);
28
+ }
package/css/site.css ADDED
@@ -0,0 +1,717 @@
1
+ /* The static site's shared components, identical in videoskillet and bender:
2
+ the page base, buttons, the site bar, the account menu, the why-sign-in card,
3
+ the privacy, about and blog pages, the signed-in home and its skeleton.
4
+ Colours come from tokens.css and each product's --accent and --accent-ink. */
5
+ :root {
6
+ --measure: 46rem;
7
+ }
8
+ body {
9
+ margin: 0;
10
+ background: var(--bg);
11
+ color: var(--fg);
12
+ line-height: 1.65;
13
+ font-family: var(--font);
14
+ font-size: 16px;
15
+ -webkit-text-size-adjust: 100%;
16
+ }
17
+ a {
18
+ color: var(--accent);
19
+ }
20
+ /* `display` on a class beats the browser's own `[hidden]` rule, and the bar
21
+ hides an inline-flex button and a flex box by setting the attribute. */
22
+ [hidden] {
23
+ display: none !important;
24
+ }
25
+ .wrap {
26
+ max-width: var(--measure);
27
+ margin: 0 auto;
28
+ /* Inline only: `padding` here is a shorthand, and it used to zero the
29
+ `padding-block` a page's own sheet had set. The inset keeps the text clear
30
+ of a cutout, the way .barIn and .footIn already do — it reads as zero on
31
+ every screen that has no cutout. */
32
+ padding-inline: max(1.25rem, env(safe-area-inset-left));
33
+ }
34
+ .wide {
35
+ max-width: 72rem;
36
+ }
37
+
38
+ section {
39
+ padding: 3.5rem 0;
40
+ }
41
+ .head {
42
+ font-size: 1.35rem;
43
+ font-weight: 600;
44
+ color: var(--fg);
45
+ margin: 0 0 0.5rem;
46
+ }
47
+ .sub {
48
+ color: var(--fg4);
49
+ font-size: 0.95rem;
50
+ margin: 0 0 1.1rem;
51
+ max-width: var(--measure);
52
+ }
53
+
54
+ .btn {
55
+ display: inline-block;
56
+ padding: 0.72rem 1.35rem;
57
+ border-radius: 0.5rem;
58
+ border: 1px solid var(--border-faint);
59
+ background: var(--surface);
60
+ color: var(--fg);
61
+ text-decoration: none;
62
+ font-weight: 600;
63
+ font-size: 1rem;
64
+ }
65
+ .btn:hover {
66
+ border-color: var(--fg4);
67
+ }
68
+ .btn.primary {
69
+ background: var(--accent);
70
+ border-color: var(--accent);
71
+ color: var(--accent-ink);
72
+ }
73
+ .btn.primary:hover {
74
+ filter: brightness(1.08);
75
+ }
76
+
77
+ /* ── the site bar ──────────────────────────────────────────────────────────
78
+ A slim row over both states of `/`: the site's three destinations on the
79
+ left, the account and the app on the right. The guide's own bar is in
80
+ guide.css and keeps its shape; the two share the palette and nothing else,
81
+ because the middle of the guide's bar is the pages of one group and the
82
+ middle of this one is the site. */
83
+ .siteBar {
84
+ border-bottom: 1px solid var(--border-faint);
85
+ background: var(--bg);
86
+ }
87
+ .barIn {
88
+ display: flex;
89
+ align-items: center;
90
+ gap: 0 1.25rem;
91
+ height: 56px;
92
+ max-width: 72rem;
93
+ margin: 0 auto;
94
+ padding: 0 max(1.15rem, env(safe-area-inset-left));
95
+ }
96
+ .barBrand {
97
+ display: flex;
98
+ align-items: center;
99
+ gap: 0.5rem;
100
+ font-weight: 700;
101
+ font-size: 15px;
102
+ color: var(--fg);
103
+ text-decoration: none;
104
+ white-space: nowrap;
105
+ }
106
+ .barNav {
107
+ display: flex;
108
+ gap: 1.15rem;
109
+ margin-right: auto;
110
+ }
111
+ .barNav a {
112
+ color: var(--fg3);
113
+ text-decoration: none;
114
+ font-size: 14px;
115
+ white-space: nowrap;
116
+ }
117
+ .barNav a:hover {
118
+ color: var(--fg);
119
+ }
120
+ .barEnd {
121
+ display: flex;
122
+ align-items: center;
123
+ gap: 0.6rem;
124
+ margin-left: auto;
125
+ }
126
+ /* The bar's own size for `.btn`, which the hero sets at full size. */
127
+ .btn.slim {
128
+ display: inline-flex;
129
+ align-items: center;
130
+ gap: 0.4rem;
131
+ padding: 0.4rem 0.85rem;
132
+ font-size: 0.875rem;
133
+ line-height: 1.25;
134
+ }
135
+ .ico {
136
+ width: 1.05rem;
137
+ height: 1.05rem;
138
+ flex: none;
139
+ }
140
+ /* On a phone the bar keeps the brand and the two buttons it exists for. The
141
+ destinations are in the footer and the rail, "Why sign in?" is asked again
142
+ under the hero, and the sign-in button keeps its icon and its label for
143
+ screen readers. The narrowest phones keep the logo without the name. */
144
+ .barEnd .btn {
145
+ white-space: nowrap;
146
+ justify-content: center;
147
+ min-width: 8.5rem;
148
+ }
149
+ @media (max-width: 44rem) {
150
+ .barIn {
151
+ gap: 0 0.75rem;
152
+ }
153
+ .barNav,
154
+ #why,
155
+ .barLabel {
156
+ display: none;
157
+ }
158
+ .barBrand {
159
+ margin-right: auto;
160
+ }
161
+ .barEnd {
162
+ gap: 0.5rem;
163
+ }
164
+ .barEnd .btn {
165
+ min-width: 0;
166
+ }
167
+ #signIn {
168
+ padding-inline: 0.55rem;
169
+ }
170
+ }
171
+ @media (max-width: 23rem) {
172
+ .barBrand span {
173
+ display: none;
174
+ }
175
+ }
176
+
177
+ .acct {
178
+ position: relative;
179
+ }
180
+ .acctBtn {
181
+ display: block;
182
+ padding: 0;
183
+ border: 0;
184
+ background: none;
185
+ cursor: pointer;
186
+ }
187
+ .avatar {
188
+ display: grid;
189
+ place-items: center;
190
+ width: 28px;
191
+ height: 28px;
192
+ border-radius: 50%;
193
+ overflow: hidden;
194
+ border: 1px solid var(--border-faint);
195
+ background: var(--surface);
196
+ color: var(--fg2);
197
+ font-size: 0.8rem;
198
+ font-weight: 600;
199
+ }
200
+ .avatar img {
201
+ width: 100%;
202
+ height: 100%;
203
+ object-fit: cover;
204
+ display: block;
205
+ }
206
+ .acctBtn:hover .avatar,
207
+ .acctBtn:focus-visible .avatar {
208
+ border-color: var(--accent);
209
+ }
210
+ .acctMenu {
211
+ position: absolute;
212
+ right: 0;
213
+ top: calc(100% + 0.45rem);
214
+ z-index: 20;
215
+ min-width: 11rem;
216
+ padding: 0.5rem;
217
+ border: 1px solid var(--border-faint);
218
+ border-radius: 0.5rem;
219
+ background: var(--surface);
220
+ }
221
+ .acctName {
222
+ margin: 0 0 0.3rem;
223
+ padding: 0.15rem 0.4rem;
224
+ font-size: 0.85rem;
225
+ color: var(--fg2);
226
+ overflow-wrap: anywhere;
227
+ }
228
+ .acctOut {
229
+ width: 100%;
230
+ padding: 0.35rem 0.4rem;
231
+ border: 0;
232
+ border-radius: 0.35rem;
233
+ background: none;
234
+ color: var(--fg3);
235
+ font: inherit;
236
+ font-size: 0.85rem;
237
+ text-align: left;
238
+ cursor: pointer;
239
+ }
240
+ .acctOut:hover {
241
+ background: var(--bg);
242
+ color: var(--fg);
243
+ }
244
+
245
+ /* ── why sign in ───────────────────────────────────────────────────────────
246
+ The question beside the bar's sign-in button and under the hero, and the card
247
+ both of them open. The card's prose is src/ui/whySignIn.ts, the same strings
248
+ the app's own dialog renders. */
249
+ .why {
250
+ font: inherit;
251
+ font-size: 0.9rem;
252
+ padding: 0.35rem 0.15rem;
253
+ background: none;
254
+ color: var(--fg4);
255
+ border: none;
256
+ text-decoration: underline;
257
+ text-underline-offset: 2px;
258
+ cursor: pointer;
259
+ }
260
+ .why:hover,
261
+ .why:focus-visible {
262
+ color: var(--fg);
263
+ }
264
+ .whyCard {
265
+ width: min(34rem, calc(100vw - 2rem));
266
+ padding: 1.4rem 1.5rem;
267
+ background: var(--surface);
268
+ color: var(--fg);
269
+ border: 1px solid var(--border-faint);
270
+ border-radius: 0.6rem;
271
+ }
272
+ .whyCard::backdrop {
273
+ background: #000a;
274
+ }
275
+ .whyTop {
276
+ display: flex;
277
+ align-items: baseline;
278
+ justify-content: space-between;
279
+ gap: 1rem;
280
+ }
281
+ .whyHead {
282
+ margin: 0 0 0.9rem;
283
+ font-size: 1.4rem;
284
+ }
285
+ /* Why the last sign-in did not finish, above the pitch and the retry. */
286
+ .whyTrouble {
287
+ margin: 0 0 0.9rem;
288
+ padding: 0.6rem 0.8rem;
289
+ border: 1px solid var(--danger);
290
+ border-radius: 0.4rem;
291
+ color: var(--danger);
292
+ font-size: 0.95rem;
293
+ }
294
+ /* The answer, at the size of the thing being answered. */
295
+ .whyPitch {
296
+ margin: 0 0 0.7rem;
297
+ font-size: 1.05rem;
298
+ color: var(--fg);
299
+ }
300
+ .whyLine {
301
+ margin: 0 0 0.7rem;
302
+ color: var(--fg3);
303
+ font-size: 0.95rem;
304
+ }
305
+ .whyLine a {
306
+ color: var(--accent);
307
+ white-space: nowrap;
308
+ }
309
+ .whyCta {
310
+ display: flex;
311
+ flex-wrap: wrap;
312
+ gap: 0.6rem;
313
+ margin: 1.1rem 0 0.8rem;
314
+ }
315
+ /* ── the privacy page ──────────────────────────────────────────────────────
316
+ One column of prose under the site bar, at the width the rest of the site
317
+ reads at. */
318
+ .privacy {
319
+ padding-top: 2.5rem;
320
+ padding-bottom: 4rem;
321
+ }
322
+ .privacy h1 {
323
+ margin: 0 0 0.6rem;
324
+ font-size: 2rem;
325
+ }
326
+ .privacy h2 {
327
+ margin: 2rem 0 0.5rem;
328
+ font-size: 1.25rem;
329
+ }
330
+ .privacy p {
331
+ margin: 0;
332
+ color: var(--fg3);
333
+ line-height: 1.65;
334
+ }
335
+ .privacy p + p {
336
+ margin-top: 0.8rem;
337
+ }
338
+ .privacy a {
339
+ color: var(--accent);
340
+ }
341
+ .privacyLead {
342
+ font-size: 1.1rem;
343
+ }
344
+
345
+ /* ── the about page and the blog ───────────────────────────────────────── */
346
+ .about,
347
+ .blog {
348
+ padding-top: 2.5rem;
349
+ padding-bottom: 4rem;
350
+ }
351
+ .about h1,
352
+ .blog h1 {
353
+ margin: 0 0 0.6rem;
354
+ font-size: 2rem;
355
+ }
356
+ .about p,
357
+ .blog p,
358
+ .blog li {
359
+ color: var(--fg3);
360
+ }
361
+ .about p {
362
+ margin: 0 0 0.8rem;
363
+ }
364
+ .aboutLead,
365
+ .blogLead {
366
+ font-size: 1.1rem;
367
+ }
368
+ .aboutLinks {
369
+ margin: 1.5rem 0 0;
370
+ padding-left: 1.2rem;
371
+ }
372
+ .aboutLinks li {
373
+ margin-bottom: 0.4rem;
374
+ }
375
+ .postMeta {
376
+ margin: 0.2rem 0 0;
377
+ font-variant-numeric: tabular-nums;
378
+ font-size: 0.85rem;
379
+ color: var(--fg4);
380
+ }
381
+ .postCard {
382
+ display: grid;
383
+ grid-template-columns: 14rem minmax(0, 1fr);
384
+ gap: 1.2rem;
385
+ margin-top: 2rem;
386
+ padding-top: 1.5rem;
387
+ border-top: 1px solid var(--border-faint);
388
+ }
389
+ .postCard img,
390
+ .postHero,
391
+ .postBody img {
392
+ display: block;
393
+ width: 100%;
394
+ height: auto;
395
+ border-radius: 4px;
396
+ }
397
+ .postCard h2 {
398
+ margin: 0;
399
+ font-size: 1.25rem;
400
+ }
401
+ .postCard h2 a {
402
+ color: var(--fg);
403
+ text-decoration: none;
404
+ }
405
+ .postCard h2 a:hover {
406
+ text-decoration: underline;
407
+ }
408
+ .postCard p {
409
+ margin: 0.4rem 0 0;
410
+ }
411
+ @media (max-width: 560px) {
412
+ .postCard {
413
+ grid-template-columns: 1fr;
414
+ }
415
+ }
416
+ .postFull h1 {
417
+ margin: 0.4rem 0 1rem;
418
+ }
419
+ .postLook {
420
+ font-weight: 600;
421
+ }
422
+ .postBody {
423
+ line-height: 1.6;
424
+ }
425
+ .postBody h2 {
426
+ margin: 2rem 0 0.6rem;
427
+ font-size: 1.25rem;
428
+ }
429
+ .postBody p {
430
+ margin: 0 0 0.9rem;
431
+ }
432
+ .postBody .anchor {
433
+ margin-left: 0.45em;
434
+ color: var(--fg4);
435
+ text-decoration: none;
436
+ font-weight: 400;
437
+ opacity: 0;
438
+ }
439
+ .postBody h2:hover .anchor,
440
+ .postBody .anchor:focus-visible {
441
+ opacity: 1;
442
+ }
443
+ .postBody img {
444
+ margin: 0.4rem 0 1rem;
445
+ }
446
+
447
+ /* ── the signed-in home ────────────────────────────────────────────────────
448
+ A rail of links and one column of sections, built by site/scripts/home.ts
449
+ once Firebase has answered. The cards are the gallery's own anatomy, so a
450
+ saved look and a demo read as the same kind of thing. */
451
+ .homeIn {
452
+ display: grid;
453
+ grid-template-columns: 200px minmax(0, 1fr);
454
+ gap: 2rem;
455
+ max-width: 88rem;
456
+ margin: 0 auto;
457
+ padding: 2rem max(1.15rem, env(safe-area-inset-left)) 3.5rem;
458
+ }
459
+ .rail {
460
+ display: flex;
461
+ flex-direction: column;
462
+ gap: 0.15rem;
463
+ position: sticky;
464
+ top: 1rem;
465
+ align-self: start;
466
+ }
467
+ .rail a {
468
+ padding: 0.4rem 0.6rem;
469
+ border-radius: 0.4rem;
470
+ color: var(--fg3);
471
+ text-decoration: none;
472
+ font-size: 0.92rem;
473
+ white-space: nowrap;
474
+ }
475
+ .rail a:hover {
476
+ background: var(--surface);
477
+ color: var(--fg);
478
+ }
479
+ .rail a.on {
480
+ background: var(--surface);
481
+ color: var(--accent);
482
+ }
483
+ .homeMain {
484
+ display: flex;
485
+ flex-direction: column;
486
+ gap: 2.75rem;
487
+ }
488
+ /* `section`'s 3.5rem belongs to the landing page's full-bleed bands; the
489
+ column spaces itself. */
490
+ .homeSec {
491
+ padding: 0;
492
+ scroll-margin-top: 1rem;
493
+ }
494
+ .homeSec .head {
495
+ font-size: 1.15rem;
496
+ }
497
+ /* The rail becomes the row under the bar: 200px of links beside a single
498
+ column of cards is most of a phone's width. */
499
+ @media (max-width: 52rem) {
500
+ .homeIn {
501
+ grid-template-columns: minmax(0, 1fr);
502
+ gap: 1.35rem;
503
+ }
504
+ .rail {
505
+ position: static;
506
+ flex-direction: row;
507
+ gap: 0.35rem;
508
+ overflow-x: auto;
509
+ scrollbar-width: none;
510
+ }
511
+ }
512
+
513
+ .resumeCard {
514
+ display: grid;
515
+ grid-template-columns: minmax(0, 20rem) minmax(0, 1fr);
516
+ border: 1px solid var(--border-faint);
517
+ border-radius: 0.6rem;
518
+ background: var(--surface);
519
+ overflow: hidden;
520
+ }
521
+ .resumeCard .shot {
522
+ position: relative;
523
+ display: block;
524
+ aspect-ratio: 5 / 4;
525
+ background: #000;
526
+ }
527
+ .resumeCard .shot .still {
528
+ position: absolute;
529
+ inset: 0;
530
+ width: 100%;
531
+ height: 100%;
532
+ display: block;
533
+ object-fit: cover;
534
+ }
535
+ .resumeBody {
536
+ display: flex;
537
+ flex-direction: column;
538
+ justify-content: center;
539
+ gap: 0.45rem;
540
+ padding: 1.35rem;
541
+ }
542
+ .when {
543
+ margin: 0;
544
+ color: var(--fg4);
545
+ font-size: 0.85rem;
546
+ }
547
+ .resumeSays {
548
+ margin: 0;
549
+ color: var(--fg2);
550
+ font-size: 0.95rem;
551
+ text-wrap: pretty;
552
+ }
553
+ .resumeCta {
554
+ display: flex;
555
+ flex-wrap: wrap;
556
+ gap: 0.6rem;
557
+ margin: 0.7rem 0 0;
558
+ }
559
+ @media (max-width: 44rem) {
560
+ .resumeCard {
561
+ grid-template-columns: minmax(0, 1fr);
562
+ }
563
+ }
564
+
565
+ /* A look with no still: the app mark on the card's own black, so the row of
566
+ cards keeps its shape. */
567
+ .shot.blank {
568
+ display: grid;
569
+ place-items: center;
570
+ background: #0a0b0d;
571
+ }
572
+ .resumeCard .shot.blank .blankMark {
573
+ position: static;
574
+ width: 2.5rem;
575
+ height: 2.5rem;
576
+ object-fit: contain;
577
+ opacity: 0.55;
578
+ }
579
+
580
+ /* A saved card with its verbs under it. The <li> carries the card's border so
581
+ the link and the row of buttons read as one card, and the row sits at the
582
+ bottom however long the name above it runs. */
583
+ .grid li:has(> .cardActions) {
584
+ display: flex;
585
+ flex-direction: column;
586
+ overflow: hidden;
587
+ border: 1px solid var(--border-faint);
588
+ border-radius: 0.6rem;
589
+ background: var(--surface);
590
+ }
591
+ .grid li:has(> .cardActions) > a:first-child {
592
+ flex: 1;
593
+ border: 0;
594
+ border-radius: 0;
595
+ }
596
+ .grid li:has(> a:first-child:hover, > a:first-child:focus-visible) {
597
+ border-color: var(--accent);
598
+ }
599
+ .cardActions {
600
+ display: flex;
601
+ flex-wrap: wrap;
602
+ align-items: center;
603
+ gap: 0.3rem 0.8rem;
604
+ padding: 0.45rem 0.85rem 0.6rem;
605
+ border-top: 1px solid var(--border-faint);
606
+ font-size: 0.8rem;
607
+ }
608
+ .cardAct {
609
+ padding: 0.15rem 0;
610
+ border: 0;
611
+ background: none;
612
+ color: var(--fg4);
613
+ font: inherit;
614
+ text-decoration: underline;
615
+ text-underline-offset: 2px;
616
+ cursor: pointer;
617
+ }
618
+ .cardAct:hover,
619
+ .cardAct:focus-visible {
620
+ color: var(--fg);
621
+ }
622
+ .cardAct:disabled {
623
+ opacity: 0.5;
624
+ cursor: default;
625
+ }
626
+ .cardAsk {
627
+ flex-basis: 100%;
628
+ color: var(--fg2);
629
+ overflow-wrap: anywhere;
630
+ }
631
+ .cardRename {
632
+ flex: 1 1 100%;
633
+ min-width: 0;
634
+ padding: 0.3rem 0.45rem;
635
+ border: 1px solid var(--border-faint);
636
+ border-radius: 0.35rem;
637
+ background: var(--bg);
638
+ color: var(--fg);
639
+ font: inherit;
640
+ }
641
+ .cardStatus {
642
+ flex-basis: 100%;
643
+ color: var(--fg3);
644
+ }
645
+ .cardStatus:empty {
646
+ display: none;
647
+ }
648
+
649
+ .empty {
650
+ padding: 2.25rem 1.5rem;
651
+ border: 1px dashed var(--border-faint);
652
+ border-radius: 0.6rem;
653
+ text-align: center;
654
+ }
655
+ .emptyHead {
656
+ margin: 0 0 0.5rem;
657
+ font-size: 1.05rem;
658
+ font-weight: 600;
659
+ }
660
+ .emptySays {
661
+ max-width: 34rem;
662
+ margin: 0 auto 1.15rem;
663
+ color: var(--fg4);
664
+ font-size: 0.92rem;
665
+ text-wrap: pretty;
666
+ }
667
+
668
+ /* ── waiting for Firebase ──────────────────────────────────────────────────
669
+ SignedInHint.astro's head script sets `data-home="pending"` on a browser that
670
+ has signed in before. The landing page and the sign-in controls stay out of
671
+ sight and the home's outline stands in until home.ts paints one state. */
672
+ .homeWait {
673
+ display: none;
674
+ }
675
+ html[data-home='pending'] .homeWait {
676
+ display: block;
677
+ min-height: 100vh;
678
+ }
679
+ html[data-home='pending'] #landing,
680
+ html[data-home='pending'] #signIn,
681
+ html[data-home='pending'] #why {
682
+ display: none;
683
+ }
684
+ .vh {
685
+ position: absolute;
686
+ width: 1px;
687
+ height: 1px;
688
+ overflow: hidden;
689
+ clip-path: inset(50%);
690
+ white-space: nowrap;
691
+ }
692
+ .bone {
693
+ display: block;
694
+ animation: bone 1.4s ease-in-out infinite;
695
+ }
696
+ @keyframes bone {
697
+ 50% {
698
+ opacity: 0.5;
699
+ }
700
+ }
701
+ @media (prefers-reduced-motion: reduce) {
702
+ .bone {
703
+ animation: none;
704
+ }
705
+ }
706
+ .bone {
707
+ border-radius: 0.4rem;
708
+ background: var(--surface);
709
+ }
710
+ .railBone {
711
+ width: 7rem;
712
+ height: 1.9rem;
713
+ }
714
+ .headBone {
715
+ width: 14rem;
716
+ height: 1.4rem;
717
+ }
@@ -0,0 +1,50 @@
1
+ import type { RecentSession, SavedItem } from './saved-list.ts';
2
+ import type { FirebaseApp, FirebaseOptions } from 'firebase/app';
3
+ import type { Auth } from 'firebase/auth';
4
+ import type { Firestore } from 'firebase/firestore/lite';
5
+ export interface CloudUser {
6
+ uid: string;
7
+ name: string | null;
8
+ photo: string | null;
9
+ }
10
+ export interface HomeDoc {
11
+ items: SavedItem[];
12
+ recent: RecentSession[];
13
+ }
14
+ export interface Sdk {
15
+ app: FirebaseApp;
16
+ auth: Auth;
17
+ db: Firestore;
18
+ fs: typeof import('firebase/firestore/lite');
19
+ authMod: typeof import('firebase/auth');
20
+ }
21
+ export interface CloudOptions {
22
+ config: FirebaseOptions;
23
+ collection: string;
24
+ listField: string;
25
+ signedInHint: string;
26
+ appCheckSiteKey: string;
27
+ recentMax: number;
28
+ storage: {
29
+ read: (key: string) => string | null;
30
+ write: (key: string, value: string) => void;
31
+ forget: (key: string) => void;
32
+ };
33
+ }
34
+ export declare function createCloud(options: CloudOptions): {
35
+ wasSignedIn: () => boolean;
36
+ loadSdk: () => Promise<Sdk>;
37
+ userDoc: (uid: string) => Promise<{
38
+ db: Firestore;
39
+ fs: typeof import("firebase/firestore/lite");
40
+ ref: import("firebase/firestore/lite").DocumentReference<import("firebase/firestore/lite").DocumentData, import("firebase/firestore/lite").DocumentData>;
41
+ }>;
42
+ warmSignIn: () => void;
43
+ watchAuth: (onUser: (user: CloudUser | null) => void) => Promise<() => void>;
44
+ signIn: () => Promise<CloudUser>;
45
+ signOut: () => Promise<void>;
46
+ fetchHome: (uid: string) => Promise<HomeDoc>;
47
+ editItems: (uid: string, edit: (items: SavedItem[]) => SavedItem[]) => Promise<SavedItem[]>;
48
+ putSession: (uid: string, entry: RecentSession) => Promise<string[]>;
49
+ dropSession: (uid: string, id: string) => Promise<RecentSession[]>;
50
+ };
package/dist/cloud.js ADDED
@@ -0,0 +1,140 @@
1
+ import { ITEM_MAX, pushRecent, readItems, readRecent, removeRecent, } from './saved-list.js';
2
+ const asCloudUser = (user) => ({
3
+ uid: user.uid,
4
+ name: user.displayName,
5
+ photo: user.photoURL,
6
+ });
7
+ const itemEntry = (item) => ({
8
+ name: item.name,
9
+ query: item.query,
10
+ ...(item.id === undefined ? {} : { id: item.id }),
11
+ ...(item.savedAt === undefined ? {} : { savedAt: item.savedAt }),
12
+ });
13
+ const sessionEntry = (s) => ({
14
+ id: s.id,
15
+ query: s.query,
16
+ at: s.at,
17
+ });
18
+ async function installAppCheck(app, siteKey) {
19
+ if (siteKey === '')
20
+ return;
21
+ const mod = await import('firebase/app-check');
22
+ mod.initializeAppCheck(app, {
23
+ provider: new mod.ReCaptchaEnterpriseProvider(siteKey),
24
+ isTokenAutoRefreshEnabled: true,
25
+ });
26
+ }
27
+ export function createCloud(options) {
28
+ const { config, collection, listField, signedInHint, recentMax, storage } = options;
29
+ const wasSignedIn = () => storage.read(signedInHint) === '1';
30
+ let sdk = null;
31
+ function loadSdk() {
32
+ if (sdk !== null)
33
+ return sdk;
34
+ const load = (async () => {
35
+ const [appMod, authMod, fs] = await Promise.all([
36
+ import('firebase/app'),
37
+ import('firebase/auth'),
38
+ import('firebase/firestore/lite'),
39
+ ]);
40
+ const app = appMod.initializeApp(config);
41
+ await installAppCheck(app, options.appCheckSiteKey);
42
+ return {
43
+ app,
44
+ auth: authMod.getAuth(app),
45
+ db: fs.getFirestore(app),
46
+ fs,
47
+ authMod,
48
+ };
49
+ })();
50
+ sdk = load;
51
+ load.catch(() => {
52
+ if (sdk === load)
53
+ sdk = null;
54
+ });
55
+ return load;
56
+ }
57
+ const warmSignIn = () => {
58
+ loadSdk().catch(() => undefined);
59
+ };
60
+ async function watchAuth(onUser) {
61
+ const { auth, authMod } = await loadSdk();
62
+ return authMod.onAuthStateChanged(auth, user => {
63
+ if (user === null)
64
+ storage.forget(signedInHint);
65
+ else
66
+ storage.write(signedInHint, '1');
67
+ onUser(user === null ? null : asCloudUser(user));
68
+ });
69
+ }
70
+ async function signIn() {
71
+ const { auth, authMod } = await loadSdk();
72
+ const provider = new authMod.GoogleAuthProvider();
73
+ const result = await authMod.signInWithPopup(auth, provider);
74
+ storage.write(signedInHint, '1');
75
+ return asCloudUser(result.user);
76
+ }
77
+ async function signOut() {
78
+ const { auth } = await loadSdk();
79
+ storage.forget(signedInHint);
80
+ await auth.signOut();
81
+ }
82
+ async function userDoc(uid) {
83
+ const { db, fs } = await loadSdk();
84
+ return { db, fs, ref: fs.doc(db, collection, uid) };
85
+ }
86
+ async function fetchHome(uid) {
87
+ const { fs, ref } = await userDoc(uid);
88
+ const snap = await fs.getDoc(ref);
89
+ if (!snap.exists())
90
+ return { items: [], recent: [] };
91
+ const data = snap.data();
92
+ return {
93
+ items: readItems(data[listField]),
94
+ recent: readRecent(data.recent, recentMax, data.current),
95
+ };
96
+ }
97
+ async function editItems(uid, edit) {
98
+ const { db, fs, ref } = await userDoc(uid);
99
+ return fs.runTransaction(db, async (tx) => {
100
+ const snap = await tx.get(ref);
101
+ const stored = snap.exists() ? readItems(snap.data()[listField]) : [];
102
+ const next = edit(stored).slice(0, ITEM_MAX);
103
+ tx.set(ref, { [listField]: next.map(itemEntry) }, { merge: true });
104
+ return next;
105
+ });
106
+ }
107
+ async function putSession(uid, entry) {
108
+ const { db, fs, ref } = await userDoc(uid);
109
+ return fs.runTransaction(db, async (tx) => {
110
+ const snap = await tx.get(ref);
111
+ const data = snap.exists() ? snap.data() : undefined;
112
+ const { recent, dropped } = pushRecent(readRecent(data?.recent, recentMax, data?.current), entry, recentMax);
113
+ tx.set(ref, { recent: recent.map(sessionEntry), current: fs.deleteField() }, { merge: true });
114
+ return dropped;
115
+ });
116
+ }
117
+ async function dropSession(uid, id) {
118
+ const { db, fs, ref } = await userDoc(uid);
119
+ return fs.runTransaction(db, async (tx) => {
120
+ const snap = await tx.get(ref);
121
+ const data = snap.exists() ? snap.data() : undefined;
122
+ const recent = removeRecent(readRecent(data?.recent, recentMax, data?.current), id);
123
+ tx.set(ref, { recent: recent.map(sessionEntry) }, { merge: true });
124
+ return recent;
125
+ });
126
+ }
127
+ return {
128
+ wasSignedIn,
129
+ loadSdk,
130
+ userDoc,
131
+ warmSignIn,
132
+ watchAuth,
133
+ signIn,
134
+ signOut,
135
+ fetchHome,
136
+ editItems,
137
+ putSession,
138
+ dropSession,
139
+ };
140
+ }
@@ -0,0 +1,30 @@
1
+ export interface SavedItem {
2
+ name: string;
3
+ query: string;
4
+ id?: string;
5
+ savedAt?: number;
6
+ }
7
+ export interface CurrentSession {
8
+ query: string;
9
+ at: number;
10
+ }
11
+ export interface RecentSession extends CurrentSession {
12
+ id: string;
13
+ }
14
+ export declare const ITEM_MAX = 200;
15
+ export declare const QUERY_MAX = 8000;
16
+ export declare const ITEM_NAME_MAX = 40;
17
+ export declare const newItemId: () => string;
18
+ export declare const cleanItemName: (raw: string) => string;
19
+ export declare const readItems: (raw: unknown) => SavedItem[];
20
+ export declare function readCurrent(raw: unknown): CurrentSession | null;
21
+ export declare function readRecent(raw: unknown, recentMax: number, legacy?: unknown): RecentSession[];
22
+ export declare function pushRecent(recent: readonly RecentSession[], entry: RecentSession, recentMax: number): {
23
+ recent: RecentSession[];
24
+ dropped: string[];
25
+ };
26
+ export declare const removeRecent: (recent: readonly RecentSession[], id: string) => RecentSession[];
27
+ export declare function upsertItem(items: readonly SavedItem[], name: string, query: string, at?: number): SavedItem[];
28
+ export declare const removeItem: (items: readonly SavedItem[], name: string) => SavedItem[];
29
+ export declare function renameItem(items: readonly SavedItem[], from: string, to: string): SavedItem[];
30
+ export declare function suggestItemName(items: readonly SavedItem[], base: string, fallback: string): string;
@@ -0,0 +1,109 @@
1
+ export const ITEM_MAX = 200;
2
+ export const QUERY_MAX = 8000;
3
+ export const ITEM_NAME_MAX = 40;
4
+ export const newItemId = () => Math.random().toString(36).slice(2, 10);
5
+ const num = (v) => typeof v === 'number' && Number.isFinite(v) ? v : undefined;
6
+ export const cleanItemName = (raw) => raw.replaceAll(/\s+/g, ' ').trim().slice(0, ITEM_NAME_MAX).trim();
7
+ function readItem(raw) {
8
+ if (typeof raw !== 'object' || raw === null)
9
+ return undefined;
10
+ const name = 'name' in raw ? raw.name : undefined;
11
+ const query = 'query' in raw ? raw.query : undefined;
12
+ if (typeof name !== 'string' || typeof query !== 'string')
13
+ return undefined;
14
+ if (query.length > QUERY_MAX)
15
+ return undefined;
16
+ const clean = cleanItemName(name);
17
+ if (clean === '')
18
+ return undefined;
19
+ const id = 'id' in raw && typeof raw.id === 'string' ? raw.id : undefined;
20
+ const savedAt = 'savedAt' in raw ? num(raw.savedAt) : undefined;
21
+ return {
22
+ name: clean,
23
+ query,
24
+ ...(id === undefined ? {} : { id }),
25
+ ...(savedAt === undefined ? {} : { savedAt }),
26
+ };
27
+ }
28
+ export const readItems = (raw) => (Array.isArray(raw) ? raw : [])
29
+ .flatMap(item => {
30
+ const parsed = readItem(item);
31
+ return parsed === undefined ? [] : [parsed];
32
+ })
33
+ .slice(0, ITEM_MAX);
34
+ export function readCurrent(raw) {
35
+ if (typeof raw !== 'object' || raw === null)
36
+ return null;
37
+ const query = 'query' in raw ? raw.query : undefined;
38
+ const at = 'at' in raw ? num(raw.at) : undefined;
39
+ if (typeof query !== 'string' || query.length > QUERY_MAX)
40
+ return null;
41
+ return at === undefined ? null : { query, at };
42
+ }
43
+ export function readRecent(raw, recentMax, legacy) {
44
+ if (!Array.isArray(raw)) {
45
+ const current = readCurrent(legacy);
46
+ return current === null ? [] : [{ id: '', ...current }];
47
+ }
48
+ const seen = new Set();
49
+ return raw
50
+ .flatMap(item => {
51
+ const session = readCurrent(item);
52
+ const id = typeof item === 'object' && item !== null && 'id' in item
53
+ ? item.id
54
+ : undefined;
55
+ if (session === null || typeof id !== 'string' || seen.has(id))
56
+ return [];
57
+ seen.add(id);
58
+ return [{ id, ...session }];
59
+ })
60
+ .slice(0, recentMax);
61
+ }
62
+ export function pushRecent(recent, entry, recentMax) {
63
+ const next = [
64
+ entry,
65
+ ...recent.filter(s => s.id !== entry.id && s.query !== entry.query),
66
+ ].slice(0, recentMax);
67
+ const kept = new Set(next.map(s => s.id));
68
+ return {
69
+ recent: next,
70
+ dropped: recent.flatMap(s => (kept.has(s.id) ? [] : [s.id])),
71
+ };
72
+ }
73
+ export const removeRecent = (recent, id) => recent.filter(item => item.id !== id);
74
+ export function upsertItem(items, name, query, at) {
75
+ const clean = cleanItemName(name);
76
+ if (clean === '')
77
+ return [...items];
78
+ const index = items.findIndex(item => item.name === clean);
79
+ const prior = index === -1 ? undefined : items[index];
80
+ const entry = { name: clean, query };
81
+ if (prior?.id !== undefined)
82
+ entry.id = prior.id;
83
+ if (at !== undefined) {
84
+ entry.id ??= newItemId();
85
+ entry.savedAt = at;
86
+ }
87
+ if (index !== -1)
88
+ return items.map((item, i) => (i === index ? entry : item));
89
+ return [...items, entry].slice(-ITEM_MAX);
90
+ }
91
+ export const removeItem = (items, name) => items.filter(item => item.name !== name);
92
+ export function renameItem(items, from, to) {
93
+ const clean = cleanItemName(to);
94
+ if (clean === '' || items.some(item => item.name === clean))
95
+ return [...items];
96
+ return items.map(item => item.name === from ? { ...item, name: clean } : item);
97
+ }
98
+ export function suggestItemName(items, base, fallback) {
99
+ const clean = cleanItemName(base);
100
+ const stem = clean === '' ? fallback : clean;
101
+ if (!items.some(item => item.name === stem))
102
+ return stem;
103
+ for (let n = 2; n < 1000; n++) {
104
+ const candidate = `${stem} ${n}`;
105
+ if (!items.some(item => item.name === candidate))
106
+ return candidate;
107
+ }
108
+ return stem;
109
+ }
@@ -0,0 +1,9 @@
1
+ export type NavLink = readonly [href: string, label: string];
2
+ export interface SiteInfo {
3
+ name: string;
4
+ home: string;
5
+ icon: string;
6
+ app: string;
7
+ nav: readonly NavLink[];
8
+ footer: readonly NavLink[];
9
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillet-shared",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Code shared by bender and videoskillet",
5
5
  "license": "MIT",
6
6
  "author": "Colin Diesh",
@@ -31,10 +31,30 @@
31
31
  "types": "./dist/wire-route.d.ts",
32
32
  "default": "./dist/wire-route.js"
33
33
  },
34
- "./tokens.css": "./dist/tokens.css"
34
+ "./tokens.css": "./dist/tokens.css",
35
+ "./site-info": {
36
+ "types": "./dist/site-info.d.ts",
37
+ "default": "./dist/site-info.js"
38
+ },
39
+ "./components/SiteBar.astro": "./components/SiteBar.astro",
40
+ "./components/Footer.astro": "./components/Footer.astro",
41
+ "./components/SocialMeta.astro": "./components/SocialMeta.astro",
42
+ "./components/SignedInHint.astro": "./components/SignedInHint.astro",
43
+ "./site.css": "./css/site.css",
44
+ "./footer.css": "./css/footer.css",
45
+ "./saved-list": {
46
+ "types": "./dist/saved-list.d.ts",
47
+ "default": "./dist/saved-list.js"
48
+ },
49
+ "./cloud": {
50
+ "types": "./dist/cloud.d.ts",
51
+ "default": "./dist/cloud.js"
52
+ }
35
53
  },
36
54
  "files": [
37
- "dist"
55
+ "dist",
56
+ "components",
57
+ "css"
38
58
  ],
39
59
  "publishConfig": {
40
60
  "access": "public"
@@ -49,11 +69,13 @@
49
69
  "postversion": "git push --follow-tags"
50
70
  },
51
71
  "peerDependencies": {
52
- "astro": "^7.4.2"
72
+ "astro": "^7.4.2",
73
+ "firebase": "^13.1.0"
53
74
  },
54
75
  "devDependencies": {
55
76
  "@types/node": "^26.6.5",
56
77
  "astro": "^7.4.2",
78
+ "firebase": "13.1.0",
57
79
  "typescript": "^7.0.2",
58
80
  "vitest": "^5.0.5"
59
81
  }