From 17e1f1f7735839e739dde99fab4f980caefd721e Mon Sep 17 00:00:00 2001 From: Erik Brinkman Date: Wed, 7 Oct 2026 08:46:53 -0400 Subject: [PATCH] refactor(web)!: rewrite in svelte --- .github/workflows/ci.yml | 2 +- .github/workflows/web.yml | 2 +- CLAUDE.md | 416 ++-- README.md | 5 +- functions/src/index.ts | 2 +- web/.gitignore | 16 +- web/app/about/page.tsx | 76 - web/app/continue/page.tsx | 331 --- web/app/error.tsx | 40 - web/app/help/page.tsx | 81 - web/app/layout.tsx | 93 - web/app/manifest.ts | 38 - web/app/page.tsx | 304 --- web/app/portal/page.tsx | 976 --------- web/app/privacy/page.tsx | 339 --- web/app/terms/page.tsx | 199 -- web/biome.json | 62 +- web/bun.lock | 469 +++-- web/components/add-dates-sheet.tsx | 208 -- web/components/auth-menu.tsx | 255 --- web/components/auth-panel.tsx | 206 -- web/components/avatar.tsx | 47 - web/components/booking-page.tsx | 454 ---- web/components/booking-row.tsx | 122 -- web/components/browse-view.tsx | 324 --- web/components/checkout-field.tsx | 36 - web/components/cover-photo.tsx | 212 -- web/components/deletion-screen.tsx | 134 -- web/components/dialog.tsx | 174 -- web/components/doc-page.tsx | 75 - web/components/emulator-badge.tsx | 18 - web/components/feedback-sheet.tsx | 104 - web/components/feedback-view.tsx | 155 -- web/components/friends-panel.tsx | 256 --- web/components/home-view.tsx | 356 ---- web/components/listing-form-screen.tsx | 137 -- web/components/listing-form.tsx | 337 --- web/components/name-gate.tsx | 409 ---- web/components/nav.tsx | 143 -- web/components/person-page.tsx | 676 ------ web/components/photo-strip.tsx | 373 ---- web/components/place-card.tsx | 117 -- web/components/places-view.tsx | 170 -- web/components/reach-field.tsx | 209 -- web/components/request-card.tsx | 257 --- web/components/room-page.tsx | 1054 ---------- web/components/room-sheet.tsx | 447 ---- web/components/saved-search-row.tsx | 45 - web/components/saved-searches.tsx | 185 -- web/components/settings-view.tsx | 1059 ---------- web/components/share-link.tsx | 140 -- web/components/site-footer.tsx | 44 - web/components/slot-row.tsx | 153 -- web/components/theme-button.tsx | 35 - web/components/theme-color.tsx | 27 - web/components/trips-view.tsx | 93 - web/components/ui/busy.tsx | 13 - web/components/ui/button.tsx | 48 - web/components/ui/chip.tsx | 62 - web/components/ui/code-input.tsx | 112 - web/components/ui/field-note.tsx | 24 - web/components/ui/icon-button.tsx | 37 - web/components/ui/input.tsx | 68 - web/components/ui/list.tsx | 84 - web/components/ui/segmented.tsx | 49 - web/components/ui/sheet.tsx | 116 -- web/components/ui/switch.tsx | 71 - web/components/use-stay-place.ts | 75 - web/components/welcome-screen.tsx | 32 - web/components/wordmark.tsx | 39 - web/next.config.js | 35 - web/package.json | 48 +- web/postcss.config.js | 1 - web/scripts/consent-check.mjs | 4 +- web/scripts/exif-check.mjs | 2 +- web/scripts/feedback-check.mjs | 2 +- web/scripts/host-check.mjs | 2 +- web/scripts/portal-check.mjs | 6 +- web/scripts/pwa-check.mjs | 25 +- web/{app/globals.css => src/app.css} | 60 +- web/src/app.d.ts | 17 + web/src/app.html | 28 + web/src/hooks.server.ts | 33 + web/{utils => src/lib}/auth.ts | 6 +- web/src/lib/base.ts | 5 + web/{utils => src/lib}/bookings.ts | 2 - web/{utils => src/lib}/checkout.ts | 0 .../lib/components/abandoned-photos.svelte.ts | 30 + web/src/lib/components/account-section.svelte | 104 + web/src/lib/components/add-dates-sheet.svelte | 206 ++ web/src/lib/components/auth-menu.svelte | 256 +++ web/src/lib/components/auth-panel.svelte | 192 ++ web/src/lib/components/avatar.svelte | 51 + web/src/lib/components/booking-page.svelte | 454 ++++ web/src/lib/components/booking-row.svelte | 132 ++ web/src/lib/components/browse-view.svelte | 75 + web/src/lib/components/checkout-field.svelte | 31 + web/src/lib/components/cover-photo.svelte | 30 + web/src/lib/components/deletion-screen.svelte | 61 + .../lib/components/deletion-stalled.svelte | 56 + web/src/lib/components/dialog.svelte | 128 ++ .../components/discoverability-section.svelte | 134 ++ web/src/lib/components/doc-card.svelte | 9 + web/src/lib/components/doc-h2.svelte | 7 + web/src/lib/components/doc-list.svelte | 9 + web/src/lib/components/doc-p.svelte | 7 + web/src/lib/components/doc-page.svelte | 64 + web/src/lib/components/door-row.svelte | 52 + web/src/lib/components/doors-section.svelte | 409 ++++ .../lib/components/draft-room-sheet.svelte | 102 + web/src/lib/components/editable-name.svelte | 111 + web/src/lib/components/emulator-badge.svelte | 17 + web/src/lib/components/feedback-sheet.svelte | 90 + web/src/lib/components/feedback-view.svelte | 157 ++ web/src/lib/components/filter-sheet.svelte | 226 ++ web/src/lib/components/floating-dock.svelte | 41 + web/src/lib/components/friends-panel.svelte | 255 +++ web/src/lib/components/home-view.svelte | 310 +++ .../lib/components/listing-form-screen.svelte | 118 ++ web/src/lib/components/listing-form.svelte | 345 +++ web/src/lib/components/listing-row.svelte | 77 + web/src/lib/components/mark.svelte | 6 + web/src/lib/components/name-gate.svelte | 382 ++++ web/src/lib/components/nav-items.ts | 44 + .../components/notifications-section.svelte | 335 +++ web/src/lib/components/offer-heading.svelte | 32 + web/src/lib/components/offer.svelte | 23 + web/src/lib/components/past-slot-row.svelte | 54 + web/src/lib/components/person-page.svelte | 351 ++++ web/src/lib/components/photo-gallery.svelte | 122 ++ web/src/lib/components/photo-strip.svelte | 379 ++++ web/src/lib/components/place-card.svelte | 121 ++ web/src/lib/components/places-view.svelte | 107 + web/src/lib/components/profile-photo.svelte | 100 + .../pwa.tsx => src/lib/components/pwa.svelte} | 27 +- web/src/lib/components/rail-fade.svelte.ts | 52 + web/src/lib/components/reach-field.svelte | 104 + web/src/lib/components/request-card.svelte | 249 +++ web/src/lib/components/room-detail.svelte | 42 + web/src/lib/components/room-fields.svelte | 98 + .../lib/components/room-friend-view.svelte | 115 + web/src/lib/components/room-owner-view.svelte | 347 +++ web/src/lib/components/room-page.svelte | 63 + web/src/lib/components/room-row.svelte | 30 + web/src/lib/components/room-sheet.svelte | 208 ++ web/src/lib/components/room-thumb.svelte | 18 + .../rooms.tsx => src/lib/components/rooms.ts} | 113 +- .../lib/components/saved-search-row.svelte | 43 + web/src/lib/components/saved-searches.svelte | 173 ++ web/src/lib/components/self-identity.svelte | 165 ++ web/src/lib/components/settings-view.svelte | 35 + web/src/lib/components/share-link.svelte | 142 ++ web/src/lib/components/site-footer.svelte | 40 + web/src/lib/components/slot-row.svelte | 147 ++ web/src/lib/components/slot-sheet.svelte | 312 +++ .../lib/components/slot-summary-row.svelte | 76 + web/src/lib/components/theme-button.svelte | 25 + web/src/lib/components/theme-color.svelte | 20 + web/src/lib/components/top-bar.svelte | 47 + web/src/lib/components/trips-view.svelte | 87 + web/src/lib/components/ui/busy.svelte | 10 + web/src/lib/components/ui/button.svelte | 52 + web/src/lib/components/ui/chip.svelte | 53 + web/src/lib/components/ui/code-input.svelte | 122 ++ web/src/lib/components/ui/count-badge.svelte | 13 + web/src/lib/components/ui/field-note.svelte | 24 + web/src/lib/components/ui/group.svelte | 17 + web/src/lib/components/ui/icon-button.svelte | 37 + web/src/lib/components/ui/input.svelte | 67 + web/src/lib/components/ui/row.svelte | 31 + .../lib/components/ui/section-heading.svelte | 21 + web/src/lib/components/ui/section.svelte | 29 + web/src/lib/components/ui/segmented.svelte | 42 + web/src/lib/components/ui/sheet-panel.svelte | 103 + web/src/lib/components/ui/sheet.svelte | 44 + web/src/lib/components/ui/switch.svelte | 67 + web/src/lib/components/ui/textarea.svelte | 18 + .../lib/components/use-leave.svelte.ts} | 57 +- .../lib/components/use-stay-place.svelte.ts | 83 + .../lib/components/verify-email-prompt.svelte | 37 + .../lib/components/visible-stays.svelte.ts | 72 + web/src/lib/components/welcome-screen.svelte | 31 + web/src/lib/components/wordmark.svelte | 25 + web/{utils => src/lib}/contact.ts | 0 web/{utils => src/lib}/debug.ts | 2 - web/{utils => src/lib}/destination.ts | 2 +- web/{utils => src/lib}/feedback.ts | 2 - web/{utils => src/lib}/firebase.ts | 35 +- web/{utils => src/lib}/format.ts | 0 web/{utils => src/lib}/friends.ts | 2 - web/{utils => src/lib}/geocode.ts | 2 - .../install.ts => src/lib/install.svelte.ts} | 101 +- web/{utils => src/lib}/leave.ts | 2 - web/src/lib/listing-icons.ts | 22 + web/{utils => src/lib}/listings.ts | 15 - web/{utils => src/lib}/photos.ts | 8 +- web/{utils => src/lib}/portals.ts | 2 - web/{utils => src/lib}/profile-gate.ts | 2 +- web/src/lib/reach.ts | 103 + web/{utils => src/lib}/reattach.ts | 2 +- web/{utils => src/lib}/requests.ts | 2 - web/{utils => src/lib}/rooms.ts | 0 web/src/lib/screens.ts | 100 + web/{utils => src/lib}/search.ts | 2 - web/{utils => src/lib}/searches.ts | 2 - web/{utils => src/lib}/settings.ts | 2 - web/{utils => src/lib}/sms.ts | 0 web/src/lib/store.svelte.ts | 1686 +++++++++++++++ web/src/lib/theme.svelte.ts | 97 + web/{utils => src/lib}/theme.ts | 4 +- web/{utils => src/lib}/types.ts | 0 web/{utils => src/lib}/username.ts | 2 - web/src/routes/(app)/+layout.svelte | 26 + web/src/routes/(app)/+page.svelte | 286 +++ web/src/routes/(app)/continue/+page.svelte | 316 +++ web/src/routes/(app)/portal/+page.svelte | 485 +++++ web/src/routes/(app)/portal/ask.ts | 58 + .../routes/(app)/portal/listing-block.svelte | 139 ++ web/src/routes/(app)/portal/name-form.svelte | 215 ++ .../routes/(app)/portal/portal-view.svelte | 146 ++ web/src/routes/(docs)/about/+page.svelte | 61 + web/src/routes/(docs)/help/+page.svelte | 62 + web/src/routes/(docs)/privacy/+page.svelte | 313 +++ web/src/routes/(docs)/terms/+page.svelte | 172 ++ web/src/routes/+error.svelte | 47 + web/src/routes/+layout.svelte | 61 + web/src/routes/+layout.ts | 4 + .../routes/manifest.webmanifest/+server.ts | 42 + web/{public => static}/apple-touch-icon.png | Bin web/{public => static}/icon-192.png | Bin web/{public => static}/icon-512.png | Bin web/{public => static}/icon-maskable-512.png | Bin web/{app => static}/icon.svg | 0 web/sw/sw.ts | 2 +- web/tailwind.config.js | 4 - web/tests/auth-copy.test.ts | 56 +- web/tests/checkout.test.ts | 2 +- web/tests/deletion.test.ts | 2 +- web/tests/destination.test.ts | 2 +- web/tests/drift.test.ts | 6 +- web/tests/feedback.test.ts | 4 +- web/tests/format.test.ts | 2 +- web/tests/notifications.test.ts | 2 +- web/tests/profile-gate.test.ts | 4 +- web/tests/reattach.test.ts | 2 +- web/tests/rooms.test.ts | 4 +- web/tests/rules.test.ts | 2 +- web/tests/search.test.ts | 4 +- web/tests/slot-verdict.test.ts | 2 +- web/tests/sms.test.ts | 2 +- web/tsconfig.json | 42 +- web/tsconfig.sw.json | 2 +- web/utils/store.tsx | 1856 ----------------- web/vite.config.ts | 59 + 254 files changed, 15416 insertions(+), 15315 deletions(-) delete mode 100644 web/app/about/page.tsx delete mode 100644 web/app/continue/page.tsx delete mode 100644 web/app/error.tsx delete mode 100644 web/app/help/page.tsx delete mode 100644 web/app/layout.tsx delete mode 100644 web/app/manifest.ts delete mode 100644 web/app/page.tsx delete mode 100644 web/app/portal/page.tsx delete mode 100644 web/app/privacy/page.tsx delete mode 100644 web/app/terms/page.tsx delete mode 100644 web/components/add-dates-sheet.tsx delete mode 100644 web/components/auth-menu.tsx delete mode 100644 web/components/auth-panel.tsx delete mode 100644 web/components/avatar.tsx delete mode 100644 web/components/booking-page.tsx delete mode 100644 web/components/booking-row.tsx delete mode 100644 web/components/browse-view.tsx delete mode 100644 web/components/checkout-field.tsx delete mode 100644 web/components/cover-photo.tsx delete mode 100644 web/components/deletion-screen.tsx delete mode 100644 web/components/dialog.tsx delete mode 100644 web/components/doc-page.tsx delete mode 100644 web/components/emulator-badge.tsx delete mode 100644 web/components/feedback-sheet.tsx delete mode 100644 web/components/feedback-view.tsx delete mode 100644 web/components/friends-panel.tsx delete mode 100644 web/components/home-view.tsx delete mode 100644 web/components/listing-form-screen.tsx delete mode 100644 web/components/listing-form.tsx delete mode 100644 web/components/name-gate.tsx delete mode 100644 web/components/nav.tsx delete mode 100644 web/components/person-page.tsx delete mode 100644 web/components/photo-strip.tsx delete mode 100644 web/components/place-card.tsx delete mode 100644 web/components/places-view.tsx delete mode 100644 web/components/reach-field.tsx delete mode 100644 web/components/request-card.tsx delete mode 100644 web/components/room-page.tsx delete mode 100644 web/components/room-sheet.tsx delete mode 100644 web/components/saved-search-row.tsx delete mode 100644 web/components/saved-searches.tsx delete mode 100644 web/components/settings-view.tsx delete mode 100644 web/components/share-link.tsx delete mode 100644 web/components/site-footer.tsx delete mode 100644 web/components/slot-row.tsx delete mode 100644 web/components/theme-button.tsx delete mode 100644 web/components/theme-color.tsx delete mode 100644 web/components/trips-view.tsx delete mode 100644 web/components/ui/busy.tsx delete mode 100644 web/components/ui/button.tsx delete mode 100644 web/components/ui/chip.tsx delete mode 100644 web/components/ui/code-input.tsx delete mode 100644 web/components/ui/field-note.tsx delete mode 100644 web/components/ui/icon-button.tsx delete mode 100644 web/components/ui/input.tsx delete mode 100644 web/components/ui/list.tsx delete mode 100644 web/components/ui/segmented.tsx delete mode 100644 web/components/ui/sheet.tsx delete mode 100644 web/components/ui/switch.tsx delete mode 100644 web/components/use-stay-place.ts delete mode 100644 web/components/welcome-screen.tsx delete mode 100644 web/components/wordmark.tsx delete mode 100644 web/next.config.js delete mode 100644 web/postcss.config.js rename web/{app/globals.css => src/app.css} (68%) create mode 100644 web/src/app.d.ts create mode 100644 web/src/app.html create mode 100644 web/src/hooks.server.ts rename web/{utils => src/lib}/auth.ts (99%) create mode 100644 web/src/lib/base.ts rename web/{utils => src/lib}/bookings.ts (99%) rename web/{utils => src/lib}/checkout.ts (100%) create mode 100644 web/src/lib/components/abandoned-photos.svelte.ts create mode 100644 web/src/lib/components/account-section.svelte create mode 100644 web/src/lib/components/add-dates-sheet.svelte create mode 100644 web/src/lib/components/auth-menu.svelte create mode 100644 web/src/lib/components/auth-panel.svelte create mode 100644 web/src/lib/components/avatar.svelte create mode 100644 web/src/lib/components/booking-page.svelte create mode 100644 web/src/lib/components/booking-row.svelte create mode 100644 web/src/lib/components/browse-view.svelte create mode 100644 web/src/lib/components/checkout-field.svelte create mode 100644 web/src/lib/components/cover-photo.svelte create mode 100644 web/src/lib/components/deletion-screen.svelte create mode 100644 web/src/lib/components/deletion-stalled.svelte create mode 100644 web/src/lib/components/dialog.svelte create mode 100644 web/src/lib/components/discoverability-section.svelte create mode 100644 web/src/lib/components/doc-card.svelte create mode 100644 web/src/lib/components/doc-h2.svelte create mode 100644 web/src/lib/components/doc-list.svelte create mode 100644 web/src/lib/components/doc-p.svelte create mode 100644 web/src/lib/components/doc-page.svelte create mode 100644 web/src/lib/components/door-row.svelte create mode 100644 web/src/lib/components/doors-section.svelte create mode 100644 web/src/lib/components/draft-room-sheet.svelte create mode 100644 web/src/lib/components/editable-name.svelte create mode 100644 web/src/lib/components/emulator-badge.svelte create mode 100644 web/src/lib/components/feedback-sheet.svelte create mode 100644 web/src/lib/components/feedback-view.svelte create mode 100644 web/src/lib/components/filter-sheet.svelte create mode 100644 web/src/lib/components/floating-dock.svelte create mode 100644 web/src/lib/components/friends-panel.svelte create mode 100644 web/src/lib/components/home-view.svelte create mode 100644 web/src/lib/components/listing-form-screen.svelte create mode 100644 web/src/lib/components/listing-form.svelte create mode 100644 web/src/lib/components/listing-row.svelte create mode 100644 web/src/lib/components/mark.svelte create mode 100644 web/src/lib/components/name-gate.svelte create mode 100644 web/src/lib/components/nav-items.ts create mode 100644 web/src/lib/components/notifications-section.svelte create mode 100644 web/src/lib/components/offer-heading.svelte create mode 100644 web/src/lib/components/offer.svelte create mode 100644 web/src/lib/components/past-slot-row.svelte create mode 100644 web/src/lib/components/person-page.svelte create mode 100644 web/src/lib/components/photo-gallery.svelte create mode 100644 web/src/lib/components/photo-strip.svelte create mode 100644 web/src/lib/components/place-card.svelte create mode 100644 web/src/lib/components/places-view.svelte create mode 100644 web/src/lib/components/profile-photo.svelte rename web/{components/pwa.tsx => src/lib/components/pwa.svelte} (55%) create mode 100644 web/src/lib/components/rail-fade.svelte.ts create mode 100644 web/src/lib/components/reach-field.svelte create mode 100644 web/src/lib/components/request-card.svelte create mode 100644 web/src/lib/components/room-detail.svelte create mode 100644 web/src/lib/components/room-fields.svelte create mode 100644 web/src/lib/components/room-friend-view.svelte create mode 100644 web/src/lib/components/room-owner-view.svelte create mode 100644 web/src/lib/components/room-page.svelte create mode 100644 web/src/lib/components/room-row.svelte create mode 100644 web/src/lib/components/room-sheet.svelte create mode 100644 web/src/lib/components/room-thumb.svelte rename web/{components/rooms.tsx => src/lib/components/rooms.ts} (50%) create mode 100644 web/src/lib/components/saved-search-row.svelte create mode 100644 web/src/lib/components/saved-searches.svelte create mode 100644 web/src/lib/components/self-identity.svelte create mode 100644 web/src/lib/components/settings-view.svelte create mode 100644 web/src/lib/components/share-link.svelte create mode 100644 web/src/lib/components/site-footer.svelte create mode 100644 web/src/lib/components/slot-row.svelte create mode 100644 web/src/lib/components/slot-sheet.svelte create mode 100644 web/src/lib/components/slot-summary-row.svelte create mode 100644 web/src/lib/components/theme-button.svelte create mode 100644 web/src/lib/components/theme-color.svelte create mode 100644 web/src/lib/components/top-bar.svelte create mode 100644 web/src/lib/components/trips-view.svelte create mode 100644 web/src/lib/components/ui/busy.svelte create mode 100644 web/src/lib/components/ui/button.svelte create mode 100644 web/src/lib/components/ui/chip.svelte create mode 100644 web/src/lib/components/ui/code-input.svelte create mode 100644 web/src/lib/components/ui/count-badge.svelte create mode 100644 web/src/lib/components/ui/field-note.svelte create mode 100644 web/src/lib/components/ui/group.svelte create mode 100644 web/src/lib/components/ui/icon-button.svelte create mode 100644 web/src/lib/components/ui/input.svelte create mode 100644 web/src/lib/components/ui/row.svelte create mode 100644 web/src/lib/components/ui/section-heading.svelte create mode 100644 web/src/lib/components/ui/section.svelte create mode 100644 web/src/lib/components/ui/segmented.svelte create mode 100644 web/src/lib/components/ui/sheet-panel.svelte create mode 100644 web/src/lib/components/ui/sheet.svelte create mode 100644 web/src/lib/components/ui/switch.svelte create mode 100644 web/src/lib/components/ui/textarea.svelte rename web/{components/use-leave.ts => src/lib/components/use-leave.svelte.ts} (58%) create mode 100644 web/src/lib/components/use-stay-place.svelte.ts create mode 100644 web/src/lib/components/verify-email-prompt.svelte create mode 100644 web/src/lib/components/visible-stays.svelte.ts create mode 100644 web/src/lib/components/welcome-screen.svelte create mode 100644 web/src/lib/components/wordmark.svelte rename web/{utils => src/lib}/contact.ts (100%) rename web/{utils => src/lib}/debug.ts (99%) rename web/{utils => src/lib}/destination.ts (96%) rename web/{utils => src/lib}/feedback.ts (99%) rename web/{utils => src/lib}/firebase.ts (84%) rename web/{utils => src/lib}/format.ts (100%) rename web/{utils => src/lib}/friends.ts (99%) rename web/{utils => src/lib}/geocode.ts (99%) rename web/{utils/install.ts => src/lib/install.svelte.ts} (51%) rename web/{utils => src/lib}/leave.ts (99%) create mode 100644 web/src/lib/listing-icons.ts rename web/{utils => src/lib}/listings.ts (98%) rename web/{utils => src/lib}/photos.ts (93%) rename web/{utils => src/lib}/portals.ts (99%) rename web/{utils => src/lib}/profile-gate.ts (98%) create mode 100644 web/src/lib/reach.ts rename web/{utils => src/lib}/reattach.ts (96%) rename web/{utils => src/lib}/requests.ts (99%) rename web/{utils => src/lib}/rooms.ts (100%) create mode 100644 web/src/lib/screens.ts rename web/{utils => src/lib}/search.ts (99%) rename web/{utils => src/lib}/searches.ts (99%) rename web/{utils => src/lib}/settings.ts (99%) rename web/{utils => src/lib}/sms.ts (100%) create mode 100644 web/src/lib/store.svelte.ts create mode 100644 web/src/lib/theme.svelte.ts rename web/{utils => src/lib}/theme.ts (77%) rename web/{utils => src/lib}/types.ts (100%) rename web/{utils => src/lib}/username.ts (99%) create mode 100644 web/src/routes/(app)/+layout.svelte create mode 100644 web/src/routes/(app)/+page.svelte create mode 100644 web/src/routes/(app)/continue/+page.svelte create mode 100644 web/src/routes/(app)/portal/+page.svelte create mode 100644 web/src/routes/(app)/portal/ask.ts create mode 100644 web/src/routes/(app)/portal/listing-block.svelte create mode 100644 web/src/routes/(app)/portal/name-form.svelte create mode 100644 web/src/routes/(app)/portal/portal-view.svelte create mode 100644 web/src/routes/(docs)/about/+page.svelte create mode 100644 web/src/routes/(docs)/help/+page.svelte create mode 100644 web/src/routes/(docs)/privacy/+page.svelte create mode 100644 web/src/routes/(docs)/terms/+page.svelte create mode 100644 web/src/routes/+error.svelte create mode 100644 web/src/routes/+layout.svelte create mode 100644 web/src/routes/+layout.ts create mode 100644 web/src/routes/manifest.webmanifest/+server.ts rename web/{public => static}/apple-touch-icon.png (100%) rename web/{public => static}/icon-192.png (100%) rename web/{public => static}/icon-512.png (100%) rename web/{public => static}/icon-maskable-512.png (100%) rename web/{app => static}/icon.svg (100%) delete mode 100644 web/tailwind.config.js delete mode 100644 web/utils/store.tsx create mode 100644 web/vite.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7de4817..f1b791b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: - uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.14 + bun-version: 1.4.2 # The Firestore rules emulator (test:rules) runs on the JVM. - uses: actions/setup-java@v6 diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index 33a0c05..8e884f3 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -59,7 +59,7 @@ jobs: - uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.14 + bun-version: 1.4.2 - run: bun install --frozen-lockfile diff --git a/CLAUDE.md b/CLAUDE.md index 05f681e..fccc9a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # kip Friends-only space sharing: list a spare room or your whole place, mark when it's free, and let -mutual friends book it for nothing. Monorepo with a Next.js web client (`web/`) and shared +mutual friends book it for nothing. Monorepo with a SvelteKit web client (`web/`) and shared Firebase rules (`firebase/`); native mobile apps are a later phase. User-facing docs live in [README.md](./README.md); this file is for contributors and AI sessions. @@ -79,7 +79,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage `friends` subcollections. Visibility checks ("can this user see this listing?") are then a single `exists(/users/$(ownerId)/friends/$(uid))` in rules — no graph traversal, no server. Accepting a request is one `writeBatch`: write both edges + delete the request, so we never - persist a half-formed friendship (`utils/friends.ts` `acceptRequest`). Either party deleting + persist a half-formed friendship (`src/lib/friends.ts` `acceptRequest`). Either party deleting unfriends both sides. - **Friend-request id is `${from}_${to}`.** Deterministic, so the rule authorizing the accepter to write into the requester's `friends` subcollection can look the request up by id, and @@ -87,7 +87,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage - **Public share links = capability-URL portals.** Making something public mints a `portals/{uuid}` doc whose id IS an unguessable UUID; knowing it is the capability. World-readable BY ID only (`get: if true`, no `list` → not enumerable), revoke = delete, regenerate = new uuid + delete old - (kills every old link). Four scopes share one spine (`utils/portals.ts`; the fourth, **ROOM**, is described under *Rooms in a place*): **USER** (all your + (kills every old link). Four scopes share one spine (`src/lib/portals.ts`; the fourth, **ROOM**, is described under *Rooms in a place*): **USER** (all your places, id in `prefs.profilePortalId`, control on your own PersonPage), **LISTING** (`listing.publicPortalId`, control in the RoomPage owner view's Sharing section), **SLOT** (`window.publicPortalId`, control in the per-slot Sheet on that same page). @@ -134,7 +134,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage `onOwner` callback that fires the moment the portal doc lands, and the page paints the host block then, with skeletons where the rooms will be. Skeletons and not "Nothing shared here right now" — that line is a statement about an empty link, and saying it to someone whose rooms are still in - flight is worse than saying nothing. `layout.tsx` also preconnects the four Firebase hosts, since a + flight is worse than saying nothing. `src/routes/+layout.svelte` also preconnects the four Firebase hosts, since a stranger opening a link pays DNS and TLS for each of them with nothing warmed. Because a grant needs an identity, the share-link page signs visitors in **anonymously** on load @@ -144,7 +144,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage **Two rules keep that from eating a real session**, and both were learned the hard way — opening a link you'd been sent while signed in silently replaced your account with an empty anonymous one and then asked you to pick a display name. - 1. `ensureAnonymous` awaits `authSettled()` (`utils/auth.ts`) before deciding. Firebase restores a + 1. `ensureAnonymous` awaits `authSettled()` (`src/lib/auth.ts`) before deciding. Firebase restores a persisted session ASYNCHRONOUSLY, so `auth().currentUser` is null for a beat after load even for someone who is signed in; reading it directly mistakes them for a stranger. @@ -157,7 +157,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage 2. **An anonymous session is a participant, not a signed-out one.** This USED to gate on `!user || anonymous`, on the reasoning that a ticket is not an account. It no longer holds: an anonymous account can carry a display name, a live ask and friendships, and every rule it - meets is blind to how it signed in. `app/page.tsx` gates on `!user` alone. What replaced the + meets is blind to how it signed in. `src/routes/(app)/+page.svelte` gates on `!user` alone. What replaced the old reasoning is `displayName` — `AuthMenu` hides for an ANONYMOUS session until there is one, because a nameless visitor has no profile to show. A nameless account with a credential keeps the menu, since Settings and the exit live only there and the name sheet can be dismissed. The @@ -170,17 +170,18 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage to `authStateSubscription` inside `if (this.lastNotifiedUid !== currentUid)`. Linking an anonymous account KEEPS the uid, so signing up from a share link fired nothing at all, and Firebase compounds it by mutating the `User` object **in place** (`Object.assign(user, updates)` in - `_reloadWithoutSaving`) — so even a `setUser(next)` would hand React the same reference and bail - out of the render. The visible bug: a visitor tapped "Ask to be friends", created an account in the + `_reloadWithoutSaving`) — so even assigning it to the store's `user` again hands Svelte the same + reference, which is no change at all, and nothing reading it re-runs. The visible bug: a visitor + tapped "Ask to be friends", created an account in the sheet, and the sheet stayed sitting over the button, because `identified` was still computing `false`. Reloading the page fixed it, which is why it looked intermittent. So the mutable fields the app branches on — `isAnonymous` and `emailVerified` — are **snapshotted - into store state** (`anonymous`, `emailVerified`) and read from there, never off `user`. Anything - that branches on a field Firebase can change without a uid change belongs in that pair; reading it - off `user` at render is the bug, not the exception. Watching the token also fires on hourly - refreshes, which is free — the `User` reference is unchanged, so `setUser` bails and no listener - effect re-runs. + into store state** (`anonymous`, `emailVerified`) by the listener itself and read from there, never + off `user`. Anything that branches on a field Firebase can change without a uid change belongs + beside that pair (`email`, `phone` and `doors` already do); reading it off `kip.user` is the bug, + not the exception. Watching the token also fires on hourly refreshes, which is free — the `User` + reference and every snapshot are unchanged, so no effect re-runs. **A tap on the share-link page is never a no-op, and that is a rule about the STATE MACHINE, not about one bug.** Tapping holds the ask in `ask` and lets an effect send it once the sender is @@ -224,14 +225,16 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage removing it brings the dead button straight back with every other check still green. `emailVerified` rides along for the same structural reason, but note what that does NOT fix: - nothing in `web/` calls `user.reload()`, the proactive token refresh doesn't reload either, and the - verification link opens on Firebase's own action origin, so **verifying in another tab and coming - back still leaves the Home prompt up until a reload**. Closing that would mean reloading the auth - user on focus or visibilitychange; the snapshot is what makes such a fix land on screen at all. + the store reloads the auth user on focus and visibilitychange only while the session is ANONYMOUS + (that is how an address attached from another browser arrives), the proactive token refresh doesn't + reload, and the verification link opens on Firebase's own action origin, so for an account that + already has a credential **verifying in another tab and coming back still leaves the Home prompt up + until a reload**. Closing that would mean widening that reload to unverified accounts; the snapshot + is what makes such a fix land on screen at all. **A uid-keyed capability must survive the visitor getting an account, and there are two halves to that.** Creating an account **links** the anonymous one (`linkWithPopup` / `linkWithCredential` in - `utils/auth.ts`), so the uid doesn't change and the grant still belongs to them. But linking is + `src/lib/auth.ts`), so the uid doesn't change and the grant still belongs to them. But linking is impossible when the credential already belongs to a real account — they're signing IN, not up — so that path falls back to a normal sign-in and the uid DOES change. Hence the second half: the grant is re-claimed at the point of use, immediately before the write that needs it. Either half alone @@ -243,7 +246,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage *distinct* paths its rule touches, not the number of documents returned. Two consequences: - **Clause order matters.** `isFriendOf` and the room/profile grants — one shared lookup each, covering a whole query — come BEFORE the per-document slot grant and guest marker. - - **Browse is chunked at 20 friends** (`BROWSE_CHUNK`, `utils/listings.ts`), NOT the 30 an `in` + - **Browse is chunked at 20 friends** (`BROWSE_CHUNK`, `src/lib/listings.ts`), NOT the 30 an `in` filter allows. Each friend's listing costs one `exists()` on their friends edge, so 25 distinct friends in one query is refused outright — 30 places across 3 friends is fine. This was a live bug (the chunk was 30), found by testing the limit rather than reasoning about it. 20 is the @@ -335,7 +338,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage 60 days after checkout, the same ISO parsing as `stillCurrent`), which needs no state machine: a pointer issued while a stay was fresh simply stops matching, with no sweep and nothing to revoke. The store's counterpart lookup mirrors it (`endedWithin` and `STAY_SIGHT_DAYS` in - `utils/format.ts`), in UTC like the rule rather than the local date the rest of the client uses, + `src/lib/format.ts`), in UTC like the rule rather than the local date the rest of the client uses, since its whole job is predicting what the rule will allow — a pointer it can't use would leave the name unresolved. @@ -410,7 +413,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage order-free by `bookingJoins`). That third one exists because a share-link guest and their host are the one pair who demonstrably know each other and the one pair neither of the others can serve — neither is searchable to the other, and neither holds the other's link. `${from}_${to}` is the - right key here: one pending friendship ask per pair. `utils/requests.ts` owns it. **A route that + right key here: one pending friendship ask per pair. `src/lib/requests.ts` owns it. **A route that is NAMED must hold even when another would have done**: a `portalId` or `bookingId` on the request is checked even for a searchable recipient, since the card says "via your link" or "you stayed together" on the strength of it. @@ -428,7 +431,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage only in the sender's own list, on a document only the two parties can read. Nobody to mislead. - **Discovery is opt-in, and a handle is optional.** A fresh account is **unreachable**: onboarding asks for a *display name only* — collected just in time by the identity sheet - (`components/name-gate.tsx`, and the portal page's own copy), never a blocking screen — and + (`src/lib/components/name-gate.svelte`, and the portal page's own copy), never a blocking screen — and nobody can initiate contact until you turn on one of **two independent avenues** in Settings → *Who can find you*: - **Searchable** (`users/{uid}.searchable`) — findable by handle. Requires a username, so the @@ -472,7 +475,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage what makes going private reversible: your name can't be released and re-squatted while you're unsearchable, so you can flip searchability back on and still be yourself. (The Settings claim flow confirms this before writing.) **Uniqueness is functionless:** `claimUsername` - (`utils/username.ts`) writes the registry entry and the profile in ONE batch; a collision hits the + (`src/lib/username.ts`) writes the registry entry and the profile in ONE batch; a collision hits the registry's owner-only `update` rule and is denied, taking the profile write down with it. **The batch is required, and it is what caps an account at one handle:** the registry refuses an entry the profile doesn't name once the commit lands (`getAfter`), and a profile's handle can only go @@ -533,11 +536,11 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage Opening a slot also **names it in the URL** (in place, so it adds nothing to go back through) — tapping a request pushes the booking on top of it, and back returns to the slot still open instead of a room that has forgotten which one it was. That is why a room's `windowId` is deliberately NOT - part of `screenKey` in `app/page.tsx`: a sheet is an overlay on the room, not a different screen, + part of `screenKey` in `src/routes/(app)/+page.svelte`: a sheet is an overlay on the room, not a different screen, and counting it scrolled the page under the sheet to the top on open and again on close. For the - same family of reason `replaceEntry` now carries `historyScroll()` forward — a replace changes what - an entry POINTS AT, not where the reader is standing in it, and defaulting to 0 meant every in-place - rewrite silently forgot the position. + same family of reason `replaceEntry` keeps the entry's id, which is what its scroll offset is + remembered under — a replace changes what an entry POINTS AT, not where the reader is standing in + it. **A slot holds at most one stay, and is born free.** `bookingMatchesOpenSlot` requires the slot to be `OPEN` as well as to hold the claimed dates, at BOTH ends of a stay's life — asking and @@ -558,7 +561,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage direction this schema has spent several passes escaping, and would add an invisible-but-live slot that still has to block overlaps and still backs a live share link. - Overlapping slots are rejected in the CLIENT (`findOverlap`, `utils/listings.ts`), not the rules — + Overlapping slots are rejected in the CLIENT (`findOverlap`, `src/lib/listings.ts`), not the rules — a rule can't query sibling documents, and the only person a clash hurts is the owner whose own calendar it is, so a crafted client gains nothing by skipping the check. `end` is exclusive, so ranges that merely touch are allowed. @@ -621,7 +624,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage after create: an ask is for a room, and moving the slot would move the ask. Ticking several rooms in the add-dates sheet writes one slot per room (`addRoomWindows`, one batch); the sheet opens with every room ticked. **A room and the whole place can't be free on the same nights, and that - is the client's check** (`findOverlap` in `utils/rooms.ts`), for the reason every overlap is: a + is the client's check** (`findOverlap` in `src/lib/rooms.ts`), for the reason every overlap is: a rule can't read sibling slots, and the only calendar it protects is the owner's own. A whole-place range clashes with everything; a room's clashes with that room's and the whole place's; two rooms overlap freely. Double booking is unaffected, since a slot still holds one stay. @@ -654,7 +657,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage cancelled as `SLOT_CANCELLED`, then the slots, their links, the room's link and photos, and last the map entry. Search treats a room's dates as type Room and whole-place dates as the listing's type, so a search for rooms finds a house with a free room. A booking carries no room: surfaces - that name one read it off the slot (`use-stay-place.ts`), and notification bodies say "Back + that name one read it off the slot (`use-stay-place.svelte.ts`), and notification bodies say "Back bedroom at Erik's house" when the slot and room can still be read — never the subject, which is also the text message and has no room for an uncapped name. - **Check-out instructions are their own documents, because they can hold a door code.** A host @@ -679,7 +682,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage cancel of a `CONFIRMED` stay reopens the window itself in the same batch (`BOOKED -> OPEN`, clear `bookingId`) — a rule clause lets the booker, and only the booker, do that release. A pending `REQUESTED` booking left the window `OPEN`, so guest cancel there just marks the booking - `CANCELLED` (see `utils/bookings.ts`). Windows carry a free-form `details` string (not tags). + `CANCELLED` (see `src/lib/bookings.ts`). Windows carry a free-form `details` string (not tags). **Taking or freeing a slot travels with the booking's own transition, in the same commit, for guest and host alike.** A slot's status is `OPEN` or `BOOKED` and nothing else. Marking it @@ -689,7 +692,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage (`stayEndedWith`), unless that booking no longer exists, since then there is nothing left to end. Otherwise the stay stays `CONFIRMED` on nights the slot is now offering to someone else. - **Dates that have been and gone stop being availability.** A slot is live while `end >= today`, - today being the LOCAL date (`isExpired`/`todayIso`, `utils/format.ts`) — the same boundary Trips uses to split upcoming from past, so a + today being the LOCAL date (`isExpired`/`todayIso`, `src/lib/format.ts`) — the same boundary Trips uses to split upcoming from past, so a slot and a stay stop being current on the same day. Nothing else ages a window out: `status` only ever says OPEN or BOOKED, so without this a slot from last year stayed bookable forever, offered in Browse, on a place card, in a friend's view of a room, and through a share link. Filtered in @@ -759,10 +762,10 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage answers nothing, so a plain ask then goes ahead and Firestore queues it. - **Search is client-side.** Each user only sees friends' listings (a small set), so Browse fetches all friends' listings + windows once (`fetchFriendListings` chunks the `in` filter at - 20 uids, `BROWSE_CHUNK` — the rules' lookup budget, see above) and filters by date/type/distance in `utils/search.ts`. This sidesteps Firestore's + 20 uids, `BROWSE_CHUNK` — the rules' lookup budget, see above) and filters by date/type/distance in `src/lib/search.ts`. This sidesteps Firestore's inability to combine a geo range with a date range, and keeps `firestore.indexes.json` empty. - **Counting a saved search costs no reads, which is the whole reason it exists.** `refreshBrowse` - runs in the store provider on mount, unconditionally — so every screen already holds every + runs from an effect in the store itself, whatever screen is up — so every screen already holds every friend's listings and windows. A saved search is just a stored `SearchCriteria`, and its count is one `searchListings` pass over data that is already in memory. Ten of them are ten array passes, not ten queries. The feature is not literally read-free: `watchSavedSearches` is one more listener @@ -776,7 +779,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage unbounded deliberately so a search past the cap is still visible and still removable. **Two surfaces, and Browse gained no new control.** Home lists them, because that's where you see - them without going looking. `SavedSearches` (`components/saved-searches.tsx`) is the same list + them without going looking. `SavedSearches` (`src/lib/components/saved-searches.svelte`) is the same list again at the BOTTOM of the filter sheet — the place a search is composed is the place to keep one or pick one up again. It sits below the sheet's footer on purpose: tweaking dates is the common reason to open it, so "Show N places" must stay reachable without scrolling past a list. @@ -818,7 +821,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage - **Geohash for distance.** Listings store a `geohash` (via `geofire-common`) computed from lat/lng on write; Browse ranks by `distanceBetween`. Coordinates are optional in the listing form for now (no geocoder yet) — distance filtering is a no-op until they're filled in. -- **Live for mine, fetched for theirs.** The store (`utils/store.tsx`) keeps live `onSnapshot` +- **Live for mine, fetched for theirs.** The store (`src/lib/store.svelte.ts`) keeps live `onSnapshot` listeners on everything the signed-in user owns (prefs, friends, requests, my listings + their windows, my trips, incoming bookings) and a manual `refreshBrowse()` fetch for friends' listings, plus `tripListings` — a by-id fetch of any listing one of your trips points @@ -839,7 +842,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage returning user through onboarding, whose `createProfile` write merges over the name they already have; as a verdict it tells an ordinary successful load that the device can't reach kip. (A plain listener swallowing it is how a share-link visitor wedged on a permanent splash in production.) So - `classifySnapshot` (`utils/profile-gate.ts`) sorts every snapshot into an ANSWER — the profile from + `classifySnapshot` (`src/lib/profile-gate.ts`) sorts every snapshot into an ANSWER — the profile from cache or server, or a server-confirmed absence — or UNPROVEN, an absence from cache, which `watchOwnProfile` settles by asking the cache which of the two raised it. What makes one listener sufficient is `includeMetadataChanges: true`, load-bearing twice over and probed against the SDK rather than @@ -864,7 +867,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage rather than the uid, since a `generation` re-attach opens a new listener for the SAME person and the straggler carries the right uid. A late `null` read as "no profile" opened the identity sheet over someone who already had a name; a late silence flipped the can't-reach-kip screen and logged - a false incident. `attempt()` (`utils/profile-gate.ts`, pinned in `tests/profile-gate.test.ts`) + a false incident. `attempt()` (`src/lib/profile-gate.ts`, pinned in `tests/profile-gate.test.ts`) is one counter serving both ordering and teardown, which is what lets the store set the profile unconditionally — a second guard there would state the same contract twice and drift. - **Recovery re-raises.** Confirming a cached absence changes no document DATA, so a plain @@ -889,8 +892,8 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage cache lease. Firing late or spuriously costs a no-op event: it can never shut an open gate, and a later answer still opens a shut one. - **The transitions are a pure reducer** — `gateStep` in `utils/profile-gate.ts`, pinned by - `tests/profile-gate.test.ts`, the same split as `reattach.ts`: the effect needs React, Firebase + **The transitions are a pure reducer** — `gateStep` in `src/lib/profile-gate.ts`, pinned by + `tests/profile-gate.test.ts`, the same split as `reattach.ts`: the effect needs Svelte, Firebase and a live session to exercise, the state machine needs none of them. Its invariants are each a bug that existed or nearly did: - **An opening belongs to a session.** A `generation` re-attach for the uid the gate is open for @@ -900,10 +903,10 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage until something answers; clearing it on each attach flashed error → splash → error at every step of the 500ms/2s/6s ladder. A new uid starts clean. - **Silence once the gate is open changes nothing** — that is the re-attach machinery's problem. - - **Never open and unreachable at once**, which is what entitles `page.tsx` to render Unreachable + - **Never open and unreachable at once**, which is what entitles `+page.svelte` to render Unreachable only behind a shut gate. - **Friends-only, and nothing blocks on enrolment.** Nothing is public, so a visitor with no - session at all only ever sees `components/welcome-screen.tsx` — the landing page, which is one + session at all only ever sees `src/lib/components/welcome-screen.svelte` — the landing page, which is one screen, fits without scrolling at 390×844 (measured, not eyeballed), and carries no photograph, no feature list and no second section. **The smallness is the design.** Almost everyone who arrives was sent a link by a friend, so they are already half-invited: the page's job is to @@ -914,7 +917,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage It says a friend's link is how most people start, and offers ONE door for both arriving and returning. **There is no password**, and nothing anywhere says "sign - up". Three doors, all passwordless, all wrapped by `utils/auth.ts`: an emailed one-time link + up". Three doors, all passwordless, all wrapped by `src/lib/auth.ts`: an emailed one-time link (`sendReturnLink` to come back, `sendAttachLink` to add an address to the account already asking), a texted one-time code (US numbers only — that is the SMS region allowlist, and `parseDestination` refuses anything else rather than letting the server bill for a refusal), and @@ -953,20 +956,20 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage **`email` rides across so a lost fragment fails loudly:** the page refuses a link with no address. Left in the query, an ATTACH link would look exactly like a RETURNING one and sign someone into a - second account instead of joining the first. `routable()` skips `/continue/` for the same family - of reason it skips `/portal/` — a route written over either fragment destroys it. + second account instead of joining the first. `routable()` is false on `/continue/` for the same + family of reason it is false on `/portal/` — a route written over either fragment destroys it. - **One attempt owns the outcome, and cancelling is a newer attempt rather than a teardown.** - StrictMode remounts, the second run is correctly refused by `spent`, and a teardown that muted the - first threw away the only answer and its timer, leaving the page on "working" for ever. Dev-only, - so only `check:host` — which waits for the redirect instead of navigating itself — ever sees it. + **One attempt owns a failure or a stall, and cancelling is a newer attempt rather than a + teardown.** The link is opened once, on mount, since its one-time code can be spent a single time; + **Try again** starts a newer attempt, and an older one still in flight may no longer report a + failure or a stall over it. **A success is the exception: it belongs to every attempt** (`finished`). A retry after a stall resends a code the stalled call may yet spend, so the retry's refusal can arrive before or after the success it lost to, and whichever succeeds wins. A RETURNING link that fails or stalls says so and offers **Open kip** (plus **Try again** on a stall), rather than stranding someone on a page with no way into the app. - **The code step is six boxes over ONE input, and the count is the point.** `components/ui/code-input.tsx` + **The code step is six boxes over ONE input, and the count is the point.** `src/lib/components/ui/code-input.svelte` draws six boxes wearing `Input`'s own shell and lays a single transparent `one-time-code` field across all of them, because the OS keyboard's "From Messages: 123456" strip, paste, backspace and select-all are things the platform already does to one field — six real inputs would mean @@ -980,10 +983,13 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage that says what a finished code looks like; all four surfaces gate their button on it, where each used to ask whether `code` was non-empty and so offered to send a single digit. - One trap worth keeping: **React's `autoFocus` focuses the node itself during commit and never goes - through `onFocus`**, so `focused` is seeded from the prop. Left at `false` the field opened as six + One trap worth keeping: **the focus given on mount lands before the field's own `focus` listener + can be relied on to have said so**, so `focused` is seeded from the `autofocus` prop. Left at + `false` the field opened as six identical empty boxes with nothing saying where the next digit lands — proved in a browser, where - `document.activeElement` was already the input and every box still read `border-border`. A real + `document.activeElement` was already the input and every box still read `border-border`. The field + is focused by hand in an effect rather than left to the attribute, which only takes while nothing + else holds focus — and the code step replaces a field that did. A real tap fires both events normally; `element.blur()` and `element.click()` from a script do not, which is why that probe has to be a dispatched mouse event. @@ -991,20 +997,20 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage which was a fair trade while standing copy filled it — the phone sheet's said adding a number was not agreeing to be texted, which the Notifications switch says where it bites. With nothing left to stand there it spent 32px of empty sheet under the button on every render, which reads as the - layout having broken. `Problem` stays MOUNTED and drops to zero height instead, in a gapless + layout having broken. The message line stays MOUNTED and drops to zero height instead, in a gapless wrapper with the button: a live region announces a CHANGE, so one that appears together with its text is one a screen reader never reads out. - `app/page.tsx` gates in order: `authReady` splash → **`WelcomeScreen`** (no session at all) → + `src/routes/(app)/+page.svelte` gates in order: `authReady` splash → **`WelcomeScreen`** (no session at all) → `profileReady` splash → the app. There is **no onboarding screen**: a missing display name is - collected by the identity sheet (`components/name-gate.tsx`, and the portal page's own copy) at + collected by the identity sheet (`src/lib/components/name-gate.svelte`, and the portal page's own copy) at the first action that puts your name in front of someone, so nothing blocks. A credentialed account with no name is offered that sheet unprompted **once per account**: dismissing it sticks, and after that only an action that needs a name brings it back. `AuthMenu` hides only for a nameless ANONYMOUS session, and withholds the exit from an account with no credential — see the sign-out note in the anonymous bullet. - **Leaving is possible, it dismantles rather than departs, and the SERVER finishes it.** The client - writes one document — `deletions/{uid}`, `utils/leave.ts` — and `onAccountDeletionRequested` + writes one document — `deletions/{uid}`, `src/lib/leave.ts` — and `onAccountDeletionRequested` (`functions/src/index.ts`, `functions/src/teardown.ts`) tears the account down with the Admin SDK in five named phases: cancel every future stay in both directions, delete the places (photos, slots, guest pointers), unfriend both sides and delete requests both ways, then prefs, saved @@ -1072,16 +1078,16 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage **The phases are a progress bar, and an ETA would have to lie** — how long this takes depends on how many stays, places and photos there are, which nothing knows without counting first. So there - is a fixed list (`DELETION_PHASES`, `utils/types.ts`, mirrored in `teardown.ts` and pinned by - `tests/drift.test.ts`) and `components/deletion-screen.tsx` draws a determinate bar over it. The + is a fixed list (`DELETION_PHASES`, `src/lib/types.ts`, mirrored in `teardown.ts` and pinned by + `tests/drift.test.ts`) and `src/lib/components/deletion-screen.svelte` draws a determinate bar over it. The bar counts the wait BEFORE the first phase as a step of its own and never fills, since the phase it names is the one still running. That field is also the only way a stuck teardown is visible at - all. `app/page.tsx` renders this AHEAD of the profile gate — the teardown deletes the profile + all. `src/routes/(app)/+page.svelte` renders this AHEAD of the profile gate — the teardown deletes the profile partway through, and the other reading of a missing profile is onboarding, which would put someone who asked to leave in front of a form asking their name and write it back. `deletionReady` is what stops the app being drawn in the beat before that document is answered for. It resets only when the uid changes, never on a `generation` re-attach, for the same reason `profileReady` doesn't: - `page.tsx` splashes while it is false. + `+page.svelte` splashes while it is false. **Failure is capped and says so.** Five attempts, counted in the document before the work so a run that takes the process with it still spends from the budget; past that the function writes `error` @@ -1091,7 +1097,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage **And a failure must not be a life sentence, which it was.** The document then stands forever — nothing else removes one: no TTL, and the reaper skips an account that still has a profile — while - `app/page.tsx` renders the deletion screen ahead of every other gate, on every device, from a + `src/routes/(app)/+page.svelte` renders the deletion screen ahead of every other gate, on every device, from a screen that had no controls on it at all. The only recovery was an operator with the Admin SDK. So the delete rule passes for the owner **when the document carries an error**, which is a @@ -1102,7 +1108,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage had to move with it. The store treats the document VANISHING as the teardown finishing and signs out — correct, since the Auth account went with it — so it now only arms that reading while the request is live: the function never deletes one it wrote an error on, so a cleared failure is - somebody choosing to stay. And `NameGateProvider` opens its sheet for a credentialed account with + somebody choosing to stay. And `NameGate` opens its sheet for a credentialed account with no display name, which is exactly what an account is once the profile phase lands — so it asked the person who had just left what to call them, over the screen saying they were leaving, and sat on top of these controls. It stands down for a deletion now. Both are driven by @@ -1131,13 +1137,9 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage THROWS past it, and the queries are single-equality with the rest filtered in memory because `firestore.indexes.json` is empty and nothing deploys a composite index. -- **No secrets, runs unconfigured.** The Firebase web config is inlined in `utils/firebase.ts` - (values are public — security is in the rules). The repo currently **ships a populated - `hafaio-kip-dev` config**, so `firebaseConfigured()` is true and the real sign-in flow runs. - The unconfigured fallback still exists for a fresh clone with the config cleared: blank the - `appId` and `firebaseConfigured()` returns false — `authReady` settles immediately on the - sign-in screen and the sign-in button shows a "not set up yet" dialog — so the app still - builds and runs before any Firebase project exists. +- **No secrets.** The Firebase web config is inlined in `src/lib/firebase.ts` + (values are public — security is in the rules), and it is the `hafaio-kip-dev` config, so a + fresh clone runs the real sign-in flow. There is no unconfigured mode. - **An account can hold more than the door it arrived through.** Settings → Account carries a **How you get in** group — email, phone, Google — each set, absent or in conflict, because an account with one credential is one lost inbox from unrecoverable and kip has no password to fall @@ -1159,9 +1161,9 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage **A credential that already belongs to someone else signs you INTO that account and changes the uid** (`sameAccount: false`), which has bitten twice; all three doors route it through one alert. - And the rows read `email`/`phone`/`doors` from **store state, never off `user` at render** — + And the rows read `email`/`phone`/`doors` from **store state, never off `user`** — Firebase mutates the `User` in place, so a link changes the fields without changing the reference - and React never re-renders. `unlink` is worse: it persists the shortened `providerData` and + and nothing reading them re-runs. `unlink` is worse: it persists the shortened `providerData` and notifies nobody, so `removeDoor` ends with a `reload()` to make the row follow the server. - **Settings is four sections, and Privacy is one question.** Account (display name, the doors above), @@ -1169,7 +1171,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage **Notifications**, Appearance. Discoverability and stay-visibility were separate sections until they were read as the same question — who sees what about you — and merged. - **Tailwind v4 semantic tokens (Terra).** Warm palette as `--color-*` in `@theme` - (`app/globals.css`) with `.dark` overrides; components use `bg-surface`, `text-muted`, + (`src/app.css`) with `.dark` overrides; components use `bg-surface`, `text-muted`, `text-accent-ink` etc. — no raw hex in markup. Terra adds: a terracotta→amber gradient exposed as the `--gradient-accent` var + a `.bg-gradient-accent` utility (primary CTAs, avatar rings, Instant chips, badges, the wordmark tile, ON switches); `accent-ink`/`success-ink` darker text tones; @@ -1179,7 +1181,7 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage **tonal elevation** (surfaces lighten, shadows nearly vanish) instead of shadow depth. The canvas is a warm near-white (`#f6f1ea`) with a faint fixed sunset radial glow; `--radius-*` is bumped to Terra's rounder scale (pills for controls/chips, `rounded-2xl`/`3xl` for surfaces). -- **UI primitives = one source of truth for controls (`components/ui/`).** Every interactive +- **UI primitives = one source of truth for controls (`src/lib/components/ui/`).** Every interactive control is a pill (`rounded-full`) primitive at a single **44px (`h-11`) control height** (thumb-friendly on mobile), so nothing looks stranded next to its neighbors: `Button` (variants `primary` = gradient + `shadow-glow`, `secondary` = tonal accent fill, `ghost`, `danger` = @@ -1190,50 +1192,61 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage visible border + an accent focus ring**. `Sheet` is the shared modal surface (bottom sheet on mobile with a drag-handle bar + `rounded-t-3xl`, centered `rounded-3xl` card on ≥sm; backdrop + Escape dismiss, scroll-lock); the dialog renders through it. **It manages focus**: opening moves - focus into the sheet (unless a child's `autoFocus` already has), closing restores it to whatever + focus into the sheet — to the child carrying `autofocus` if there is one, which the panel focuses + itself, since the attribute alone only takes while nothing else holds focus and the control that + opened the sheet does — closing restores it to whatever opened it, and Tab is trapped inside. Open sheets form a stack, and Escape and Tab belong to the TOP one only, since a confirm raised over a sheet is its own sheet. A button that is working swaps - its label for a spinner through `components/ui/busy.tsx`, which keeps the label `sr-only`, or the - button is announced with no name at all. **It renders through a PORTAL onto - ``, and that is load-bearing rather than tidy:** `fixed` resolves against the viewport only + its label for a spinner through `src/lib/components/ui/busy.svelte`, which keeps the label `sr-only`, or the + button is announced with no name at all. **Its panel is mounted onto + `` as a tree of its own** (`sheet.svelte` mounts `sheet-panel.svelte` there while open), + **and that is load-bearing rather than tidy:** `fixed` resolves against the viewport only while no ancestor is a containing block, and `backdrop-filter` makes one — so the desktop `TopBar`, which is blurred, trapped a sheet opened from `AuthMenu` inside itself and collapsed it to a zero-height strip across the top. Any transform, filter or `will-change` would do the same, so it belongs in the primitive rather than at each caller: a modal must not be positioned by whatever - happens to contain the button that opened it. **Only ≥md could show it** — the mobile header carries + happens to contain the button that opened it. Mounted rather than moved, because a sheet that moved + its own node there could not be closed by its parent removing it. **Only ≥md could show it** — the mobile header carries no blur — which is exactly why phone-width screenshots of the feedback sheet looked right while it was broken, and why `check:feedback` measures the backdrop against the viewport at 1280 wide. `Segmented` (tonal pill track, active = white thumb) and `Switch` (labeled track/thumb, ON = gradient) round out the set. Lists use `Group` (a `rounded-3xl bg-surface shadow-card` with near-invisible `divide-y` — the **shadow is the separator**, no outer border) + `Row` (`min-h-14`) + `Section`/`SectionHeading` — flat grouped lists (iOS-Settings style), one action per surface, whole-row tap targets, never a row that clips - at 390px. **Status is a soft tonal `Chip`** (`components/ui/chip.tsx`, replaces the old + at 390px. **Status is a soft tonal `Chip`** (`src/lib/components/ui/chip.svelte`, replaces the old editorial byline): a low-contrast pill — `pending` (amber), `confirmed` (green), `open` (accent), `booked` (dimmed neutral), `instant` (gradient fill + bolt, white text), `type` (neutral outline for `Room`/`Flat`/`House`), `neutral` (cancelled). Low-contrast fills so a chip reads as a passive *label*, never as a button — used for slot/booking state, the booking-detail status, and listing - type. `CountBadge` (same file) is the gradient count pill on nav destinations. Mobile nav is a - **floating dock** (`FloatingDock` in `nav.tsx`, `md:hidden`, inset from the edges, `rounded-3xl`, + type. `CountBadge` (`count-badge.svelte`) is the gradient count pill on nav destinations. Mobile nav is a + **floating dock** (`floating-dock.svelte`, `md:hidden`, inset from the edges, `rounded-3xl`, translucent + `backdrop-blur`, `shadow-dock`; active tab wrapped in an accent-soft pill, badges float over the icon); Settings lives in the `AuthMenu` profile menu. (Design was iterated by rendering real components via a throwaway unguarded route + headless-Chrome screenshots — see the - `verify-ui-visually` session memory.) -- **Theming via `next-themes`.** `app/layout.tsx` wraps the app in next-themes' - `ThemeProvider` (`attribute="class"`, `defaultTheme="system"`, `enableSystem`), so it toggles - the `.dark` class on `` and injects a pre-paint script (no FOUC); `` carries - `suppressHydrationWarning`. `theme-button.tsx` (a cycling system → light → dark `IconButton`, - via `utils/theme.ts` `asThemeChoice`/`nextThemeChoice`/`themeLabel`) sits beside the avatar in + `verify-ui-visually` session memory.) Icons are Lucide, compiled in by `unplugin-icons` + (`~icons/lucide/…`) from `@iconify-json/lucide`. +- **Theming is a class on ``, set before first paint.** The first inline script in + `src/app.html` reads the stored choice (`localStorage` key `theme`: system, light or dark) and sets + `light` or `dark` on `` before anything is drawn (no FOUC). `src/lib/theme.svelte.ts` is its + other half: `startTheme()`, run from an effect in `src/routes/+layout.svelte`, reads the same key + into `theme` (`choice`, `resolved`, `set()`) and follows the system preference and other tabs from + then on. `theme-button.svelte` (a cycling system → light → dark `IconButton`, + via `src/lib/theme.ts` `asThemeChoice`/`nextThemeChoice`/`themeLabel`) sits beside the avatar in BOTH app headers (mobile and the desktop `TopBar`) as well as on the sign-in screen and the public portal header. Settings keeps the Appearance `Segmented` (System/Light/Dark) too — the - toggle is the quick reach, the segmented control is where you go to be deliberate. Each is guarded by a `mounted` flag. -- **Terra identity.** One typeface — **Plus Jakarta Sans** (loaded via `next/font/google` in - `app/layout.tsx` as `--font-jakarta`, wired to `--font-sans`, self-hosted into the static export) + toggle is the quick reach, the segmented control is where you go to be deliberate. `theme.choice` reads + "system" until `startTheme` has run, so the first client render agrees with the prerendered HTML. +- **Terra identity.** One typeface — **Plus Jakarta Sans** (`@fontsource-variable/plus-jakarta-sans`, + imported in `src/app.css` and wired to `--font-sans`, self-hosted into the static export) — for body AND headings; headings just heavier + tighter (`font-extrabold tracking-[-0.03em]`; `.font-heading` is repointed to that, not a serif). Base font size **16px**; quiet section labels are `text-sm font-semibold text-muted`; dates/counters use `tabular-nums`. The brand lockup is - `components/wordmark.tsx` — a gradient disc holding a white "k" beside "kip" extrabold — used in - the mobile top bar (Home), the desktop top bar, sign-in and portal; its `Mark` export is the same - disc alone, pulsing, on the splash and the share-link page's loading state. + `src/lib/components/wordmark.svelte` — a gradient disc holding a white "k" beside "kip" extrabold — used in + the mobile top bar (Home), the desktop top bar, sign-in and portal; `mark.svelte` is the same + disc alone, pulsing, on the splash and the share-link page's loading state. Next in the font stack + is a `@font-face` of local Arial scaled to Plus Jakarta Sans's metrics (`src/app.css`): it stands in + until the real file arrives, so the swap doesn't move the page, and it draws the characters the + font has no glyph for — the arrow in "→" links. **Every mark is a circle, and the classes now say so.** They always rendered as circles — Terra's radii exceed half of a box this small and CSS clamps border-radius there, so `rounded-xl` on an @@ -1247,35 +1260,50 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage only where it earns attention (CTAs, rings, Instant, badges); elevation comes from layered soft shadows in light and tonal lightening in dark. Chrome is borderless canvas: a **mobile top bar** (back + screen title or wordmark + `AuthMenu`) and, on `≥md`, a **sticky desktop top app bar** - (`TopBar` in `nav.tsx` — wordmark + inline nav pills + avatar) that **replaces the old left + (`top-bar.svelte` — wordmark + inline nav pills + avatar) that **replaces the old left sidebar**; content sits in a `max-w-6xl` centered container. Direction: Airbnb-grade structure, but a friends-first, less commercial feel — distinct from a marketplace. -- **In-app dialogs, no browser `confirm`/`alert`.** `components/dialog.tsx` provides - `DialogProvider` + `useDialog()` returning async `confirm()` / `alert()` (mounted in - `layout.tsx` around the app). The async API mirrors a native action sheet/dialog, and the UI is +- **In-app dialogs, no browser `confirm`/`alert`.** `src/lib/components/dialog.svelte` exports + `dialog`, whose async `confirm()` / `alert()` can be called from anywhere, and draws whichever is + open (``, mounted once in `src/routes/(app)/+layout.svelte`). The async API mirrors a + native action sheet/dialog, and the UI is a **bottom sheet on mobile, centered card on desktop**. All destructive actions (delete listing, unfriend, cancel slot) route through it; nothing calls `window.confirm`/`alert`. Enter confirms only when focus is NOT on a control, since a focused button answers Enter itself — otherwise Enter on a focused Cancel would confirm. **A failed action is always said, never only - logged**: `useAction` runs a fire-and-forget action and turns its rejection into a "That didn't - work" dialog, and `useFailure` does the same for handlers that hold their own busy state. - - `app/error.tsx` is the render-crash boundary: "This page couldn't load", with Reload and a Home - link that is a full page load, because the app is one route and a router push to it from inside - it would leave the boundary standing. Next's own fallback would link to `/`, which under a base - path is outside kip altogether. + logged**: `runAction` runs a fire-and-forget action and turns its rejection into a "That didn't + work" dialog, and `reportFailure` does the same for handlers that hold their own busy state. + + `src/routes/+error.svelte` is the render-crash boundary: "This page couldn't load", with Reload and a Home + link that is a full page load, because the app is one route and a router navigation to it from + inside it would leave the boundary standing. The link is built from `BASE_PATH`, since a bare `/` + under a base path is outside kip altogether. The same page is exported as `404.html`, which GitHub + Pages serves for a path that names no file; there it says the page could not be found. - **Every screen has a URL, in the fragment.** `#/`, `#/browse`, `#/person/`, `#/room/`, `#/room//slot/`, `#/room//edit`, `#/new-place`, - `#/booking/` — `screenHash`/`screenForHash` in `utils/store.tsx` are an inverse pair and the + `#/booking/` — `screenHash`/`screenForHash` in `src/lib/screens.ts` are an inverse pair and the round trip is lossless for every variant of the `Screen` union. `navigate` pushes a real history - entry, `replace` replaces it, `back` calls `history.back()`, and `popstate` only ever calls - `setStack` — never a history write, which is what stops the double-entry echo. The fragment names + entry, `replace` replaces it, and `back` calls `history.back()`. **Every history write goes through + SvelteKit's `goto(hash, { shallow, replace, state })`, never `history.pushState`**, because Kit's + router owns history: it numbers every entry, and one written behind its back shares a number with + its neighbor, which the router then refuses to traverse between. The stack, a depth and an entry id + ride in `page.state` (`kipStack`, `kipDepth`, `kipEntry`), which is also what hands the stack back + after a reload. **A traversal is adopted from `page.state`**: `popstate` only raises a flag, the + router delivers the state of the entry it landed on, and an effect copies the stack out of it — + never a history write, which is what stops the double-entry echo. Scroll offsets live in + `sessionStorage` keyed by entry id (`historyScroll`/`rememberScroll`), which survives a reload and + a forward; the listener that records them attaches when `
` appears, which is only once the + gates have opened. The fragment names the TOP screen only; the stack beneath it is the app's own memory, so a pasted link is seeded as - `[home, entity]` and `back` at depth 0 replaces in place rather than leaving the site. An + `[home, entity]` and `back` at depth 0 replaces in place rather than leaving the site. **A fragment + typed into the address bar reloads the page**: the browser has already pushed an entry Kit never + numbered, so the page is loaded again on it and it becomes an ordinary arrival — with the depth + carried across in session storage, so Back still returns to the screen it was typed over. An unparseable fragment resolves to Home and is rewritten, so a bad link never renders nothing. Ids in the URL are fine: every one is enforced by `firestore.rules`, and the only capability- bearing ids are portal tokens — which live on `/portal/`, whose fragment means something else - entirely and which every history write skips by pathname — as does `/continue/`, whose fragment + entirely. The store routes only on `/` (`routable()` compares the route id), so it never writes + history there — nor on `/continue/`, whose fragment carries the half of a sign-in link that kip adds. - **Object-model navigation (client SPA).** The domain is four entities — Person, Room (listing), Slot (window), Booking — and the app is a client-side nav stack in the store (`Screen` = @@ -1285,14 +1313,14 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage everything else transient (filters, the slot editor, add-slot, confirms) is a `Sheet` with component-local state, deliberately NOT in the stack. Each entity has a compact card/row for lists AND a full page; both are **state-aware** (affordances from viewer-role × state), e.g. - `SlotRow` shows book/request/pending/booked-by-you. `page.tsx` renders the current screen inside + `SlotRow` shows book/request/pending/booked-by-you. `src/routes/(app)/+page.svelte` renders the current screen inside the `max-w-6xl` container; the mobile top bar carries the back button + the screen title (the wordmark only on Home) + the `AuthMenu`, and on `≥md` a back row sits above the content (the desktop nav is the top app bar). Detail/list screens own their desktop layouts — Home and RoomPage - are 2-col with a right rail / sticky panel, Browse is a card grid, the rest a centered column. The **RoomPage is the single place surface**: owner view (details + an Availability - grouped list whose rows open a per-slot `Sheet`, a Sharing section, a Guests list, Edit-details → + are 2-col with a right rail / sticky panel, Browse is a card grid, the rest a centered column. The **RoomPage is the single place surface**: owner view (`room-owner-view.svelte`: details + an Availability + grouped list whose rows open a per-slot `Sheet`, `slot-sheet.svelte`, a Sharing section, a Guests list, Edit-details → `listing-form`, and a quiet Delete) absorbs the old ManageListing + AvailabilityEditor; friend - view is host-block + an Open-dates list of bookable `SlotRow`s. Browse/Home/Person list results as + view (`room-friend-view.svelte`) is host-block + an Open-dates list of bookable `SlotRow`s. Browse/Home/Person list results as the compact **`PlaceCard`** (host featured on top → `PersonPage`, except `showHost={false}` on the host's own page; the whole card taps to the room, no slot rows/buttons inside) — the old `RoomCard`/`ListingCard` are retired. Browse's filters live in the store's `criteria` (so they @@ -1321,12 +1349,12 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage is for: a diagnostic is written BY a failure and is worth having from the anonymous visitor it happened to, while this is prose somebody sits down and reads. What that costs is the visitor best placed to report a broken share link, which is the trade the credential gate makes everywhere. - `credentialed` (`utils/feedback.ts`) mirrors the rule so the menu row can hide rather than be + `credentialed` (`src/lib/feedback.ts`) mirrors the rule so the menu row can hide rather than be offered and then denied; `tests/feedback.test.ts` pins the copy, since the two disagreeing shows up as a missing control or an unexplained refusal and neither says which half is wrong. **Its author never reads it back** — a report is written, not held — and the operator reads them - in the app, on a `feedback` screen reached from the menu (`components/feedback-view.tsx`, one more + in the app, on a `feedback` screen reached from the menu (`src/lib/components/feedback-view.svelte`, one more `View`, so it gets `#/feedback` for free). Fetched rather than watched: nothing writes while it is open except the operator's own deletes, which are applied locally. Nothing edits one; the only thing to do with a report that has been dealt with is remove it — with no confirm, deliberately, @@ -1397,15 +1425,17 @@ Rules: [firebase/firestore.rules](./firebase/firestore.rules), [firebase/storage - **Failures that throw nothing report themselves, to a collection nobody can read.** The failures worth diagnosing here are silences — a wait that ended, a listener that went quiet — so there is no exception and a stack trace would be empty. What matters is the STATE that made the decision, which - is why `debug` carries a `detail` JSON blob (`utils/debug.ts`, `recordDebugEvent` + `clientState`) + is why `debug` carries a `detail` JSON blob (`src/lib/debug.ts`, `recordDebugEvent` + `clientState`) rather than an error. Three sites write one: the portal ask when it is refused or stalls, the store when the re-attach budget runs out, and the profile gate when it turns unreachable. The latter two fire once per incident rather than once per retry, since the retries behind them are automatic and say nothing new. A portal ask is a person tapping, so every attempt is its own event. - **The guard vector is snapshotted during render, not read in the effect.** A timer fires up to ten - seconds after it armed, and reporting the state the effect closed over would describe the moment - the visitor tapped rather than the moment it failed. + **The guard vector is read when the report is made, not when the wait began.** A timer fires up to + ten seconds after it armed, and reporting the state captured then would describe the moment + the visitor tapped rather than the moment it failed. `report()` on the portal page reads every + field as it is called, and untracked, since one caller is an effect that must not re-run on what + is only being reported. **It is client-writable and nobody can read it back, which is exactly what separates it from the `mail` collection this schema refused.** No recipient, no delivery, nothing to read — so it can't @@ -1474,7 +1504,7 @@ friends", PersonPage gates on `!friend`, and the portal page on its `Connect` st not, and is how this case was found), but the rules don't forbid the write and the trigger can't assume the client behaved. -**A connect control has three states and a fourth for "not yet".** `Connect` (`app/portal/page.tsx`) +**A connect control has three states and a fourth for "not yet".** `Connect` (`src/routes/(app)/portal/ask.ts`) is `ask` | `sent` | `none` | `unknown`, and the control reports whichever it's in WHERE IT STANDS — a `pending` Chip reading "Friend request sent" in the button's own place, the same swap a slot row makes between Request and its Requested chip. It used to vanish instead, with a paragraph further @@ -1521,7 +1551,7 @@ account would otherwise just silently receive nothing. **Per-event preferences** live at `users/{uid}/settings/prefs.notify`, surfaced as a Notifications section in Settings. Owner-private, but the sender runs as admin so it reads them regardless. -**Every event is defined once**, in `NOTIFY_EVENTS` (`utils/types.ts`): label, description, and its +**Every event is defined once**, in `NOTIFY_EVENTS` (`src/lib/types.ts`): label, description, and its own default. The `NotifyPrefs` type, `DEFAULT_NOTIFY` and the Settings rows all derive from it, so adding an event is a single edit and the three can't drift. (The function keeps its own copy of the KEYS, being a separate package — they must stay in step, since a key that exists on only one side @@ -1620,9 +1650,11 @@ on that job. ## About, Privacy, Terms and Help -Four **statically exported routes** — `/about/`, `/privacy/`, `/terms/`, `/help/` — each a plain server -component importing no store, initialising no Firebase and needing no session, so the full text sits -in the exported HTML for `curl` with JavaScript off. +Four **statically exported routes** — `/about/`, `/privacy/`, `/terms/`, `/help/`, under +`src/routes/(docs)/` — each a prerendered page importing no store, initialising no Firebase and +needing no session, so the full text sits +in the exported HTML for `curl` with JavaScript off. That is structural: the store, the dialogs and +the identity sheet are started by `src/routes/(app)/+layout.svelte`, which these routes sit outside. **They cannot be fragment screens, and that is not a preference.** Twilio fetches the Privacy and Terms URLs **server-side** during campaign registration, and a fragment never reaches the server — @@ -1630,19 +1662,21 @@ Terms URLs **server-side** during campaign registration, and a fragment never re the required language. It would pass the URL check and fail the content review, which is the worst shape of failure: the thing looks configured and isn't. `web/tests` cannot catch this; the check is grepping `web/out/privacy/index.html` for the non-sharing sentence after `bun export`. +`src/hooks.server.ts` is what lets a whole sentence match: it turns each line break the source was +wrapped at into the one space it renders as, while prerendering. Neither are they BOTH — a fragment screen beside a static route is two copies of legal text that will drift. One canonical URL each, and `screenForHash` already resolves an unknown fragment to Home, so a stray `#/privacy` link degrades rather than breaking. -`components/doc-page.tsx` is the shell and about a dozen element classes; deliberately not +`src/lib/components/doc-page.svelte` is the shell, with `doc-h2`, `doc-p`, `doc-list` and `doc-card` beside it; deliberately not `@tailwindcss/typography`, which would be a dependency to keep the tokens exact for four pages. Body text sits on the **warm canvas, not in cards** — a card in kip means a control or a list, and a wall of card behind two thousand words reads as a form. **Exactly two cards exist** and both earn it: Privacy's "short version", which is the honesty gesture, and Terms' "Text messaging program", which makes the required SMS disclosures impossible for a reviewer to miss. -`utils/contact.ts` holds the contact address as one constant: `support@kip.hafa.cc`, which +`src/lib/contact.ts` holds the contact address as one constant: `support@kip.hafa.cc`, which Cloudflare Email Routing forwards to the `kip-app@googlegroups.com` Google Group. It is deliberately NOT the sending address, which nobody reads. Two group settings are load-bearing, since forwarded mail still arrives under the stranger's own address: **posting open to anyone on the web**, or every message from a stranger @@ -1677,7 +1711,7 @@ stored SMS consent record references documents, and until now those links resolv ## Photos A listing carries up to `MAX_PHOTOS` (8) entries in `photos`, each `{ id, url }`; the objects live -in Storage at `listings/{ownerId}/{listingId}/{photoId}`. `utils/photos.ts` owns the round trip. +in Storage at `listings/{ownerId}/{listingId}/{photoId}`. `src/lib/photos.ts` owns the round trip. `uploadListingPhoto` **shrinks in the browser first** (canvas, 1600px max edge, JPEG q0.82), which keeps the bucket small and, deliberately, re-encodes away EXIF: a GPS tag on a photo of someone's home should not ride along with a share link. @@ -1687,12 +1721,12 @@ of the function rather than an edge case. A canvas with no 2d context, and a sou `toBlob` won't take, each used to fall back to the original file — so the two paths that delivered an untouched photo were exactly the two that delivered its GPS tag, silently, while the privacy page said otherwise. A refused upload is the lesser harm, and it says so in its own words: the two -callers (`photo-strip.tsx`, `person-page.tsx`) tell a `PhotoEncodeError` from a network failure, +callers (`photo-strip.svelte`, `profile-photo.svelte`) tell a `PhotoEncodeError` from a network failure, because nothing about this one gets better on a retry. Proved in `check:exif`, below. -It then mints the download URL once, at upload, and returns it to be stored. `components/photo-strip.tsx` is the editable strip (owner view of RoomPage +It then mints the download URL once, at upload, and returns it to be stored. `src/lib/components/photo-strip.svelte` is the editable strip (owner view of RoomPage and the listing form; drag or the per-thumbnail arrows reorder, and the first photo is the cover), -`components/cover-photo.tsx` the read-only cover used by `PlaceCard`, the RoomPage hero and the +`src/lib/components/cover-photo.svelte` the read-only cover used by `PlaceCard` and, through `photo-gallery.svelte`, the RoomPage hero and the portal page. **A new place can carry photos, because its id is minted before it is written.** `newListingId()` @@ -1739,20 +1773,21 @@ listing could name a slot's token; nothing starts from there any more. ## Installing it -**The worker is TypeScript**, compiled from `sw/sw.ts` to a gitignored `public/sw.js` — the source -sits outside `public/` so none of it is served. `tsconfig.sw.json` is its own because a worker has no +**The worker is TypeScript**, compiled from `sw/sw.ts` to a gitignored `static/sw.js`, served at +`/sw.js` — the source +sits outside `static/` so none of it is served. `tsconfig.sw.json` is its own because a worker has no DOM, and `lib: ["dom"]` would type `self` as a Window and accept code that throws the moment it runs. -**`next.config.js` runs that compile**, because it is the one file every Next command loads: wired -into `dev`/`export` instead, a bare `next build` shipped a site whose `sw.js` 404s with nothing -saying so. It resolves `tsc` from its own directory, not the working one, which Next does not set — -`next build web` from the repo root died with ENOENT before Next printed anything. `build:sw` is the +**`vite.config.ts` runs that compile**, because it is the one file every Vite command loads: wired +into `dev`/`export` instead, a bare `vite build` would ship a site whose `sw.js` 404s with nothing +saying so. It resolves `tsc` from its own directory, not the working one, which a caller is free to +set to anything. `build:sw` is the same compile by hand, for when only the worker has changed. **Nothing with a query or a fragment on it is ever stored.** A navigation's `request.url` carries both — measured, not assumed, and two reviews of this disagreed about it — so caching the request as it arrives writes a portal capability and a one-time sign-in code into Cache Storage, which has no expiry, is readable by any same-origin script, and outlives the revocation meant to kill them. The -key is the PATH alone, forced to a trailing slash to match `trailingSlash: true` — and the response +key is the PATH alone, forced to a trailing slash to match `trailingSlash = "always"` — and the response is stored as a fresh `Response` with no URL on it, since a cloned response carries the URL it came from, query and all, into Cache Storage. `check:pwa` opens `/portal/#token` and `/continue/?oobCode=…` and asserts neither reaches a cache key. @@ -1768,24 +1803,26 @@ mints new hashed names and nothing invalidates the old ones. that deletes its caches — or unregisters itself — is the only remote lever there will ever be. Worth knowing before it is needed. -`app/manifest.ts` plus the compiled worker make kip installable, and `Pwa` (mounted in the layout) +`src/routes/manifest.webmanifest/+server.ts` (prerendered to a file) plus the compiled worker make +kip installable, and `Pwa` (mounted in the root layout) registers the worker after load. Nothing is prefixed for us, so `start_url`, `scope` and every icon -path read `NEXT_PUBLIC_BASE_PATH` themselves. The release no longer sets it — kip is served at the +path read `VITE_BASE_PATH` themselves, through `BASE_PATH` in `src/lib/base.ts`. The release no longer sets it — kip is served at the root of `kip.hafa.cc` — but the support stays, so building under a path is still one env var; `scope` covers the whole app so a share link opens -inside an installed kip rather than bouncing to a tab. Icons are rendered from `app/icon.svg`, with +inside an installed kip rather than bouncing to a tab. Icons are rendered from `static/icon.svg`, with a separate full-bleed maskable one because Android crops the disc otherwise. **The worker caches the SHELL and nothing else.** Firestore already persists to IndexedDB and queues writes until it can reach the server, so the only thing between kip and working offline was -the HTML and JS needed to start it. Content-hashed `/_next/static/**` is cache-first; documents are +the HTML and JS needed to start it. Content-hashed `/_app/immutable/**` is cache-first; documents are network-first so a deploy lands immediately and a cache miss only matters with no signal. Cross-origin requests are never touched — a worker in front of the SDK's own offline machinery -could only get in its way. It does not register in dev, where `next dev` serves modules a stale +could only get in its way. It does not register in dev, where the dev server serves modules a stale cache would hand back. -**Installing is only ever a deliberate tap.** `utils/install.ts` holds Chrome's `beforeinstallprompt` +**Installing is only ever a deliberate tap.** `src/lib/install.svelte.ts` holds Chrome's `beforeinstallprompt` rather than letting it through, so Chrome never puts up its own banner and the only way in is the -menu row. That row lives in `AuthMenu`, which `/portal/` and `/continue/` do not render — so a +menu row. Chrome can fire that event before the module has loaded and does not replay it, so the +inline script in `src/app.html` catches it first and leaves it on `window` for the module to pick up. That row lives in `AuthMenu`, which `/portal/` and `/continue/` do not render — so a share-link visitor is never offered an install, which is right: they have not joined anything yet. Safari fires no such event at all, so on an iPhone the row explains Share → Add to Home Screen instead of offering a button that cannot work, and iPads are found by their Macintosh user agent @@ -1800,7 +1837,9 @@ reaches a cache key, and then **kills the server** and checks kip still renders. matters: the first version emulated offline through CDP, which does not apply to the fetches a service worker makes, so it watched the shell render over the live network and credited the cache. It passed with the entire offline fallback deleted. It does not cover a signed-in kip offline: that -is Firestore's persistence, which needs a real session. +is Firestore's persistence, which needs a real session. Its static server listens with a backlog of +128, because the build boots by fetching some thirty small modules at once and the default queue of +5 reset the connections that didn't fit. The export it builds talks to the REAL project, so the check fails every Firebase Auth request in the browser and asserts the share link's anonymous sign-up was attempted and blocked: without that, @@ -1808,10 +1847,21 @@ every run left an account in production. ## Web build +The client is SvelteKit 3 on Svelte 5 (runes enforced) and Vite 8, exported by `adapter-static`. `cd web && bun install`, and `cd functions && npm install` once (a separate package on the Node -runtime). `bun lint` is the gate and covers all THREE: `tsc` for the site, `tsc -p tsconfig.sw.json` -for the service worker, `biome check`, then `tsc --noEmit -p ../functions`. `bun dev` for local dev. `bun export` runs `next build` → static -site in `web/out/`. +runtime). `bun lint` is the gate and covers all THREE: `svelte-kit sync` then +`svelte-check --fail-on-warnings` for the site, `tsc -p tsconfig.sw.json` +for the service worker, `biome check`, then `tsc --noEmit -p ../functions`. `bun dev` for local dev. `bun export` runs `vite build` → static +site in `web/out/`, with a `404.html` for GitHub Pages. + +All of Kit's config is in `vite.config.ts` — Kit 3 has no `svelte.config.js` — beside the Tailwind +and icon plugins and the worker compile (see *Installing it*). There is no `$lib` either: the alias +is `#lib/*`, declared in `package.json`'s `imports`, and it needs the full file extension +(`#lib/store.svelte.ts`). Every route is prerendered with a trailing slash (`src/routes/+layout.ts`), +so anything that runs while a component initializes also runs at build with no `window`; browser +work goes in an effect or `onMount`. `src/hooks.server.ts` runs in dev and while prerendering only — +the release has no server — and besides unwrapping line breaks it preloads the Latin font file. +TypeScript is held at `~6.0.3`, because TypeScript 7 breaks `svelte-kit sync` and `svelte-check`. ## Cloud Functions @@ -1854,13 +1904,17 @@ says so on screen for exactly this reason; the symptom is otherwise indistinguis the portal. (Its string is present in a production bundle but unreachable: `usingEmulators()` folds to false there, so the badge cannot render.) -`utils/firebase.ts` reads that flag and points BOTH auth and Firestore at the emulators — both or +`src/lib/firebase.ts` reads that flag and points BOTH auth and Firestore at the emulators — both or neither, since an emulator-issued token is scoped to the emulator's project and would be refused by -the real database. The condition is ANDed with `NODE_ENV !== "production"`, and that half is what -keeps it out of a shipped bundle: a `NEXT_PUBLIC_*` flag alone compiles to a runtime read, so the +the real database. The flag is `VITE_AUTH_EMULATOR`, and the condition ANDs it with +`import.meta.env.DEV`; that half is what +keeps it out of a shipped bundle: the flag alone is replaced with whatever the build's environment +held, so the emulator's address travels into the build and only an env var stands between production and -localhost auth. With NODE_ENV in the condition the whole thing folds to a constant and the branch -is eliminated — verified by grepping the export, not assumed. +localhost auth. Vite replaces `DEV` with a literal `false` in a build, so the whole thing folds to a +constant and the branch +is eliminated — verified by grepping the export, not assumed. The pair is written out at each site +rather than read through `usingEmulators()`, so the fold doesn't depend on a function being inlined. What this is for, and what it caught: the phone path's whole point is that linking preserves the uid, and the only way to see that is to count the accounts afterwards. One account with the number @@ -1888,11 +1942,11 @@ cd web && bun run dev:emulated # one shell, serves on 3001 bun run check:portal # another ``` -It serves on **3001**, not Next's default, so an emulated server can never quietly answer for the +It serves on **3001**, with `--strictPort`, so an emulated server can never quietly answer for the ordinary `bun dev` on 3000 — the two would be indistinguishable in a browser and the emulated one has an empty database. Both the script and the check are pinned to it; `KIP_ORIGIN` still overrides. -Run it after touching the portal page, the identity sheet, or `utils/auth.ts`. Everything this path +Run it after touching the portal page, the identity sheet, or `src/lib/auth.ts`. Everything this path has broken — a link that resolved for nobody, a code step that swallowed a wrong code, a returning door that never signed anyone in — passed lint, the unit suite and the rules suite while broken, because none of those can open a page. @@ -1916,7 +1970,7 @@ next page load — and a fixture seeded against it belongs to nobody, which rend will not load. Ask the emulator instead: `POST /identitytoolkit.googleapis.com/v1/projects/demo-kip/accounts:query`. -**And attach to the browser's exceptions from OUTSIDE the page.** `app/error.tsx` catches a render +**And attach to the browser's exceptions from OUTSIDE the page.** `src/routes/+error.svelte` catches a render throw and paints "This page couldn't load" over it, so an in-page `console.error` hook installed after navigation sees nothing — the stack survives only in CDP's own `Runtime.exceptionThrown` and `Runtime.consoleAPICalled` events. Both checks now collect those and print them before their @@ -1948,7 +2002,7 @@ component state and a room page that has forgotten which slot was open is where otherwise. One thing it caught immediately, worth keeping: **a listing with no `location` crashes the room page -outright** (`DetailBlock` reads `listing.location.label`). Unreachable in production — every write +outright** (`room-detail.svelte` reads `listing.location.label`). Unreachable in production — every write path sets one — so this is a note about FIXTURES, not a bug: seed the field, or spend the time reading a stack trace for a state that cannot exist. @@ -2060,7 +2114,7 @@ reachable with no sender, and a check that quietly skips a disabled feature is h removed by accident — so the run asserts that the switch is off, refuses the press, writes no consent when pressed anyway, says why on the row rather than sitting greyed out saying nothing, and still lists the kinds a text cannot carry. Provision a number and the five states run as written. -It reads the constant out of `utils/sms.ts` rather than being told, so the two cannot disagree. +It reads the constant out of `src/lib/sms.ts` rather than being told, so the two cannot disagree. It also pins that **a full code submits itself** — `check:consent` is the only check that types one. The code goes in and nothing is clicked; the assertion is that the sheet has gone anyway. @@ -2143,9 +2197,8 @@ If port 8080 is already held by another project's emulator, switch both `firebas only because email-link sign-in rides on that provider; kip asks for no password anywhere. 3. **Firestore Database →** create (production mode). 4. **Project settings → Your apps → Web →** register an app; copy the config object into the - `firebaseConfig` in `web/utils/firebase.ts` (replacing the shipped `hafaio-kip-dev` dev - config). A blank `appId` makes `firebaseConfigured()` false and disables sign-in — the - unconfigured fallback — so keep a real `appId` for a working build. + `firebaseConfig` in `web/src/lib/firebase.ts` (replacing the shipped `hafaio-kip-dev` dev + config). 5. **Blaze plan** — required for Cloud Functions (notification email, leaving, the reaper, the text check). Set a Cloud Billing budget alert while you're there. 6. **Auth providers, and the one switch that must stay off.** Authentication → Sign-in method: @@ -2196,7 +2249,7 @@ that and nothing in the repo does it. **An origin of its own, not a path under ` (Firestore's offline cache and the auth session) and service-worker scope with every other project under `hafa.cc`, any of whose scripts could read kip's. -`auth.kip.hafa.cc` exists because it is the app's `authDomain` (`utils/firebase.ts`): the Google +`auth.kip.hafa.cc` exists because it is the app's `authDomain` (`src/lib/firebase.ts`): the Google sign-in popup opens Firebase's reserved `/__/auth/*` pages on it, so the consent screen names kip's domain instead of a `firebaseapp.com` one. **Nothing is deployed to Firebase Hosting**, and there is no `hosting` block in `firebase.json`: Firebase serves its reserved `/__/*` pages on a connected @@ -2264,7 +2317,7 @@ a booking it already cancelled and tells the other party their stay was called o attempt); and a drift check that pins the vocabulary the two packages share but can't import across: the notification kinds, the texted subset and both channels' defaults, the two map paths, the sending number, the -teardown phases in order, and the cancel reasons — an EXACT set match between `utils/types.ts` and +teardown phases in order, and the cancel reasons — an EXACT set match between `src/lib/types.ts` and `messages.ts`, plus a check that every reason `leaving.ts` writes is one the web side knows. It matches against source with comments stripped, so a string surviving only in a comment doesn't pass. Drift now fails CLOSED at the sender — an unknown kind reads as not wanted (see the SMS @@ -2288,7 +2341,7 @@ release — see Notifications), and `SITE_ORIGIN` in `functions/src/index.ts` ma where Pages actually serves. **`hafaio-kip-dev` IS production**, despite the name — `.firebaserc` and the config in -`web/utils/firebase.ts` both point at it, and it carries no fixture data. Standing up a separate +`web/src/lib/firebase.ts` both point at it, and it carries no fixture data. Standing up a separate `hafaio-kip` would mean redoing the whole one-time setup: rules, storage, functions, secrets, WIF, authorized domains. @@ -2304,7 +2357,7 @@ lines it returns alongside are deploys, not invocations. ## Known limitations / next steps -- Geocoding is via OpenStreetMap Nominatim (`utils/geocode.ts`): the listing form takes an +- Geocoding is via OpenStreetMap Nominatim (`src/lib/geocode.ts`): the listing form takes an address and looks up lat/lng/geohash (free, key-less, low-volume). Swap for Google/Mapbox if precision/volume demands. No autocomplete yet — it's a single lookup on "Find" or at submit. - Friends' listings refresh on demand (`refreshBrowse`), not live. Each fetch takes a ticket and @@ -2313,17 +2366,17 @@ lines it returns alongside are deploys, not invocations. - **Switching accounts without signing out resets every piece of session state.** A texted code or credential that already belongs to another account signs INTO it with no sign-out between, so the uid changes under a live session and every reset keyed on `!user` is skipped. The store resets - during RENDER when the uid changes, not in an effect — an effect would let that render's own - effects act for the new uid on the old account's trips and bookings first. + inside the auth listener when the uid changes, in the same turn that sets `user` — effects run + after it, so none of them can act for the new uid on the old account's trips and bookings first. - **A lost listener is re-attached, and giving up is said out loud.** A Firestore snapshot error is - TERMINAL — the SDK drops that listener and never retries — so `onSnapshotError` (`utils/firebase.ts`) + TERMINAL — the SDK drops that listener and never retries — so `onSnapshotError` (`src/lib/firebase.ts`) logging and returning left the screen frozen on its last snapshot, still styled as live. That is worse than an error: nothing looks wrong, and a place you just added simply never appears. It now also fires `onListenerLost`, and the store re-attaches every owned listener by bumping a - `generation` counter that the three subscription effects depend on. + `generation` counter that the four subscription effects depend on. - **The decision of WHEN to re-attach is a pure function** (`utils/reattach.ts`, `decideReattach`), - split out for the same reason `messages.ts` is: the effect around it needs React, Firebase and a + **The decision of WHEN to re-attach is a pure function** (`src/lib/reattach.ts`, `decideReattach`), + split out for the same reason `messages.ts` is: the effect around it needs Svelte, Firebase and a clock to exercise, the decision needs none of them, and `tests/reattach.test.ts` pins it. Backoff is `REATTACH_DELAYS` (500ms / 2s / 6s) and the burst is **debounced to one re-attach** — the owned listeners all die together whenever the token is what's refused, so nine losses must buy one retry, @@ -2343,7 +2396,7 @@ lines it returns alongside are deploys, not invocations. at once, and since a live timer IS the burst guard, leaving one armed would swallow the new session's first real loss. - **A re-attach must not reset `profileReady`** — nor `deletionReady`, which follows the same rule. `page.tsx` renders a full-screen splash whenever + **A re-attach must not reset `profileReady`** — nor `deletionReady`, which follows the same rule. `+page.svelte` renders a full-screen splash whenever that is false, so resetting it mid-session blanks the whole app — unmounting an open sheet and any half-typed form — which is the exact opposite of the invisible repair this mechanism exists to be. Which session an opening belongs to is now `gateStep`'s job (see the store bullet) — it reopens @@ -2577,8 +2630,7 @@ lines it returns alongside are deploys, not invocations. nothing takes today and which is what an email-only kind will render as when one arrives. **The whole text section is gated on `SMS_LIVE`**, which is `Boolean(SMS_FROM)` — the same - ships-able-to-be-off shape as `smsConfigured()` on the sender and `firebaseConfigured()` on the - client. kip has no number, so nothing can be sent, and a switch that records a TCPA consent for + ships-able-to-be-off shape as `smsConfigured()` on the sender. kip has no number, so nothing can be sent, and a switch that records a TCPA consent for texts that cannot be delivered is a promise the product cannot keep. It gates three things, not one: the master switch (`disabled`, with copy naming the reason rather than sitting greyed out saying nothing), `texting` itself (or an account that agreed BEFORE kip lost its number renders @@ -2592,7 +2644,7 @@ lines it returns alongside are deploys, not invocations. **TCPA consent is collected on that switch, NOT on the sheet's number field** — the reverse of what this note used to say, and the reason matters: consent to marketing-adjacent automated texts must be - UNBUNDLED from anything else. The number in `reach-field.tsx` is collected to sign in, so attaching + UNBUNDLED from anything else. The number in `reach-field.svelte` is collected to sign in, so attaching notification consent there bundles it with account creation, which is the specific pattern the rule forbids, and loads friction onto the one screen — a stranger on a share link — the whole design exists to keep frictionless. The switch carries the four required disclosures (automated texts, @@ -2609,7 +2661,7 @@ lines it returns alongside are deploys, not invocations. field existed), and removing the phone door stops the texts, so re-adding the same one asks again — taking a number off the account is the plainest way there is of saying stop texting me. The sender's check is pinned by `tests/drift.test.ts`, since nothing else can see it. - `reach-field.tsx` carries one line for it: the code step appends "Standard message rates apply." + `reach-field.svelte` carries one line for it: the code step appends "Standard message rates apply." **Changing your number re-asks in one tap; it does not transfer anything.** Bound to a number, the consent otherwise lapses the moment the number changes — the switch goes off and stays off until @@ -2690,7 +2742,7 @@ lines it returns alongside are deploys, not invocations. **A check that never comes back says so, and the server outlasts the screen.** The trigger's answer is the only thing that stops the spinner, so a single failed write left the button disabled across reloads until some unrelated setting was changed. Two halves. `probeState` - (`utils/sms.ts`, pinned in `tests/sms.test.ts`) derives `idle | checking | stalled | answered` from + (`src/lib/sms.ts`, pinned in `tests/sms.test.ts`) derives `idle | checking | stalled | answered` from the two stamps and the clock — nothing stored, so a reload can't lose a check in flight — and `CHECK_STALL_MS` is 30s rather than the portal ask's 10, because this wait legitimately contains a cold start, Twilio's own 10s timeout and a second write. A stall is not terminal: a late answer @@ -2708,7 +2760,7 @@ lines it returns alongside are deploys, not invocations. "still blocked" line could not render at all. That line is the whole reason the check is legible, since the note is otherwise identical before and after. - **The number is in both packages and pinned.** `SMS_FROM` (`utils/sms.ts`) is a second copy of + **The number is in both packages and pinned.** `SMS_FROM` (`src/lib/sms.ts`) is a second copy of `TWILIO_FROM`, since neither package can import from the other, and `tests/drift.test.ts` pins them. Wrong, Settings would tell someone to text START to a phone kip has never sent from — the message goes through, nothing changes, and nobody can see why. Both are empty until a number is @@ -2740,7 +2792,7 @@ lines it returns alongside are deploys, not invocations. **It ships able to be off, and the credential is never a deploy-time condition.** One gate — `smsConfigured()`, the empty `TWILIO_ACCOUNT_SID`/key SID/`TWILIO_FROM` constants — checked before - anything is read, written or sent, the same shape as `firebaseConfigured()` on the web side. + anything is read, written or sent. The credential itself is **fetched at first use** from Secret Manager's REST API, with the runtime service account's own metadata-server token and cached for the life of the instance. That is a @@ -2770,7 +2822,7 @@ lines it returns alongside are deploys, not invocations. than rejecting — so the consequence is minutes of delay, not lost messages. **Geography is pinned in three places and they must move together**: `parseDestination`'s US-only - refusal, `US_DIGITS` in `utils/destination.ts`, and the Firebase Auth SMS region allowlist (already + refusal, `US_DIGITS` in `src/lib/destination.ts`, and the Firebase Auth SMS region allowlist (already US-only on the live project). `textIfWanted` ALSO checks the number starts with `+1` before spending, since a number can reach an Auth account by routes the web form does not own. 10DLC is US carrier policy, so international traffic skips TCR entirely but costs 5–20× per segment and carries @@ -2788,7 +2840,7 @@ lines it returns alongside are deploys, not invocations. trigger**: availability isn't urgent, a host adding a week of dates writes several windows in one sitting, and a digest collapses that into one email and one run regardless of write volume. - **The blocker is duplication, not cost.** `searchListings` lives in `web/utils/search.ts` and the + **The blocker is duplication, not cost.** `searchListings` lives in `web/src/lib/search.ts` and the two packages can't import across each other — the same reason `NotifyKind` needs a drift test. Reimplementing date overlap, type and `distanceBetween` in `functions/` is a far bigger drift surface than a handful of string keys, and a divergence is user-visible in the worst way: the diff --git a/README.md b/README.md index 1085066..1772da9 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ one at the point of asking. Live at . -Responsive web client (Next.js, static export) synced via Firestore. Native mobile apps come later. +Responsive web client (SvelteKit, static export) synced via Firestore. Native mobile apps come later. ## Running locally @@ -26,8 +26,7 @@ bun dev The repo ships a populated dev Firebase config (`hafaio-kip-dev`), so `bun dev` gives you the real sign-in flow out of the box — an emailed one-time link, a texted code, or Google. kip has no password anywhere. To point at your own project, replace the `firebaseConfig` in -`web/utils/firebase.ts` (see [CLAUDE.md](./CLAUDE.md) for the full setup walkthrough). Blanking its -`appId` disables sign-in for a config-free build. +`web/src/lib/firebase.ts` (see [CLAUDE.md](./CLAUDE.md) for the full setup walkthrough). ## License diff --git a/functions/src/index.ts b/functions/src/index.ts index 230ff59..b2dfc19 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -60,7 +60,7 @@ const SENDER = "noreply@kip.hafa.cc"; const RESEND_API_KEY = defineSecret("RESEND_API_KEY"); // Empty, so SMS is off: `smsConfigured()` is checked before anything is read, -// written or sent, the same shape `firebaseConfigured()` has on the web side. +// written or sent. // Filling these in is not enough on its own — until the 10DLC campaign is // approved every US send is blocked with 30034 and billed anyway. // diff --git a/web/.gitignore b/web/.gitignore index 6153d2a..689216b 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -1,17 +1,7 @@ node_modules/ -.next/ +.svelte-kit/ out/ -*.tsbuildinfo .DS_Store -# Generated, and its content depends on which command wrote it last: `next dev` -# points the route-types import at .next/dev, `next build` at .next. Tracking it -# meant a permanently dirty file that a bare commit could sweep up. `tsc` passes -# without it entirely, so it is regenerated locally and needed by nothing. -next-env.d.ts -# Written by `next dev`, and re-added if deleted. -AGENTS.md -CLAUDE.md - -# Compiled from sw/sw.ts (see next.config.js). -public/sw.js +# Compiled from sw/sw.ts (see vite.config.ts). +static/sw.js diff --git a/web/app/about/page.tsx b/web/app/about/page.tsx deleted file mode 100644 index 0006fb2..0000000 --- a/web/app/about/page.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import type { Metadata } from "next"; -import Link from "next/link"; -import type { ReactElement } from "react"; -import DocPage, { H2, link, P } from "../../components/doc-page"; -import { HAFA_URL, REPO_URL } from "../../utils/contact"; - -export const metadata: Metadata = { - title: "About kip", - description: "A friends-only way to share space. No listings, no payments.", -}; - -export default function AboutPage(): ReactElement { - return ( - -

- kip is a friends-only way to share space. List a spare room — or your - whole place while you're away — mark the dates it's free, and let - friends stay for nothing. No public listings, no payments, no fees. -

- -

How it works

-

- Nothing on kip is public. There's no search page, no - directory, no map of places. A fresh account can't be found by anyone: - you become reachable only when you choose to — by picking a username - friends can search for, or by sending someone a share link you can - revoke at any time. -

-

- Places are visible to friends only. When you list a - room, the only people who can ever see it are the friends you've - connected with — and, if you send one, whoever holds a share link you - created for that room or those dates. A friend asks for dates, you say - yes (or mark dates “instant” so friends can book them - first-come-first-served), and that's a stay. -

-

- Share links work before someone has an account. Send a - link to your sister and she can see what's free immediately; she only - needs an account at the moment she asks for something. No password - anywhere — you get in by an emailed link, a texted code, or Google. -

-

- No money changes hands. kip has no payments and no - service fee. -

- -

Who makes it

-

- kip is a{" "} - - hafa - {" "} - project. It doesn't charge, doesn't show ads, and doesn't sell anything - — including data. What kip knows about you and why is written down - plainly in the{" "} - - Privacy Policy - - . If something's broken, or you just want to reach us, the{" "} - - Help page - {" "} - says how. -

-

- The code is open source under the MIT license:{" "} - - github.com/hafacc/kip - - . Every version of the app — and of these pages — is public in that - repository's history. -

-
- ); -} diff --git a/web/app/continue/page.tsx b/web/app/continue/page.tsx deleted file mode 100644 index ed36ebe..0000000 --- a/web/app/continue/page.tsx +++ /dev/null @@ -1,331 +0,0 @@ -"use client"; - -import { signInWithEmailLink } from "firebase/auth"; -import { - type ReactElement, - useCallback, - useEffect, - useRef, - useState, -} from "react"; -import ThemeButton from "../../components/theme-button"; -import { Mark } from "../../components/wordmark"; -import { tokenExpired } from "../../utils/auth"; -import { auth, firebaseConfig } from "../../utils/firebase"; - -// Its own route rather than a mode of `/portal/`, because that page mints an -// anonymous account on load — this one must not, since it has no use for a -// session and every landing would leave a throwaway behind. A skip flag would -// have been an invariant held by a comment. -// -// Two jobs behind one URL, told apart by whether an ID token rides along. -// ATTACHING (token present) links the address to the anonymous account already -// asking somewhere else and never signs in here — which is what makes it survive -// a mail app's throwaway browser. RETURNING (no token) is a real sign-in on this -// device, for someone coming back with no session at all. - -// One HTTPS round trip to a warm endpoint, with a person watching a page that -// offers nothing else. Deliberately shorter than the portal's ten seconds, which -// covers a dependency chain with the SDK's own retries behind it. -const CONTINUE_TIMEOUT_MS = 6_000; - -// `connectAuthEmulator` points the SDK, and nothing points a raw fetch — so -// attaching was the one door a local run could not open, while returning (an SDK -// call) worked. Written out rather than read off a helper for the reason the -// same pair is written out in `utils/firebase.ts`: Next inlines NODE_ENV, so the -// whole ternary folds and no localhost address reaches a production bundle. -function identityToolkit(): string { - return process.env.NODE_ENV !== "production" && - process.env.NEXT_PUBLIC_AUTH_EMULATOR === "1" - ? "http://127.0.0.1:9099/identitytoolkit.googleapis.com" - : "https://identitytoolkit.googleapis.com"; -} - -type Outcome = "working" | "done" | "expired" | "stalled" | "taken" | "failed"; - -// The one refusal that is worth naming, because it is the one nobody can fix -// from anywhere else: kip cannot merge two accounts, and the ask this link -// belongs to is held by the account that was asking, not by the address. -const EMAIL_EXISTS = "EMAIL_EXISTS"; - -// What the link carried. -type Link = { - // The URL as it arrived. `signInWithEmailLink` parses the code out of it - // itself, so it has to be kept rather than rebuilt. - href: string; - idToken: string; - email: string; - oobCode: string; - host: string | null; -}; - -function readLink(): Link | null { - // kip's half in the fragment, Firebase's `oobCode` in the query — see - // `continueUrl` in `utils/auth.ts`. - const carried = new URLSearchParams(window.location.hash.replace(/^#/, "")); - const query = new URLSearchParams(window.location.search); - const email = carried.get("email"); - if (!email) { - // No fragment. Refused rather than mistaken for a returning link, which - // would sign them into a second account. - return null; - } else { - return { - href: window.location.href, - idToken: carried.get("idToken") ?? "", - email, - oobCode: query.get("oobCode") ?? "", - host: carried.get("host"), - }; - } -} - -export default function ContinuePage(): ReactElement { - const [outcome, setOutcome] = useState("working"); - const [mode, setMode] = useState<"attach" | "return">("attach"); - const [host, setHost] = useState(null); - - // The one-time code may be spent ONCE. React's StrictMode mounts every effect - // twice in dev, and the cleanup below only stops the first run from writing - // state — it cannot recall a request already in flight. Without this the - // second mount always found the code spent, so the email door was the one - // path a local run could never verify. - const spent = useRef(null); - // Which attempt owns a failure or a stall. Cancelling is a newer attempt - // starting, not a teardown: StrictMode's remount is refused by `spent`, so a - // teardown that muted the first left the page on "working" with no answer and - // no timer. A success belongs to every attempt — see `finished`. - const attempt = useRef(0); - // Set by the first attempt that succeeds, whichever it was. A retry after a - // stall resends a code the stalled call may yet spend, so the retry's refusal - // can arrive before, or after, the success it lost to. - const finished = useRef(false); - const link = useRef(null); - - const attach = useCallback((retrying = false): void => { - link.current ??= readLink(); - const held = link.current; - - if (!held) { - setOutcome("failed"); - return; - } - const { idToken, email } = held; - setHost(held.host); - // The discriminator between the two modes. An ID token means an anonymous - // account is asking for this address to be attached to it; its absence means - // someone is coming back to an account they already have, and the same call - // signs them in instead of linking. - const returning = !idToken; - setMode(returning ? "return" : "attach"); - - // Before spending the one-time code, never after: the code outlives our - // token, so an expired open must not burn one that still works. A returning - // link carries no token, so only the code's own lifetime applies. - if (!returning && tokenExpired(idToken)) { - setOutcome("expired"); - return; - } - - const code = held.oobCode; - // Only refuses the second MOUNT; a retry deliberately resends. Checked - // before claiming an attempt, or the refused run takes ownership from the - // one still in flight. - if (!retrying && spent.current === code) return; - spent.current = code; - - const mine = ++attempt.current; - const live = () => mine === attempt.current; - // A stall is only ever provisional: the call may still be in flight, and if - // it lands the answer replaces this. Retry is offered on the timeout alone, - // because a call that ANSWERED has already spent the one-time code — posting - // it again would report failure for a flow that worked. - const timer = setTimeout(() => { - if (live() && !finished.current) setOutcome("stalled"); - }, CONTINUE_TIMEOUT_MS); - - // Two calls, because the modes want different things from the answer. - // Returning needs a SESSION in this browser, and only the SDK can persist - // one — the raw REST call hands back tokens with nowhere to put them, so the - // page would say "you're signed in" over a browser that is not, and every - // attempt would burn a one-time code. Attaching wants no session at all: - // passing an idToken makes the endpoint LINK the address to that account - // rather than mint one, and this browser is disposable. - const work: Promise = returning - ? signInWithEmailLink(auth(), email, held.href) - : fetch( - `${identityToolkit()}/v1/accounts:signInWithEmailLink?key=${firebaseConfig.apiKey}`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, oobCode: code, idToken }), - }, - ).then(async (response) => { - if (response.ok) return; - const refusal = await response - .json() - .then((body) => body?.error?.message) - .catch(() => null); - throw new Error(refusal === EMAIL_EXISTS ? EMAIL_EXISTS : "refused"); - }); - - work - .then((): Outcome => "done") - .catch( - (error: Error): Outcome => - error.message === EMAIL_EXISTS ? "taken" : "failed", - ) - .then((result) => { - if (finished.current) return; - if (result === "done") finished.current = true; - else if (!live()) return; - clearTimeout(timer); - setOutcome(result); - }); - }, []); - - useEffect(() => { - attach(); - }, [attach]); - - // A new attempt is the cancellation: the old one no longer owns a failure. - function retry(): void { - setOutcome("working"); - attach(true); - } - - // Returning ends in the app itself: the sign-in has happened and this is the - // device they want it on, so a panel with a button on it is a step between - // someone and the thing they asked for. Attaching stays where it is — that - // browser is disposable and the session it belongs to is somewhere else. - const arriving = mode === "return" && outcome === "done"; - const left = useRef(false); - useEffect(() => { - // StrictMode runs this twice, and the guard costs less than reasoning about - // whether a second navigation to the same URL is harmless. - if (!arriving || left.current) return; - left.current = true; - // Replace, not assign: the code in this URL has been spent, so Back must - // not lead back to it, and the app is entered on a URL that never held it. - window.location.replace(`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/`); - }, [arriving]); - - // Empty when the address was added from inside the app rather than beside an - // ask, where there is no request to mention at all. - const who = host || null; - - return ( -
-
- -
-
- - - {/* Static, and true whichever way the call goes. A script that never - runs at all has still said the thing that matters — which a timeout - cannot do, because a timeout is also script. It cannot name the host - for the same reason: one exported page, and the query is unreadable - before hydration. */} -

- This page only finishes what the email started — nothing you've - already done depends on it. -

- - {/* `arriving` holds the page on its quiet state while the browser - leaves, rather than flashing a panel nobody is meant to read. */} - {outcome === "working" || arriving ? null : outcome === "done" ? ( -
-

You're set

-

- {who - ? `Your email is attached, and your request to ${who} is already on its way.` - : "Your email is attached."}{" "} - Your kip lives in the browser you started from — you can close - this page and head back there. -

-
- ) : outcome === "expired" ? ( -
-

- This link expired -

-

- Links like this only work for about an hour. Open the link your - friend sent you again and kip will offer a fresh one — your - request is unaffected. -

-
- ) : outcome === "taken" ? ( -
-

- That address already has a kip -

-

- It belongs to another account, and kip can't combine two. Open kip - where you already use that address, or sign back in with it from - kip's front page. Whatever you asked for still stands where you - asked it. -

-
- ) : mode === "return" ? ( -
-

- {outcome === "stalled" - ? "This page can't get through" - : "That link didn't sign you in"} -

-

- {outcome === "stalled" - ? "Try again, or open kip and ask for a fresh link." - : "It may have been used already, or be too old. Open kip and ask for a fresh one."} -

-
- {outcome === "stalled" ? ( - - ) : null} - - Open kip - -
-
- ) : ( -
-

- {outcome === "stalled" - ? "This page can't get through" - : "That didn't work"} -

-

- {outcome === "stalled" - ? "Nothing is lost. Try again, or close this and finish from where you started." - : "This link can't be used. Nothing is lost: whatever you asked for still stands where you asked it."} -

- {/* Only after a timeout, where the call may never have run. A call - that ANSWERED has spent the one-time code, so retrying it fails - identically every press — the refusal is the answer, not a - hiccup. */} - {outcome === "stalled" ? ( - - ) : null} -
- )} -
-
- ); -} diff --git a/web/app/error.tsx b/web/app/error.tsx deleted file mode 100644 index 950ff79..0000000 --- a/web/app/error.tsx +++ /dev/null @@ -1,40 +0,0 @@ -"use client"; - -import { type ReactElement, useEffect } from "react"; -import Button from "../components/ui/button"; -import { Mark } from "../components/wordmark"; - -// Without this, Next's own fallback offers a way "back" to `/`, which under a -// base path is outside kip altogether. -export default function ErrorPage({ - error, -}: { - error: Error & { digest?: string }; -}): ReactElement { - useEffect(() => { - console.error(error); - }, [error]); - - return ( -
- -

- This page couldn't load -

-

- Something went wrong on our side. Reloading usually fixes it. -

-
- - {/* A full load, not a router push: the app is one route, so a push to - it from inside it would leave this boundary standing. */} - - Home - -
-
- ); -} diff --git a/web/app/help/page.tsx b/web/app/help/page.tsx deleted file mode 100644 index f552298..0000000 --- a/web/app/help/page.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import type { Metadata } from "next"; -import Link from "next/link"; -import type { ReactElement } from "react"; -import DocPage, { H2, List, link, P } from "../../components/doc-page"; -import { CONTACT_EMAIL, ISSUES_URL } from "../../utils/contact"; - -export const metadata: Metadata = { - title: "Help — kip", - description: - "What kip is, how to stop its texts, and where to reach a person.", -}; - -export default function HelpPage(): ReactElement { - return ( - -

Did kip text you?

-

- kip is a small, friends-only app for lending spare rooms between friends - — no money, no public listings. It's at{" "} - - kip.hafa.cc - - . -

-

- kip only ever texts people about their own account, and only if they - turned texts on: someone asked to stay at a place you listed, a host - answered your request, a confirmed stay was called off, or someone - agreed to be friends. It never sends marketing. -

-

- To stop the texts, reply STOP to the message. That - works instantly, whoever you are — you don't need a kip account. (If you - use kip, you can also turn texts off in Settings → Notifications.) If a - text reached you and you've never used kip, the number may have - previously belonged to someone who did; STOP ends it either way. -

- -

Something's broken, or you have a question

- -
  • - The quickest route: tap your picture in kip, then{" "} - Send feedback. It goes straight to us, needs no - account anywhere else, and isn't posted publicly. -
  • -
  • - Bugs and questions:{" "} - - open an issue on GitHub - {" "} - — public, and the fastest way to get something fixed. -
  • -
  • - Private matters — a privacy request, a safety - concern, anything you'd rather not post publicly: email{" "} - - {CONTACT_EMAIL} - - . It goes straight to us, not onto the public issue tracker. -
  • -
    - -

    The fine print

    -

    - What kip is:{" "} - - About - - . What it stores and why:{" "} - - Privacy Policy - - . The rules:{" "} - - Terms - - . -

    -
    - ); -} diff --git a/web/app/layout.tsx b/web/app/layout.tsx deleted file mode 100644 index a99a5df..0000000 --- a/web/app/layout.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import type { Metadata, Viewport } from "next"; -import { Plus_Jakarta_Sans } from "next/font/google"; -import { ThemeProvider } from "next-themes"; -import type { ReactElement, ReactNode } from "react"; -import DialogProvider from "../components/dialog"; -import EmulatorBadge from "../components/emulator-badge"; -import NameGateProvider from "../components/name-gate"; -import Pwa from "../components/pwa"; -import ThemeColor from "../components/theme-color"; -import { KipProvider } from "../utils/store"; -import "./globals.css"; - -const jakarta = Plus_Jakarta_Sans({ - subsets: ["latin"], - weight: ["400", "500", "600", "700", "800"], - variable: "--font-jakarta", - display: "swap", -}); - -export const metadata: Metadata = { - title: "kip", - description: "Share a spare room or your whole place with friends, for free.", - // Safari reads none of the manifest for Add to Home Screen; it wants these. - appleWebApp: { capable: true, title: "kip", statusBarStyle: "default" }, - // Setting `icons` at all stops Next adding app/icon.svg on its own, so it is - // named here too. - icons: { - icon: `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/icon.svg`, - apple: `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/apple-touch-icon.png`, - }, -}; - -// The first paint only, and keyed on the SYSTEM preference because static HTML -// has nothing else to key on — `ThemeColor` corrects both to the theme actually -// resolved as soon as it runs. Values are `--color-bg` from globals.css. -export const viewport: Viewport = { - themeColor: [ - { media: "(prefers-color-scheme: light)", color: "#f6f1ea" }, - { media: "(prefers-color-scheme: dark)", color: "#161009" }, - ], -}; - -export default function RootLayout({ - children, -}: { - children: ReactNode; -}): ReactElement { - return ( - - {/* crossOrigin must match the SDK's CORS fetch or the preconnected - socket isn't reused; photos are plain , so none. */} - - - - - - - - - - {/* Inside KipProvider: it reads the profile to know whether a name - is needed at all. It gates ITSELF to the app's own routes — - `/portal/` and `/continue/` carry their own sheets, and its - auto-open would stack a second, undismissable one over them. */} - - {children} - - - - - - - - - ); -} diff --git a/web/app/manifest.ts b/web/app/manifest.ts deleted file mode 100644 index dc148b0..0000000 --- a/web/app/manifest.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { MetadataRoute } from "next"; - -// Nothing here is prefixed for us — `start_url`, `scope` and every icon path -// are taken literally — so a build under a base path has to add it itself. -const base = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; - -// A static export has no request to vary on. -export const dynamic = "force-static"; - -// `scope` deliberately covers the whole app, so /portal/ and /continue/ open -// inside an installed kip too — a share link handed to someone who has it -// installed should not bounce them out to a browser tab. -export default function manifest(): MetadataRoute.Manifest { - return { - name: "kip", - short_name: "kip", - description: - "Share a spare room or your whole place with friends, for free.", - start_url: `${base}/`, - scope: `${base}/`, - display: "standalone", - background_color: "#f6f1ea", - theme_color: "#f6f1ea", - icons: [ - { src: `${base}/icon-192.png`, sizes: "192x192", type: "image/png" }, - { src: `${base}/icon-512.png`, sizes: "512x512", type: "image/png" }, - // Full-bleed, with the mark inside the safe circle: Android crops a - // non-maskable icon to whatever shape the launcher uses, which would take - // the edges off the disc. - { - src: `${base}/icon-maskable-512.png`, - sizes: "512x512", - type: "image/png", - purpose: "maskable", - }, - ], - }; -} diff --git a/web/app/page.tsx b/web/app/page.tsx deleted file mode 100644 index f0b6ff1..0000000 --- a/web/app/page.tsx +++ /dev/null @@ -1,304 +0,0 @@ -"use client"; - -import { type ReactElement, useEffect, useRef } from "react"; -import { LuArrowLeft } from "react-icons/lu"; -import AuthMenu from "../components/auth-menu"; -import BookingPage from "../components/booking-page"; -import BrowseView from "../components/browse-view"; -import DeletionScreen from "../components/deletion-screen"; -import FeedbackView from "../components/feedback-view"; -import FriendsPanel from "../components/friends-panel"; -import HomeView from "../components/home-view"; -import ListingFormScreen from "../components/listing-form-screen"; -import { FloatingDock, TopBar } from "../components/nav"; -import PersonPage from "../components/person-page"; -import PlacesView from "../components/places-view"; -import RoomPage from "../components/room-page"; -import SettingsView from "../components/settings-view"; -import ThemeButton from "../components/theme-button"; -import TripsView from "../components/trips-view"; -import Button from "../components/ui/button"; -import IconButton from "../components/ui/icon-button"; -import WelcomeScreen from "../components/welcome-screen"; -import Wordmark, { Mark } from "../components/wordmark"; -import { historyScroll, rememberScroll, useKip } from "../utils/store"; -import type { Screen, View } from "../utils/types"; - -const TITLES: Record = { - home: "Home", - browse: "Browse", - places: "Places", - friends: "Friends", - trips: "Trips", - settings: "Settings", - feedback: "Feedback inbox", -}; - -function CurrentScreen({ screen }: { screen: Screen }): ReactElement { - switch (screen.kind) { - case "person": - return ; - case "room": - return ; - case "booking": - return ; - case "listing-form": - return ; - case "tab": - switch (screen.tab) { - case "home": - return ; - case "browse": - return ; - case "places": - return ; - case "friends": - return ; - case "trips": - return ; - case "settings": - return ; - // Guessing the fragment renders an empty list rather than a leak: every - // read behind it is refused by the rules for anyone but the operator. - case "feedback": - return ; - } - } -} - -// Reload rather than a retry button: the store has already retried and given up, -// and a reload is what re-runs auth from scratch, which covers the likeliest -// causes. A client cannot talk a server out of a refusal, so this is disclosure. -function StaleDataNotice(): ReactElement { - return ( -
    -

    - kip stopped receiving updates, so this screen may be out of date. -

    - -
    - ); -} - -// In place of the splash once the profile gate has given up — the splash claims -// something is still on its way. Deliberately BEHIND the gate: the other reading -// of an unanswered profile is "no profile", which is onboarding and an overwrite. -// Nothing here is terminal: a late answer opens the gate and this unmounts itself. -function Unreachable({ canSignOut }: { canSignOut: boolean }): ReactElement { - const { signOut } = useKip(); - - async function doSignOut() { - try { - await signOut(); - } catch (error) { - console.error(error); - } - } - - return ( -
    -

    - Can't reach kip right now -

    -

    - Nothing is lost — this device just can't get through to kip. Check your - connection and try again. -

    -
    - - {/* A session the server refuses outright — a disabled account, a - revoked token — lands here too, and reloading hits the same wall. - Withheld from a session with nothing to sign back in with: there it - destroys the account outright, dressed as recovery advice. */} - {canSignOut ? ( - - ) : null} -
    -
    - ); -} - -function Splash(): ReactElement { - return ( -
    - -
    - ); -} - -function useScreenTitle(screen: Screen): string { - const { user, friends } = useKip(); - switch (screen.kind) { - case "tab": - return TITLES[screen.tab]; - case "room": - return "Place"; - case "booking": - return "Booking"; - case "listing-form": - return screen.id ? "Edit place" : "Add place"; - case "person": { - if (screen.id === user?.uid) return "You"; - const friend = friends.find((candidate) => candidate.uid === screen.id); - return friend ? friend.displayName.split(" ")[0] : "Profile"; - } - } -} - -export default function Page(): ReactElement { - const { - authReady, - listenersLost, - user, - anonymous, - profileReady, - profileUnreachable, - deletion, - deletionReady, - screen, - canGoBack, - popped, - back, - setView, - } = useKip(); - const title = useScreenTitle(screen); - const scroller = useRef(null); - // True when the change came from history rather than a tap. - const restoring = useRef(false); - const lastPopped = useRef(popped); - if (lastPopped.current !== popped) { - lastPopped.current = popped; - restoring.current = true; - } - - // The screen's identity, not the object, which is rebuilt on every render. - // windowId excluded: a slot is a sheet over the room and must not reset scroll. - const screenKey = `${screen.kind}:${"id" in screen ? screen.id : ""}:${ - screen.kind === "tab" ? screen.tab : "" - }`; - // Written continuously, not on the way out: a browser Back gives no chance to - // save anything first. - useEffect(() => { - const element = scroller.current; - if (!element) return; - let queued = 0; - const onScroll = () => { - window.clearTimeout(queued); - queued = window.setTimeout( - () => rememberScroll(element.scrollTop || window.scrollY), - 150, - ); - }; - element.addEventListener("scroll", onScroll, { passive: true }); - window.addEventListener("scroll", onScroll, { passive: true }); - return () => { - window.clearTimeout(queued); - element.removeEventListener("scroll", onScroll); - window.removeEventListener("scroll", onScroll); - }; - }, []); - - // Opening a screen starts at its top; returning puts it back where you left it. - // Instant, never smooth — an animation on something that has only just appeared - // reads as the page moving under you. Both the scroller and `window`, since - // which one actually scrolls depends on the viewport. - // biome-ignore lint/correctness/useExhaustiveDependencies: the keys ARE the dependencies — the effect reads nothing from them and exists only to fire when they change. - useEffect(() => { - const to = restoring.current ? historyScroll() : 0; - restoring.current = false; - const apply = () => { - scroller.current?.scrollTo({ top: to, behavior: "instant" }); - window.scrollTo({ top: to, behavior: "instant" }); - return (scroller.current?.scrollTop ?? window.scrollY) >= to; - }; - if (apply() || to === 0) return; - // You can't scroll to 900px until something is 900px tall, and a list you're - // returning to may not have refetched yet — so retry while it fills in. - // Landing short is the honest failure: too high, never somewhere unvisited. - const timers = [60, 160, 320, 640].map((delay) => - window.setTimeout(apply, delay), - ); - return () => { - for (const timer of timers) window.clearTimeout(timer); - }; - }, [screenKey, popped]); - - if (!authReady) return ; - // Only a session-less visitor is turned away. An anonymous one is a - // participant or about to be: they may hold a name, an ask, even friendships, - // and every rule they meet is blind to how they signed in. - if (!user) return ; - // Ahead of the profile gate, because the teardown DELETES the profile partway - // through and the other reading of a missing one is onboarding — which would - // put someone who asked to leave in front of a form asking their name, and - // write it back. `deletionReady` is what stops the app being drawn in the beat - // before the answer arrives. - if (deletion) return ; - if (!profileReady || !deletionReady) - return profileUnreachable ? ( - - ) : ( - - ); - - const isHome = screen.kind === "tab" && screen.tab === "home"; - - return ( -
    -
    - {canGoBack ? ( - - - - ) : null} - {isHome ? ( - - ) : ( -

    - {title} -

    - )} -
    - - -
    -
    - - - -
    -
    - {canGoBack ? ( -
    - - - - {title} -
    - ) : null} - {listenersLost ? : null} - -
    -
    - - -
    - ); -} diff --git a/web/app/portal/page.tsx b/web/app/portal/page.tsx deleted file mode 100644 index 4eb1afb..0000000 --- a/web/app/portal/page.tsx +++ /dev/null @@ -1,976 +0,0 @@ -"use client"; - -import { - type ReactElement, - useCallback, - useEffect, - useRef, - useState, -} from "react"; -import { FaGoogle } from "react-icons/fa"; -import { LuBed, LuMapPin } from "react-icons/lu"; -import Avatar from "../../components/avatar"; -import { PhotoGallery } from "../../components/cover-photo"; -import ReachField, { - codeReady, - confirmReach, - EMPTY_REACH, - type ReachState, - reachError, - sendReach, -} from "../../components/reach-field"; -import { groupByOffer, OfferHeading } from "../../components/rooms"; -import SiteFooter from "../../components/site-footer"; -import ThemeButton from "../../components/theme-button"; -import Busy from "../../components/ui/busy"; -import Button from "../../components/ui/button"; -import Chip from "../../components/ui/chip"; -import Input from "../../components/ui/input"; -import Sheet from "../../components/ui/sheet"; -import Wordmark, { Mark } from "../../components/wordmark"; -import { - fetchMyBookingsWith, - requestStayViaPortal, - SlotGone, -} from "../../utils/bookings"; -import { - clientState, - type DebugDetail, - recordDebugEvent, -} from "../../utils/debug"; -import { auth, errorCode } from "../../utils/firebase"; -import { formatDateRange, nights } from "../../utils/format"; -import { areFriends } from "../../utils/friends"; -import { - listingTypeIcon, - listingTypeLabel, - placeTypeLabel, -} from "../../utils/listings"; -import { claimGrant, fetchPortalPage } from "../../utils/portals"; -import { - fetchMyConnectRequest, - sendPortalConnectRequest, -} from "../../utils/requests"; -import { useKip } from "../../utils/store"; -import type { - Portal, - PortalContent, - PortalListing, - PortalWindow, -} from "../../utils/types"; -import { validateDisplayName } from "../../utils/username"; - -type LoadState = "loading" | "ready" | "missing" | "unreachable"; - -// A read that never reached the server is not an answer about the link. Offline -// the two are indistinguishable to the code and opposite to the reader: one says -// their friend revoked it, the other that they are in a tunnel. Same distinction -// the profile gate draws, for the same reason — a cached absence proves nothing. -function unreachable(error: unknown): boolean { - if (typeof navigator !== "undefined" && navigator.onLine === false) { - return true; - } - const code = (error as { code?: string })?.code ?? ""; - return ( - code === "unavailable" || - code === "auth/network-request-failed" || - code === "deadline-exceeded" - ); -} - -// Stands in for a window id when the ask carries no dates. -const FRIEND_ONLY = "__friend__"; - -// What the visitor tapped, held while they make an account. A null window means -// they asked to connect rather than for specific dates. -type Ask = { listingId: string | null; window: PortalWindow | null }; - -// Why an ask never went out, and the advice differs for every one: a refused -// write may mean a revoked link, a stall never reached the server, and the four -// slot causes are the world having moved rather than anything being wrong. -type Failure = "refused" | "stalled" | "taken" | "moved" | "removed" | "past"; - -// Said in the visitor's terms, never the rule's. The four slot causes read as -// news about the dates rather than as something they did wrong, because that is -// what they are — and each names what happened, since "unavailable" would leave -// someone wondering whether to wait or ask for something else. -const FAILURE_COPY: Record = { - refused: "That didn't go through — the link may have been turned off.", - stalled: - "Couldn't reach kip just now, so nothing was sent. Check your connection.", - taken: "Someone else took those dates while you were deciding.", - moved: "Those dates changed, so nothing was sent. Take another look.", - removed: "Those dates aren't offered any more.", - past: "Those dates have already been and gone.", -}; - -// The only timeout in the flow: this page is outside the app's gate, so the -// Unreachable screen never renders here. A stall nobody else names ends here. -const ASK_TIMEOUT_MS = 10_000; - -// Everything about the visitor's relationship with this host, answered in one -// go. Dates stay a LIST rather than a flag, or one pending friend request -// suppresses every Request button on the page — bookings have auto-ids and -// nothing stops a visitor asking for several ranges. -// -// Null means not answered YET, which is why the connect control waits for it -// rather than guessing: the three states are mutually exclusive, and the wrong -// guess offers an ask that can only be refused. -type Standing = { - windowIds: readonly string[]; - confirmed: boolean; - connectPending: boolean; - friend: boolean; -}; - -// What the connect control has to say. `none` covers the host themselves and -// anyone already connected — neither has anything left to ask for. `unknown` is -// the lookup not having answered yet, where drawing an ask would be a guess. -type Connect = "ask" | "sent" | "none" | "unknown"; - -// The one screen outside the auth gate. Nothing here needs an account: browsing -// needs no identity at all, and asking needs only a name. For a visitor with no -// name yet every button is live from the first paint; a named one's connect -// control waits on the standing lookup. Tapping holds the ask and asks who they -// are in place. -export default function PortalPage(): ReactElement { - const { - user, - anonymous, - profile, - profileReady, - profileUnreachable, - ensureAnonymous, - } = useKip(); - const [page, setPage] = useState(null); - // The portal doc on its own, a round trip ahead of `page`. A SLOT link carries - // its room here too, but only `page` has the DATES, and a room rendered - // without them claims "No open dates right now" — so nothing below the host - // block is drawn from this. Superseded the moment `page` lands. - const [owner, setOwner] = useState(null); - const [state, setState] = useState("loading"); - const [standing, setStanding] = useState(null); - const [busy, setBusy] = useState(null); - const [ask, setAsk] = useState(null); - // Carries the ask it came from, so trying again is one tap rather than a hunt - // back to a button that may now be scrolled off or behind the error. - const [failed, setFailed] = useState<{ reason: Failure; ask: Ask } | null>( - null, - ); - // Lifted out of the form: the form unmounts the moment the profile lands, and - // these are the two things that still have something to say afterwards. - const [sentTo, setSentTo] = useState(null); - const [notice, setNotice] = useState(null); - // The rooms didn't load, but the host block did — a different failure from a - // link that never resolved, and it must not overwrite what's already on screen. - const [roomsFailed, setRoomsFailed] = useState(false); - // Everything that needs the ROOMS reads `page`; everything that needs only the - // host reads this, so the header can render while the rest is still arriving. - const portal = page?.portal ?? owner; - - // Anonymous sign-in gives the live reads an identity to hang a grant on. The - // hashchange listener matters because pasting a different link changes only the - // fragment, which the browser treats as the same document — no reload. - const [token, setToken] = useState(""); - // For async work that outlives a fragment change and must not land on the - // next link's page. - const currentToken = useRef(""); - useEffect(() => { - const read = () => { - currentToken.current = window.location.hash.slice(1); - setToken(currentToken.current); - }; - read(); - window.addEventListener("hashchange", read); - return () => window.removeEventListener("hashchange", read); - }, []); - - useEffect(() => { - if (!token) { - setState("missing"); - return; - } - let live = true; - // Tracked here rather than off `owner`, which this closure captured as null. - let painted = false; - setState("loading"); - setPage(null); - setOwner(null); - setRoomsFailed(false); - setStanding(null); - // A held ask names a portal that is about to stop existing, and the send - // effect would refuse it forever without saying so. - setAsk(null); - setFailed(null); - // Handed over in flight: the portal doc needs no identity to read. - fetchPortalPage(token, ensureAnonymous(), (found) => { - if (!live) return; - painted = true; - setOwner(found); - setState("ready"); - }) - .then((found) => { - if (!live) return; - setPage(found); - setState(found ? "ready" : "missing"); - }) - .catch((error: unknown) => { - console.error(error); - if (!live) return; - // Once the host block is up, the link demonstrably resolved, so - // replacing the page with "this link isn't active" would be a lie about - // what went wrong — and a jarring one, since they can already see it did. - if (painted) setRoomsFailed(true); - else setState(unreachable(error) ? "unreachable" : "missing"); - }); - return () => { - live = false; - }; - }, [token, ensureAnonymous]); - - // Reported separately, so neither ask can hide the other's affordance. Keyed - // on the host's uid rather than on `portal`, which is now a DIFFERENT object - // each time it improves — first the portal doc, then the fuller one — and - // would otherwise run this, and its three reads, twice on every load. - const hostId = portal?.ownerId ?? null; - useEffect(() => { - if (!user || !hostId) return; - let live = true; - Promise.all([ - fetchMyBookingsWith(user.uid, hostId), - fetchMyConnectRequest(user.uid, hostId), - areFriends(user.uid, hostId), - ]) - .then(([bookings, request, friend]) => { - if (!live) return; - const current = bookings.filter( - (booking) => booking.status !== "CANCELLED", - ); - setStanding({ - windowIds: current.map((booking) => booking.windowId), - confirmed: current.some((booking) => booking.status === "CONFIRMED"), - connectPending: request !== null, - friend, - }); - }) - // Offer the ask rather than wait for ever: a redundant ask is refused - // or harmless, while `unknown` has no control on it at all. - .catch((error: unknown) => { - console.error(error); - if (!live) return; - setStanding({ - windowIds: [], - confirmed: false, - connectPending: false, - friend: false, - }); - }); - // Runs for an anonymous visitor too, and must: they are the ones who ask. - // Someone returning to their own pending ask hours later is signed in as the - // same account (Firebase persists to IndexedDB), so this is what recognises - // them instead of offering an ask they already made. - return () => { - live = false; - }; - }, [user, hostId]); - - // Written during render so a failure reports the state it actually failed in, - // rather than whatever the effect closed over when it armed — for the timer - // that is up to ten seconds of drift. Same shape as `lastPopped` in page.tsx. - const vector = useRef({}); - vector.current = { - anonymous, - profileReady, - profileUnreachable, - hasProfile: profile !== null, - hasName: Boolean(profile?.displayName), - hostLoaded: portal !== null, - roomsLoaded: page !== null, - }; - // These failures throw nothing, so the state that decided them is the whole - // report. Stable, so effects can call it without re-running on every render. - const report = useCallback((reason: Failure, extra: DebugDetail = {}) => { - recordDebugEvent("portal-ask", { - reason, - ...vector.current, - ...clientState(), - ...extra, - }); - }, []); - - // Cleared first, so a later dependency change can't fire a second request. The - // name comes off the kip profile, never `user.email` — that would write their - // address into a document the host can read. - useEffect(() => { - if (!ask || !portal || !user || !profileReady || !profile?.displayName) { - return; - } - const { listingId, window: slot } = ask; - setAsk(null); - setBusy(slot?.id ?? FRIEND_ONLY); - setFailed(null); - const sender = { - uid: user.uid, - username: profile.username, - displayName: profile.displayName, - photoURL: profile.photoURL, - }; - - // Re-claimed as whoever we are NOW: signing in to an existing account mints - // a fresh uid, orphaning the grant written on load. - claimGrant(portal.id, user.uid) - // Asking for dates IS a booking — the same document a friend creates. - .then(() => - listingId && slot - ? requestStayViaPortal( - sender.uid, - portal.ownerId, - listingId, - slot, - // The rules check a room link on its own, and only when told. - portal.scope === "ROOM" ? "ROOM" : null, - ) - : sendPortalConnectRequest(portal, sender), - ) - // Never confirmed: a link is not friendship, whatever the slot allows. - .then(() => - setStanding((current) => ({ - windowIds: slot - ? [...(current?.windowIds ?? []), slot.id] - : (current?.windowIds ?? []), - confirmed: current?.confirmed ?? false, - connectPending: slot ? (current?.connectPending ?? false) : true, - // Asking is only ever offered to someone who isn't one yet, so this - // settles `unknown` for a visitor whose lookup never ran at all. - friend: current?.friend ?? false, - })), - ) - .catch((error: unknown) => { - console.error(error); - // A slot that moved is not a refusal — it is news, and the visitor is - // owed which news. Everything else reaching here really was refused. - const gone = error instanceof SlotGone ? error.why : null; - report(gone ?? "refused", { code: errorCode(error) }); - setFailed({ - reason: gone ?? "refused", - ask: { listingId, window: slot }, - }); - // The dates on screen are the ones that just proved stale. - if (gone) { - fetchPortalPage(portal.id, ensureAnonymous()) - .then((found) => { - if (found && found.portal.id === currentToken.current) { - setPage(found); - } - }) - .catch((refetch: unknown) => console.error(refetch)); - } - }) - .finally(() => setBusy(null)); - }, [ask, portal, user, profileReady, profile, report, ensureAnonymous]); - - // A name is what an ask needs — not an account. Someone who has never typed - // one has nothing to look up either, so their ask is live from the first - // paint, which is the visitor this whole page exists for. - const named = Boolean(profile?.displayName); - const isOwner = Boolean(user) && user?.uid === portal?.ownerId; - const connect: Connect = isOwner - ? "none" - : !named - ? "ask" - : standing === null - ? "unknown" - : standing.friend - ? "none" - : standing.connectPending - ? "sent" - : "ask"; - const needsName = ask !== null && profileReady && !named; - // A held ask spins the control it came from, so a tap is never a no-op. The - // sheet covers "no name"; it does not cover the gap between the page loading - // and the profile answering, where a tap used to show nothing at all. - const holding = ask === null ? busy : (ask.window?.id ?? FRIEND_ONLY); - - // An ask held with NEITHER sheet up is waiting on the profile. Deliberately - // not extended to the send: that write is Firestore's, which queues offline - // and lands on reconnect. - useEffect(() => { - if (ask === null || needsName) return; - const give = (): void => { - report("stalled"); - setAsk(null); - setFailed({ reason: "stalled", ask }); - }; - if (profileUnreachable) { - give(); - return; - } - const timer = setTimeout(give, ASK_TIMEOUT_MS); - return () => clearTimeout(timer); - }, [ask, needsName, profileUnreachable, report]); - - return ( -
    -
    - {/* An anchor, not a router push — this page sits outside the nav stack. */} - - - -
    - -
    -
    - -
    - {state === "loading" ? ( - // The same mark the app boots behind. -
    - -
    - ) : state === "unreachable" ? ( -
    -

    - Can't reach kip right now -

    -

    - The link is probably fine — kip just can't check it from here. Try - again once you're back online. -

    -
    - ) : state === "missing" ? ( -
    -

    - This link isn't active -

    -

    - It may have been turned off or regenerated. Ask whoever shared it - for a fresh link. -

    -
    - ) : portal ? ( - { - if (!failed) return; - setNotice(null); - setAsk(failed.ask); - setFailed(null); - }} - onAsk={(listingId, slot) => { - // Both clear: a fresh ask must not sit under the explanation of - // why the last one didn't go. - setNotice(null); - setFailed(null); - setAsk({ listingId, window: slot }); - }} - /> - ) : null} - - -
    - - {/* Closing it abandons the held ask, which is the only way out and needs - no separate control. */} - {/* Held open past the profile write when an address was given: writing it - flips `named` and the ask flies, but a one-time send has no other - guard against a typo than the address read back. */} - { - setAsk(null); - setSentTo(null); - }} - title={sentTo ? "Check your email" : "What should we call you?"} - > - {sentTo ? ( -
    -

    - Your request is already on its way. Open the link at {sentTo} and - your kip will work on any device — nothing here is waiting on it. -

    - -
    - ) : ( - { - setAsk(null); - setNotice(why); - }} - /> - )} -
    -
    - ); -} - -// The host has to see a name rather than a blank. No handle, and no account — -// the ask goes out on this alone, and keeping it is a separate offer made after. -function NameForm({ - host, - onSent, - onAbandon, -}: { - host: string | null; - onSent: (email: string) => void; - // Drops the held ask AND carries the reason up. Needed when the visitor turns - // out to already have an account: the send effect would otherwise lodge the - // request from an identity they never asked as — and dropping it unmounts - // this form, so anything said down here would never be read. - onAbandon: (why: string) => void; -}): ReactElement { - const { user, signIn, completeOnboarding } = useKip(); - const [name, setName] = useState(user?.displayName ?? ""); - const [reach, setReach] = useState(EMPTY_REACH); - const recaptcha = useRef(null); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - - const invalid = name ? validateDisplayName(name) : null; - const reachInvalid = reachError(reach.raw); - const problem = invalid ?? reachInvalid ?? error; - - // The one path that identifies you BEFORE the ask, and the only one that needs - // no name typed: Google already knows it. The other two send on the name in the - // field and prove the address or number afterwards. - async function googleAsk(): Promise { - setBusy(true); - setError(null); - try { - const { sameAccount } = await signIn(); - // Landing in an account they already had: it has its own name and photo, - // which must not be overwritten, and the ask must not fly from it as a - // side effect of adding a way to be reached. - if (!sameAccount) { - onAbandon( - "That Google account is already on kip — you're in it now. Ask again and it'll go from this account.", - ); - return; - } - const known = auth().currentUser?.displayName?.trim(); - if (!known) { - // Nothing to write, so fall back to the field rather than sending an ask - // that would reach the host as a blank. - setError("Google didn't share a name. Type one."); - return; - } - await completeOnboarding(known); - } catch (caught) { - console.error(caught); - setError("That didn't work. Try again, or use email."); - } finally { - setBusy(false); - } - } - - async function submit(): Promise { - if (validateDisplayName(name) || reachInvalid) return; - setBusy(true); - setError(null); - let emailed: string | null = null; - // Two failures, two remedies: a bad destination is corrected here, a failed - // write is retried. One catch blamed the destination for both. - if (reach.pending) { - try { - const { sameAccount } = await confirmReach(reach.pending, reach.code); - // That number already had a kip account, so they are in it now — with - // its own name and photo, which the sheet's must not overwrite. The ask - // stays with the account that made it; only that browser can act on it. - if (!sameAccount) { - // Dropping the held ask is what makes this true: otherwise the send - // effect fires the moment the new account's profile lands, lodging - // the request from an identity they never asked as. The message goes - // UP, because dropping the ask unmounts this form. - setBusy(false); - onAbandon( - "That number is already on kip — you're in it now. Ask again and it'll go from this account.", - ); - return; - } - } catch (caught) { - console.error(caught); - setError("Wrong code. Check it, or ask for another."); - setBusy(false); - return; - } - } else if (reach.raw) { - try { - // Sent FIRST, and an emailed address handed UP before the profile write, - // which unmounts this form the moment it lands. - const holder = recaptcha.current; - if (!holder) throw new Error("no element for the check to bind to"); - const sent = await sendReach(reach.raw, host ?? "your host", holder); - if (sent.pending) { - // A code is on its way; the ask waits for it to be typed. - setReach({ ...reach, pending: sent.pending, sentTo: sent.sentTo }); - setBusy(false); - return; - } - // NOT handed up yet: doing so swaps the sheet's body and unmounts this - // form, so a profile write that then failed would have nowhere to say - // so — under a line claiming the request was already on its way. - emailed = sent.sentTo; - } catch (caught) { - console.error(caught); - // The profile is unwritten, so the ask has not gone: they can correct - // it or clear it and send without one. - setError("Couldn't send that. Check it, or clear it."); - setBusy(false); - return; - } - } - - try { - await completeOnboarding(name.trim()); - } catch (caught) { - console.error(caught); - setError("Couldn't save that. Check your connection."); - setBusy(false); - return; - } - setBusy(false); - if (emailed) onSent(emailed); - } - - return ( -
    { - event.preventDefault(); - submit(); - }} - > - setName(event.target.value)} - placeholder="Your name" - aria-label="Your name" - /> - { - setError(null); - setReach(next); - }} - hostRef={recaptcha} - invalid={Boolean(reachInvalid || error)} - busy={busy} - /> - {/* Two lines reserved so a message swap never resizes the sheet; two - because the host's name is in the standing copy. */} -

    - {problem ?? - `Only so kip can reach you — ${host ?? "they"} never sees it.`} -

    - - - {/* Below the button it replaces, because that is what it is: the same ask - sent a different way. Above it, it read as the preferred route. */} -
    - - or - -
    - - - ); -} - -function PortalView({ - portal, - windows, - roomsPending, - roomsFailed, - isOwner, - connect, - standing, - busy, - notice, - failed, - onRetry, - onAsk, -}: { - portal: Portal; - windows: Readonly>; - // True while only the portal doc has arrived, so the host block is all that - // can be drawn honestly. - roomsPending: boolean; - roomsFailed: boolean; - // Why an ask was dropped rather than sent — the visitor turned out to have an - // account already. Lives up here because dropping the ask unmounts the sheet - // that would otherwise have said so. - notice: string | null; - failed: Failure | null; - isOwner: boolean; - connect: Connect; - standing: Standing | null; - busy: string | null; - onRetry: () => void; - onAsk: (listingId: string | null, window: PortalWindow | null) => void; -}): ReactElement { - const firstName = portal.ownerName.split(" ")[0] || portal.ownerName; - - return ( -
    -
    - -
    -

    Shared by

    -

    {portal.ownerName}

    -
    -
    - - {roomsFailed ? ( -

    - Couldn't load what's shared here. Check your connection and try again. -

    - ) : roomsPending ? ( - // "Nothing shared here right now" is a statement about an empty link, - // and saying it to someone whose rooms are in flight is worse than - // saying nothing at all. -
    -
    -
    -
    - ) : portal.listings.length === 0 ? ( -

    Nothing shared here right now.

    - ) : ( - portal.listings.map((listing) => ( - - )) - )} - - {/* The only route when a link has nothing free on it, and it reports its - own state where it stands rather than vanishing — the same swap a slot - row makes between Request and a Requested chip. Nothing at all once - they're connected: a link is an ordinary way to reach a friend's - places, so there is simply nothing left to ask for. */} - {connect === "sent" ? ( - - Friend request sent - - ) : connect === "ask" ? ( - - ) : null} - - {/* Dates only — the connect chip above says its own piece, and this line - used to speak for both without naming which. */} - {standing && standing.windowIds.length > 0 ? ( -

    - {standing.confirmed - ? `${firstName} confirmed your dates — they're yours.` - : `Sent — ${firstName} will get back to you.`} -

    - ) : null} - - {notice ? ( -

    {notice}

    - ) : null} - - {/* Without this the button just reappears and a failure is invisible. The - retry re-sends the ask that failed, so nothing has to be found again. */} - {failed ? ( -
    -

    - {FAILURE_COPY[failed]} -

    - {/* Retrying a slot that has gone would only fail again the same way, - so the four of those offer nothing and say so instead. */} - {failed === "refused" || failed === "stalled" ? ( - - ) : null} -
    - ) : null} -
    - ); -} - -function ListingBlock({ - listing, - roomLink, - windows, - canAsk, - requestedWindowIds, - busy, - onAsk, -}: { - listing: PortalListing; - // A link to one room: the card is that room, and the place is only where it is. - roomLink: boolean; - windows: readonly PortalWindow[]; - canAsk: boolean; - requestedWindowIds: readonly string[]; - busy: string | null; - onAsk: (listingId: string | null, window: PortalWindow | null) => void; -}): ReactElement { - const only = roomLink ? (listing.rooms[0] ?? null) : null; - const TypeIcon = only ? LuBed : listingTypeIcon(listing.type); - const note = only ? only.note : listing.description; - // Under a room link every date is that room's, so there is nothing to group. - const groups = - !only && listing.rooms.length > 0 - ? groupByOffer(listing.rooms, windows) - : null; - - const dates = (shown: readonly PortalWindow[]): ReactElement => ( -
      - {shown.map((window) => ( -
    • -
      - - {formatDateRange(window.start, window.end)} - -

      - {nights(window.start, window.end)} nights - {window.details ? ` · ${window.details}` : ""} -

      -
      - {/* A range the visitor holds is listed only because it's theirs, so a - stranger's grey chip would tell them nothing. */} - {window.bookedByMe ? ( - Booked by you - ) : window.booked ? ( - Booked - ) : requestedWindowIds.includes(window.id) ? ( - Requested - ) : canAsk ? ( - - ) : null} -
    • - ))} -
    - ); - - return ( -
    - {/* The token in each URL is what opens the object, so a link-holder - browses the same photos a friend would. */} - -
    - - - -
    -

    - {only ? only.name : listing.title} -

    -

    - - - {only ? `In ${listing.title} · ` : ""} - {listing.locationLabel} - -

    -
    - - {only - ? listingTypeLabel("ROOM") - : // A slot link carries only the room its dates are in, so a count - // off it would say "1 room" of a house that has three. - placeTypeLabel( - listing.type, - listing.windowIds ? 0 : listing.rooms.length, - )} - -
    - {note ? ( -

    {note}

    - ) : null} - - {windows.length === 0 || groups?.length === 0 ? ( -

    - No open dates right now. -

    - ) : groups ? ( - groups.map((group) => ( -
    - - {dates(group.windows)} -
    - )) - ) : ( - dates(windows) - )} -
    - ); -} diff --git a/web/app/privacy/page.tsx b/web/app/privacy/page.tsx deleted file mode 100644 index 55840cd..0000000 --- a/web/app/privacy/page.tsx +++ /dev/null @@ -1,339 +0,0 @@ -import type { Metadata } from "next"; -import Link from "next/link"; -import type { ReactElement } from "react"; -import DocPage, { Card, H2, List, link, P } from "../../components/doc-page"; -import { - CONTACT_EMAIL, - HAFA_URL, - ISSUES_URL, - REPO_URL, -} from "../../utils/contact"; - -export const metadata: Metadata = { - title: "Privacy Policy — kip", - description: - "What kip stores, what it deliberately doesn't, and who sees it.", -}; - -const UPDATED = "August 23, 2026"; - -export default function PrivacyPage(): ReactElement { - return ( - -

    - kip is a friends-only space-sharing app, made and run by{" "} - - hafa - - . kip collects the minimum it needs to work, shows it only to the people - you choose, and sells nothing to anyone. -

    - - -

    The short version

    - -
  • - Nothing on kip is public. Nobody can find you, or anything you list, - unless you choose to be found. -
  • -
  • - Your email address and phone number live on your sign-in account, - not in kip's database. The one exception is the number you agree to - be texted at, which is kept as part of your consent record. -
  • -
  • - kip has no ads and no analytics, and does not sell or share your - data for marketing — including your phone number and your consent to - be texted. The one tracker it loads is Google reCAPTCHA, only on the - phone door, because signing in by text needs it. -
  • -
  • Leaving kip deletes your account and your data.
  • -
    -
    - -

    What kip stores

    -

    To work at all, kip keeps:

    - -
  • - Your profile — a display name you choose, an optional - username, an optional photo. -
  • -
  • - Your places — titles, descriptions, photos, the dates - they're free, a location if you add one, and any check-out - instructions you write. You choose how precise that location is; it's - shown only to people who can see the place. Check-out instructions are - shown only to guests with a confirmed stay. -
  • -
  • - Your friendships and requests — who you're connected - to, and pending asks in either direction. -
  • -
  • - Your stays — requests, confirmations, and - cancellations, with their dates. -
  • -
  • - Your settings — notification preferences, saved - searches, and privacy switches. A saved search keeps whatever you - searched by, which can include a place's coordinates — and if you used - “Use my location”, those are your device's, as your browser reported - them. kip asks for that only when you press it, and only Browse ever - asks. -
  • -
    -

    - All of it lives in Google Firebase (Firestore and Cloud Storage), which - is kip's database and file store. -

    - -

    What kip deliberately does not store

    - -
  • - - Your email address and phone number are not in the database. - {" "} - They exist only on your Firebase sign-in account, where they're used - to let you back in. When kip sends you a notification, it reads the - address off that account for that one send and puts it in no database - — the delivery log described below is the one place it is written - down. One exception, and it exists to protect you: turning on texts - stores the number you agreed to be texted at, because a consent record - that doesn't say which phone it was given for can't stop kip texting a - different one. -
  • -
  • - - No analytics, no ad tech, no tracking pixels, no cookies of kip's - own. - {" "} - What kip keeps in your browser's own storage is for you, not about - you: your signed-in session, your light/dark choice, a copy of what - you've been shown — your profile, friends, places and stays, and the - places and people kip drew for you — and a copy of the app itself, so - it opens without waiting for the network and keeps working when you - have none. Clearing your browser's data for kip removes all of it. -
  • -
  • - Photo location data is removed. Photos you upload are - re-encoded in your browser before they're stored, which strips - metadata — including any GPS position your camera embedded. A photo of - your home shouldn't carry its coordinates. -
  • -
    - -

    Who can see what

    - -
  • - Discovery is off until you turn it on. A new account - is unreachable. You can opt in to being found by username, or create a - share link — and turn either off again. -
  • -
  • - Share links are unguessable URLs. Knowing the link is - what grants access; anyone you send one to can see what it covers. - Revoking or regenerating a link cuts off every copy of the old one, - instantly. The secret part of the link is carried in the URL fragment, - which browsers don't send to servers — so it stays out of kip's - hosting logs and out of other sites' referrer headers. -
  • -
  • - Opening a share link creates a temporary session.{" "} - Visitors are signed in anonymously behind the scenes so the link can - show live dates. A note recording that a visitor opened a link expires - on its own within about thirty days, wherever it sits. An unused - session and its traces are deleted after about thirty days of - inactivity — as is any account left with nothing attached to it at - all: no profile, no stay, no friend, no share link. -
  • -
  • - Asking to stay somewhere shows the host who you are.{" "} - When you request dates, the host can see your profile — they're being - asked to give a stranger their keys, and they should know whose. It is - one-way until they say yes: being asked is not agreeing to be looked - up, so you can't see them until the stay is confirmed. After that each - of you can see the other's profile, up to about two months after the - stay ends. Then it lapses on its own. -
  • -
  • - Photos are protected by unguessable URLs. Whoever can - see a place can see its photos. Like any image on the web, someone who - could see a photo can save it. -
  • -
  • - Where you're staying is private by default. Friends - see your confirmed stays only if you turn on that switch in Settings. -
  • -
    - -

    Text messages (SMS)

    -

    - If you turn on texts in Settings, kip sends automated notification texts - to the phone number on your account — things like a request for your - dates, an answer to yours, or a cancelled stay. This is optional and off - by default; agreeing to texts is never a condition of using kip. Message - frequency varies. Message and data rates may apply. Reply STOP to any - text to stop them, or HELP for help; you can also turn them off in - Settings. -

    -

    - - kip does not share, sell, or provide your mobile phone number, or your - consent to receive text messages, to any third party or affiliate for - marketing or promotional purposes. - {" "} - No mobile information is shared with third parties or affiliates for - marketing. The only party that handles your number besides kip is - Twilio, the messaging provider that delivers the texts, and it does so - solely to deliver them. Twilio keeps its own delivery logs of messages - sent. -

    -

    - When you turn texts on, kip records the date, the phone number you - agreed to be texted at, and which version of the consent wording you - saw, so there's an honest record of exactly what you agreed to. That - record is kept even if you later turn texts off or change your number — - and it goes when your account does. -

    - -

    Email

    -

    - kip emails you about things that happen to you — a request, an answer, a - cancellation — and only to an address you've confirmed. Each kind of - email can be turned off in Settings, and every email carries a one-click - unsubscribe for its kind. Notification email is delivered via Resend, - which processes the message in transit. -

    - -

    Services kip relies on

    -

    - kip runs on a small number of services, each of which processes some of - your data to do its job — none of which is marketing: -

    - -
  • - Google Firebase — sign-in, database, file storage, - and the small server functions that send notifications and finish - account deletions. Your data is stored on Google Cloud. -
  • -
  • - Google reCAPTCHA — checks that a phone sign-in is a - real person, which Firebase requires before it will text a code. It - inspects your browser to score the request, which makes it the one - tracker on kip — so it is loaded only at the moment you submit a phone - number, and never if you sign in by email or with Google. -
  • -
  • - GitHub Pages — hosts the app itself. Like any web - host, GitHub sees standard request logs (such as IP addresses) when - you load the page. -
  • -
  • - Resend — delivers notification email. -
  • -
  • - Twilio — delivers notification texts, if you've - turned them on. -
  • -
  • - OpenStreetMap (Nominatim) — when you type a place - name or address, either while listing somewhere or in Browse's - location box, that text is sent to OpenStreetMap's free geocoding - service to find its coordinates. Only the text you typed, and only - when you type it. -
  • -
    - -

    Logs and diagnostics

    -

    - kip doesn't log what you do in the app. Two narrow exceptions. When - something goes wrong in a way that throws no error — a page that - silently stalls — kip may write a small record describing the app's - state at that moment, so the bug can be fixed; no user of the app can - read those, only the operator, and they're deleted automatically — after - about a week, or two for a failure the server itself recorded. And the - machinery that sends notifications keeps routine operational logs in - Google Cloud — that a message was sent, or why it wasn't. Those name the - address or number it went to, and Google keeps them about a month before - deleting them. Both exist to keep kip working, nothing else. -

    -

    - Separately, if you send feedback from the menu, what - you write is stored so it can be read and acted on. It goes to the - people who make kip and nowhere else — it isn't published, forwarded, or - posted anywhere public. Only the operator can read it, and it's kept - alongside your account ID so we can follow it up. Nobody else can, - including you: once a report is sent you have no copy of it here. Unlike - the diagnostics above it isn't deleted on a timer, and it isn't deleted - when you leave — a bug report is still worth fixing after the person who - sent it has gone. Write to us if you'd like one removed. -

    - -

    Leaving, and what remains

    -

    - You can delete your account from Settings at any time. Leaving cancels - your upcoming stays in both directions, disconnects you from your - friends, and deletes your photos, your places, your share links, your - profile, and your sign-in account. -

    -

    Two honest footnotes:

    - -
  • - Your username is retired, not recycled. Usernames are - never released for someone else to claim — that's what prevents - impersonation — so a deleted account's name stays reserved forever, - including from you if you return. -
  • -
  • - The other side keeps their record. A stay involves - two people, and a record of it remains in the other person's history — - dates and place, but not your name or photo, which are deleted with - your account. -
  • -
  • - Feedback you sent stays. A report about something - broken is still worth fixing afterwards, so it isn't deleted with your - account — see above, and write to us if you'd like one removed. -
  • -
    - -

    Children

    -

    - kip is for adults: you must be 18 or older to use it, and it is not - directed at children. kip doesn't knowingly keep an account for anyone - under 18 — if you believe one exists, email{" "} - - {CONTACT_EMAIL} - {" "} - and it will be removed. -

    - -

    Changes to this policy

    -

    - If kip's practices change, this page changes with them and the date at - the top is updated. kip is open source, so{" "} - - every previous version of this page - {" "} - is public. -

    - -

    Contact

    -

    - Questions about privacy:{" "} - - {CONTACT_EMAIL} - - , or{" "} - - an issue on GitHub - - . See also the{" "} - - Terms - - . -

    -
    - ); -} diff --git a/web/app/terms/page.tsx b/web/app/terms/page.tsx deleted file mode 100644 index ffbe9ae..0000000 --- a/web/app/terms/page.tsx +++ /dev/null @@ -1,199 +0,0 @@ -import type { Metadata } from "next"; -import Link from "next/link"; -import type { ReactElement } from "react"; -import DocPage, { Card, H2, List, link, P } from "../../components/doc-page"; -import { CONTACT_EMAIL, HAFA_URL, ISSUES_URL } from "../../utils/contact"; - -export const metadata: Metadata = { - title: "Terms of Service — kip", - description: "The rules for using kip, written to be read.", -}; - -const UPDATED = "September 23, 2026"; - -export default function TermsPage(): ReactElement { - return ( - -

    - These are the terms for using kip ( - - https://kip.hafa.cc - - ), a friends-only space-sharing app operated by{" "} - - hafa - - . Using kip means you agree to them. They're written to be read. -

    - -

    What kip is, and isn't

    -

    - kip lets you list space you have the right to offer, mark when it's - free, and let friends stay there for nothing. kip is free to use, - handles no money, and takes no fees. It is not a marketplace, a rental - platform, or a booking agent — it's a calendar between friends. -

    - -

    Your account

    - -
  • You must be at least 18 to use kip.
  • -
  • - Your account is yours: keep access to the email address, phone number, - or Google account you sign in with, because they're the only ways back - in. -
  • -
  • - Give your real name or the name your friends know you by — identity - between friends is the entire basis of the app. -
  • -
  • - You can leave at any time from Settings, which deletes your account - and data as described in the{" "} - - Privacy Policy - - . Usernames are never recycled, so a deleted account's username stays - permanently retired. -
  • -
    - -

    What you agree to

    - -
  • - Only list a place you actually have the right to offer for stays. -
  • -
  • Be honest — in your listings, your name, and your requests.
  • -
  • - Don't use kip to harass anyone, to break the law, or to do anything to - another person's home or account that you wouldn't do to their face. -
  • -
  • - Don't probe, scrape, or interfere with the service. (The code is open - source; read it there instead.) -
  • -
    -

    - You keep ownership of everything you post — photos, descriptions, your - name. You give kip permission to store it and show it to the people - you've chosen to share it with, which is what the app is. That - permission ends when you delete the content or your account. -

    - -

    Stays are between friends

    -

    - kip introduces no strangers and vouches for no one: everyone who can see - your place is someone you connected with or handed a link to. A stay is - an arrangement between the two of you. kip is not a party to it, doesn't - guarantee it, doesn't insure it, and can't mediate it. Hosts are - responsible for their places and for being allowed to offer them; guests - are responsible for their conduct. Whether either of you owes the other - anything — a favour, a bottle of wine, an apology — is between you. -

    - -

    Text messaging program

    - -

    - kip offers optional automated account-notification texts (“kip - notifications”), sent by kip via its messaging provider to the phone - number on your account. -

    - -
  • - What it sends: automated notifications when - something needs you or answers you — someone asks to stay at your - place, a host answers your request, a confirmed stay is called off, - or someone agrees to be friends. No marketing, ever. -
  • -
  • - Opting in: you consent by turning on “Text me” in - Settings, where these disclosures are shown. Consent is specific to - this program, is not a condition of any purchase, and is never a - condition of using kip or any part of it. -
  • -
  • - Message frequency varies. How often you're texted - depends entirely on how much happens to you. -
  • -
  • - Message and data rates may apply. -
  • -
  • - Opting out: reply STOP to any - message to stop receiving texts, or turn them off in Settings. Reply{" "} - HELP for help, or contact{" "} - - {CONTACT_EMAIL} - - . -
  • -
  • kip supports US phone numbers only.
  • -
  • Carriers are not liable for delayed or undelivered messages.
  • -
  • - - Your phone number and your opt-in are never shared with, sold to, - or bought by third parties or affiliates. - {" "} - See the{" "} - - Privacy Policy - - . -
  • -
    -
    - -

    Email notifications

    -

    - kip emails confirmed addresses about account events. Every kind can be - switched off in Settings, and every email carries a one-click - unsubscribe. -

    - -

    Ending things

    -

    - You can stop using kip, or delete your account, whenever you like. kip - may suspend or remove an account that breaks these terms or abuses the - service or other people on it. Since stays are free and kip holds no - money, neither of us owes the other anything on the way out. -

    - -

    The honest disclaimers

    -

    - kip is provided as it is, free of charge. It's built with care, but it - comes with no warranty of any kind — not that it will be available, - uninterrupted, or error-free. To the fullest extent permitted by law, - kip and its operator are not liable for indirect, incidental, or - consequential damages arising from the service or from any stay arranged - through it; where liability can't be excluded, it is limited to the - amount you paid to use kip, which is nothing. Nothing in these terms - excludes liability that cannot lawfully be excluded. -

    -

    - These terms are governed by the laws of New York, and any dispute that - somehow reaches a court belongs in the state or federal courts of Kings - County, New York. -

    - -

    Changes

    -

    - If these terms change in substance, this page and its date change, and - material changes will be called out. Previous versions are public in - kip's repository history. -

    - -

    Contact

    -

    - - Open an issue on GitHub - {" "} - — public, and the fastest way to get an answer. For anything you'd - rather not post publicly, the{" "} - - Help page - {" "} - has an address. The SMS program has its own contact above, which is the - one carriers require. -

    -
    - ); -} diff --git a/web/biome.json b/web/biome.json index 03863d8..22db7d7 100644 --- a/web/biome.json +++ b/web/biome.json @@ -1,22 +1,64 @@ { - "$schema": "https://biomejs.dev/schemas/2.5.10/schema.json", - "vcs": { "enabled": false, "clientKind": "git", "useIgnoreFile": false }, + "$schema": "https://biomejs.dev/schemas/2.5.15/schema.json", + "vcs": { + "enabled": false, + "clientKind": "git", + "useIgnoreFile": false + }, "files": { "includes": [ - "app/**/*.{ts,tsx}", - "components/**/*.{ts,tsx}", - "utils/**/*.{ts,tsx}", + "src/**/*.{ts,svelte,css,html}", "scripts/**/*.ts", "sw/**/*.ts", "*.{json,js,ts}" ], "ignoreUnknown": false }, - "formatter": { "enabled": true, "indentStyle": "space" }, - "linter": { "enabled": true, "rules": { "preset": "recommended" } }, - "javascript": { "formatter": { "quoteStyle": "double" } }, + "formatter": { + "enabled": true, + "indentStyle": "space" + }, + "linter": { + "enabled": true, + "rules": { + "preset": "recommended" + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + }, + "css": { + "parser": { + "tailwindDirectives": true + } + }, + "html": { + "experimentalFullSupportEnabled": true, + "formatter": { + "enabled": true, + "indentScriptAndStyle": true + } + }, "assist": { "enabled": true, - "actions": { "source": { "organizeImports": "on" } } - } + "actions": { + "source": { + "organizeImports": "on" + } + } + }, + "overrides": [ + { + "includes": ["**/*.svelte"], + "linter": { + "rules": { + "a11y": { + "noAutofocus": "off" + } + } + } + } + ] } diff --git a/web/bun.lock b/web/bun.lock index 4f76d9a..9b435d2 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -1,58 +1,55 @@ { - "lockfileVersion": 1, + "lockfileVersion": 2, "configVersion": 1, "workspaces": { "": { "name": "kip-web", - "dependencies": { - "firebase": "^12.19.0", - "geofire-common": "^6.0.0", - "next": "^16.3.6", - "next-themes": "^0.4.6", - "react": "^19.3.0", - "react-dom": "^19.3.0", - "react-icons": "^5.7.0", - }, "devDependencies": { - "@biomejs/biome": "^2.5.14", + "@biomejs/biome": "^2.5.15", "@firebase/rules-unit-testing": "^5.0.2", - "@tailwindcss/postcss": "^4.3.3", + "@fontsource-variable/plus-jakarta-sans": "^5.3.0", + "@iconify-json/fa6-brands": "^1.2.6", + "@iconify-json/lucide": "^1.2.141", + "@sveltejs/adapter-static": "^4.0.0", + "@sveltejs/kit": "^3.0.1", + "@sveltejs/vite-plugin-svelte": "^7.3.1", + "@tailwindcss/vite": "^4.3.3", "@types/bun": "^1.4.2", "@types/node": "^26.6.3", - "@types/react": "^19.3.0", - "@types/react-dom": "^19.3.0", - "autoprefixer": "^10.6.1", + "firebase": "^12.19.0", "firebase-admin": "^14.5.0", - "postcss": "^8.5.28", + "geofire-common": "^6.0.0", + "svelte": "^5.57.2", + "svelte-check": "^4.7.6", "tailwindcss": "^4.3.3", - "typescript": "~7.0.2", + "typescript": "~6.0.3", + "unplugin-icons": "^24.0.0", + "vite": "^8.3.3", }, }, }, "packages": { - "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], + "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], - "@biomejs/biome": ["@biomejs/biome@2.5.14", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.14", "@biomejs/cli-darwin-x64": "2.5.14", "@biomejs/cli-linux-arm64": "2.5.14", "@biomejs/cli-linux-arm64-musl": "2.5.14", "@biomejs/cli-linux-x64": "2.5.14", "@biomejs/cli-linux-x64-musl": "2.5.14", "@biomejs/cli-win32-arm64": "2.5.14", "@biomejs/cli-win32-x64": "2.5.14" }, "bin": { "biome": "bin/biome" } }, "sha512-0FabLIjd4M/dm8VFI86RMaLLdgepzbdfiAL2R8cr7V81OYYrP1w7Z73KfAwPK5h9SrEBXTzNG2y+mBwPo9xRnw=="], + "@biomejs/biome": ["@biomejs/biome@2.5.15", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.15", "@biomejs/cli-darwin-x64": "2.5.15", "@biomejs/cli-linux-arm64": "2.5.15", "@biomejs/cli-linux-arm64-musl": "2.5.15", "@biomejs/cli-linux-x64": "2.5.15", "@biomejs/cli-linux-x64-musl": "2.5.15", "@biomejs/cli-win32-arm64": "2.5.15", "@biomejs/cli-win32-x64": "2.5.15" }, "bin": { "biome": "bin/biome" } }, "sha512-WZTW4slm/pdkh92K6t/3aEN++44JD1PQ7squ7RCMLI1flHGl43DVOIGXvCjHiZgBMO1V4uYxoFNtqrIA4qGuIQ=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UnzaXO65L4tsZimFITFP2M121GyhDcWFrT3pL5ZJ5U4XcS/0L5VHYytVohdCf/gnDUFHgl2I9xnt5bV/J1kxHQ=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BZVzFhJ/mUvTLMYbc9x6el0o2Uv5zP7bACYyhFb6fSc1giroXm7PL0a5KqMJ9F+/BnkYRjagD/ROSDVOZ6eapg=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-kiy8qA16K93J7uvFfWi4LgjqDpnRKyePAna6A0Y4jxyUga35SYpIZ2cNWlhy0lsfgqRenCpfymnJWEZ6mgpRgA=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-V5Kw63V+fVGNFhFrizDxMbGXAzZCh8kYzJpy3GI6gTapWDg7bZK9oYC9CbVgQSEdTnupz0U5Efz8Ev+5vdBBXw=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-vO/9BaU1n30CiFNLx49gMTMtbCAAqlo/EqFoG0BU3deQInTbJrmXSmTQ9e3FoDZIKQnvSLDVNL4lx1ci7y1T1Q=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-XaG7P7eeSLYETD3K9grfB0mQmpLQZjygdbsxekWBJAg4am79fGAtRpfRyNnTvXB2fZFNnJZG3nXm7HBat0+VUw=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-SJ9PrZkBnnH9dHJDnxk34vKs0GB2dbsioaft6/hPhWJ7AHpwYH83Isnhj0FSRpFkGgpVfJ1lds23ApB2czUDLQ=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-tGzZUTcJCV7tj5Adh/Gn6nR4MycqA3iohq2LEcrarAgRBkU0h0OKd2UzCRkOsOYOuUGaUJb4YmqRqyy2feGGSw=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.14", "", { "os": "linux", "cpu": "x64" }, "sha512-VHZRa7CCQBUWKxNwUvHJeuW03WFRgN5NWO//SDGOitc9QIeNyfA42V9zlKm+x82xFSG9Bzi5fi+hbhm9anO8yA=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.15", "", { "os": "linux", "cpu": "x64" }, "sha512-xE4iEW/3LqlYj9GFgGrFsFSH51dEEpUbYWBfeOv0q87WkUDxK2o/HhreSb7qMJqck70RVM6Lg96hgedcAYcOkA=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.14", "", { "os": "linux", "cpu": "x64" }, "sha512-2kI5PrMgW5dcEZYrstLPmUmCwkUwZY39rP4BN93Vxbwcs2O57LDQOktUZZYPvvspN5i0mhGr3TJtF5sU26NUWg=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.15", "", { "os": "linux", "cpu": "x64" }, "sha512-IlxUcyxilVGPsE008x13pWdkTbU3nQpP2i9b5UrbOYj6goBkKsRX1XB7yJLcV+O1H9LBchIIm4adOaVLZBX0ZQ=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-pHgAFffmZtaYoxavEsWEYNvcA7NwOIjLyqw2HXyLbt16xYryX0L4fMV2u0G9ag6LNYPIoqWaWqzUcnc6rLGGXg=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-2kPKzhNlm8C+Ru3GcO0Me2ODkCBLrVOUNuyi84RJyWDVKAA4+Kjj3jMjL938lF6BRfvltC4vB2nPY9SvN51Ovg=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.14", "", { "os": "win32", "cpu": "x64" }, "sha512-oJWmBhoHsnhUKIke+0gXDX0mltJrWHA1UyHsrTlXwX0TL64ilVZAo+TYZm97baecV0esBdpzy3k96q+09JaZUQ=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.15", "", { "os": "win32", "cpu": "x64" }, "sha512-yAzh4UqEImV6Hcy0zjUqNfJAsUhoD64FBQCdTG8Md/Z2wQeVP/ZmJ3XRYAs1g8J9fjTFyl2fCtc5FB5til47+g=="], - "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], - - "@fastify/busboy": ["@fastify/busboy@3.2.0", "", {}, "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA=="], + "@fastify/busboy": ["@fastify/busboy@3.2.2", "", {}, "sha512-yXSS27qPExaXeuLvMRMXOLtpipzfQYNjG3FkunDWKGfMYjKuhFXko9CVzqxm8jcF+lmtS9Fd89QNdh9XDjnbNg=="], "@firebase/ai": ["@firebase/ai@2.16.0", "", { "dependencies": { "@firebase/app-check-interop-types": "0.3.5", "@firebase/component": "0.7.5", "@firebase/logger": "0.5.2", "@firebase/util": "1.15.3", "tslib": "^2.1.0" }, "peerDependencies": { "@firebase/app": "0.x", "@firebase/app-types": "0.x" } }, "sha512-WRVxl58B61Orwf7st7949ZIW2L4huV80Gpaum1z8v2eH+m2xaxWtC8mOjQyVAOirr6O/yf89cBjlPvoyBpBfTg=="], @@ -144,7 +141,9 @@ "@firebase/webchannel-wrapper": ["@firebase/webchannel-wrapper@1.0.7", "", {}, "sha512-phBFwieDLvkZGYN9CE9ZFNEIoBVksprzsnCzQejCmCHtgwCXReeuRpoEGN9C4EbhONztv8NRV1tau6Rb9pONwQ=="], - "@google-cloud/firestore": ["@google-cloud/firestore@9.3.0", "", { "dependencies": { "@google-cloud/firestore-api": "^0.2.0", "@opentelemetry/api": "^1.9.0", "fast-deep-equal": "^3.1.3", "functional-red-black-tree": "^1.0.1", "google-gax": "^6.0.1", "protobufjs": "^7.5.3" } }, "sha512-OeLAXk+gDjysfUqVmdo401K0FWGgUTa6vFt85LFtA2Qiaq6jHkqhAq6N4Kn09asv1F9fsO4Fkv8TwpnGutQZ6Q=="], + "@fontsource-variable/plus-jakarta-sans": ["@fontsource-variable/plus-jakarta-sans@5.3.0", "", {}, "sha512-/l/4r0yyWK9JzAlmA0LiYgGgmJe/Gswt4jTJEzr5QhJfwMbvJZDmYWyW0M4X7yCK69BajMVlV/Lx8g7WDc1+sw=="], + + "@google-cloud/firestore": ["@google-cloud/firestore@9.3.1", "", { "dependencies": { "@google-cloud/firestore-api": "^0.2.0", "@opentelemetry/api": "^1.9.0", "fast-deep-equal": "^3.1.3", "functional-red-black-tree": "^1.0.1", "google-gax": "^6.0.1", "protobufjs": "^7.5.3" } }, "sha512-ofGTiSpfXrHrrS3KaDOmsupSzCHr4WVl1+s6Ke0F8rPBRyb/OuZSC8u1NjCKtt/fO7A1GvqASQGhdAwVsnGpQA=="], "@google-cloud/firestore-api": ["@google-cloud/firestore-api@0.2.0", "", { "dependencies": { "google-gax": "^5.0.0" } }, "sha512-wfHJPZmLqzpy6FN6U+7HkkP/AKhGsbHSpEy/qL2TyI3/pKRAvpamQxZ2qvps5hMRgAjfuHCim+oeJj9RveCaqg=="], @@ -160,119 +159,99 @@ "@grpc/proto-loader": ["@grpc/proto-loader@0.7.15", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.2.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ=="], - "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], - - "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg=="], - - "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.4" }, "os": "darwin", "cpu": "x64" }, "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw=="], + "@iconify-json/fa6-brands": ["@iconify-json/fa6-brands@1.2.6", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-twL3X4KWcxAhbc1vz/mIDsVr+CAItk1/EIfxKUVQtpv6O4eydk5KNYqTZWdvJNHGInUgd6vKg21aWfVgb5DXEg=="], - "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.5", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "os": "freebsd" }, "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw=="], + "@iconify-json/lucide": ["@iconify-json/lucide@1.2.141", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-V9IC4cc7giA8PtoSeNc5GbASh4Sc5sRILu3jdGO9TMRWC+4YUeU3I3prNnoAZ5QiRZpfrjBhmXkqIdwfS//PGw=="], - "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw=="], + "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], - "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ=="], + "@iconify/utils": ["@iconify/utils@3.1.7", "", { "dependencies": { "@antfu/install-pkg": "^2.0.1", "@iconify/types": "^2.0.0", "import-meta-resolve": "^4.2.0" } }, "sha512-JZHlwdID+dy+lTgbYC8NEC4zeugqeYsc6jewvzb4c58kHauJn+X7rNwQjxz5p2qSjqaEeQoLkCIQ9v/H4PK0/w=="], - "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.4", "", { "os": "linux", "cpu": "arm" }, "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw=="], - - "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A=="], - - "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ=="], - - "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.4", "", { "os": "linux", "cpu": "none" }, "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q=="], - - "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ=="], - - "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw=="], - - "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA=="], - - "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg=="], - - "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.4" }, "os": "linux", "cpu": "arm" }, "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ=="], + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], - "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64" }, "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], - "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA=="], + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], - "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.4" }, "os": "linux", "cpu": "none" }, "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ=="], + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], - "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.4" }, "os": "linux", "cpu": "s390x" }, "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], - "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.4" }, "os": "linux", "cpu": "x64" }, "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog=="], + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64" }, "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg=="], + "@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="], - "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.4" }, "os": "linux", "cpu": "x64" }, "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A=="], + "@nodable/entities": ["@nodable/entities@3.1.0", "", {}, "sha512-LsS/DjHr+uDM647Gru/cA8+J3a3HfhttwCKLyuoyN7yXTFCxKBtSgMQBvrW9yNPa2/zDRUNuGPaH5QUFoa4arQ=="], - "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.5", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ=="], + "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], - "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.5", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "cpu": "none" }, "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA=="], + "@oxc-project/types": ["@oxc-project/types@0.152.0", "", {}, "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw=="], - "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q=="], + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], - "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A=="], + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], - "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.5", "", { "os": "win32", "cpu": "x64" }, "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg=="], + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], - "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], - "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="], - "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="], - "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="], - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], - "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], - "@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="], + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], - "@next/env": ["@next/env@16.3.6", "", {}, "sha512-x9Vblze1EbtltQYnNH38xCPWU3TVfBd1eXqA3+w9+BTpedkkdNpAaltXlGQ/nsc1+E0mVTNrtcbX3GoO09zeLQ=="], + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="], - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-E/7GEqaUkt8mk/T8v9lAnrhzR06kdq1ZBkC12F8tAMkdIadwNp3H1KqHynDHrpcTlGCUdq/qu6vUL2aYVyYBdw=="], + "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.12", "", { "os": "android", "cpu": "arm" }, "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A=="], - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-yBE893/nDWTlaiBD1p+qgt7NUen4U5R6FXyH0s67Npq1S3E0cVSef1WIXC2xBRgQvwAvJq6DnS6Y6PrY0cy4Ew=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.12", "", { "os": "android", "cpu": "arm64" }, "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg=="], - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-KJDpjBqBPYlvkivmyrp+Qys6k/7ksbqGQvRVc6ZEGfR+cjQxx+nUkJaWmNZJsmoOrqYNbaXByF8wa0lBwDhB3Q=="], + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w=="], - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-mqNg2K+hvWskSRb/QM+Ix412DvBsuSF0XV+frTSw5vmoucNnIlynFwKYew8D01bfATErMOM7Bujrf0BA5DRKFA=="], + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA=="], - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-nFncBNGAYouRHjRVaITs9beZRfhX4ssVwpnvPIAbkZVH6LtGoAVlH4bJ8Cnf9SOo9bsXgPFer/GdHtEE3JNOkw=="], + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ=="], - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-5Mf3cHDGR/Iz0ng2Bj3zUR3p5QS9YK3Hn2QiAfavFmyF48zwThAjpFoiTKNIcOHLYS4zEk+gzyJ/9deQ2ZB8yQ=="], + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.12", "", { "os": "linux", "cpu": "arm" }, "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g=="], - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-0jkJy0C2kbrJWTk4YLa3xk80pVBpx8FCHJym7CnUfDAXe/FWv5qT7SQJbR0KuemyxaEDlEx5WT4VQJoTW+/9Qw=="], + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw=="], - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.6", "", { "os": "win32", "cpu": "x64" }, "sha512-/YXjI1e5OXcZ7YpxRwgP/1jAV/SBKTzeVKqN2mk7mLpcICsyn3Gl5+dIfDTJp70M0ccMhyMMRso4v6mPDCGepg=="], + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw=="], - "@nodable/entities": ["@nodable/entities@2.1.1", "", {}, "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg=="], + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ=="], - "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw=="], - "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw=="], - "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g=="], - "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA=="], - "@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="], + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew=="], - "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="], + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.12", "", { "os": "win32", "cpu": "x64" }, "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ=="], - "@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], - "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@protobufjs/inquire": ["@protobufjs/inquire@1.1.2", "", {}, "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw=="], + "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.13", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ=="], - "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + "@sveltejs/adapter-static": ["@sveltejs/adapter-static@4.0.0", "", { "peerDependencies": { "@sveltejs/kit": "^3.0.0-next.0" } }, "sha512-aF1J23R1ebKqUxQ4/qLr/Lxu63NFmLAtg8UZl3HFLxaoA1D/nhTVGcD9lTqx65iUbjNR2iOL0TgOxSX82x6Paw=="], - "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + "@sveltejs/kit": ["@sveltejs/kit@3.0.1", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1", "@standard-schema/spec": "^1.1.0", "cookie": "^2.0.1", "devalue": "^5.9.4", "esm-env": "^1.2.2", "magic-string": "^1.2.3", "mrmime": "^2.0.1", "sirv": "^3.0.2" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^7.0.0", "svelte": "^5.57.1", "typescript": "^6.0.0", "vite": "^8.0.12" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-8JMCCq7ZUJELi3hbhb0+c9Rs8qnbHW90vhOfM+t1FSF4fhIpG9/FOsH4IYgcITMhbdkSrrn+Kvd9G+q8A+UO6Q=="], - "@protobufjs/utf8": ["@protobufjs/utf8@1.1.1", "", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="], + "@sveltejs/load-config": ["@sveltejs/load-config@0.2.3", "", {}, "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ=="], - "@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="], + "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@7.3.1", "", { "dependencies": { "deepmerge": "^4.3.1", "magic-string": "^1.0.0", "obug": "^2.1.0", "vitefu": "^1.1.2" }, "peerDependencies": { "svelte": "^5.46.4", "vite": "^8.0.0-beta.7 || ^8.0.0" } }, "sha512-ZPsLN8B1e/En+Ak5s4V7srFDT532oS0qieLsQwu63NGKsS+iAjoO2Js1BochlHlglcU+Pt7WAO3C5Ee+4f6gVA=="], "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="], @@ -302,136 +281,108 @@ "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="], - "@tailwindcss/postcss": ["@tailwindcss/postcss@4.3.3", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "postcss": "^8.5.16", "tailwindcss": "4.3.3" } }, "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg=="], + "@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="], "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + "@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.10", "", { "dependencies": { "@types/ms": "*", "@types/node": "*" } }, "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA=="], "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@26.6.3", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg=="], - - "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="], - - "@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="], - - "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], - - "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], - - "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], - - "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], - - "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], - - "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], - - "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], - - "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], - - "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], - - "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], - - "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], - - "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], - - "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], - - "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], - - "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], - - "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], - - "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], - - "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], - - "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], - - "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + "@types/node": ["@types/node@26.6.4", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg=="], "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + "acorn": ["acorn@8.19.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "anynum": ["anynum@1.0.1", "", {}, "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A=="], + + "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], + "async-retry": ["async-retry@1.3.3", "", { "dependencies": { "retry": "0.13.1" } }, "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw=="], - "autoprefixer": ["autoprefixer@10.6.1", "", { "dependencies": { "browserslist": "^4.28.9", "caniuse-lite": "^1.0.30001810", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-cL1Qz6ADZhcEbny/8HPfe99J6HhNoYtpX2LFLIbhgGE7Q1hlQVkYFdetDN7Id3KiQxhDrHwzlHr/YQCnZ8+xSA=="], + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.11.7", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-APw5YuIQAg6L9w4sHDI6j26DGFJI6RpYOhnkMPdC9lWbkKvsyPHzDsve1yd73lk21yz7Y09Kci8B2Pp9FonzWA=="], - "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], - "brace-expansion": ["brace-expansion@2.1.3", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-DRdx5neNsG/QXbniLFWi2YmC/68oeOOmKz6zOjVk6ZS1ZLXgLIKqVEc6hWsmkjBbgii0SwaBTcJ5XKj5gzY/4A=="], - - "browserslist": ["browserslist@4.29.3", "", { "dependencies": { "baseline-browser-mapping": "^2.11.26", "caniuse-lite": "^1.0.30001813", "electron-to-chromium": "^1.5.439", "node-releases": "^2.0.57", "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA=="], + "brace-expansion": ["brace-expansion@2.1.7", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g=="], "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], - "caniuse-lite": ["caniuse-lite@1.0.30001814", "", {}, "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA=="], - - "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "confbox": ["confbox@0.3.1", "", {}, "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA=="], - "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "cookie": ["cookie@2.0.1", "", {}, "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "devalue": ["devalue@5.9.4", "", {}, "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg=="], + "duplexify": ["duplexify@4.1.3", "", { "dependencies": { "end-of-stream": "^1.4.1", "inherits": "^2.0.3", "readable-stream": "^3.1.1", "stream-shift": "^1.0.2" } }, "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA=="], "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], - "electron-to-chromium": ["electron-to-chromium@1.5.443", "", {}, "sha512-TDJG36L9A3CWWwZ97HKaE+Iz1sW80pNp6sAU0owzCIV3Zc9eVyRqZK8Vz6NKuMIU8WjP0pIkoqCa0VRf/yFD0Q=="], - "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], - "enhanced-resolve": ["enhanced-resolve@5.24.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-GVoi+ICHocoOIU7qVVM48wOJziRsqrsyqlI0Ce0LdowRn6v3bcH2zUa9kp85ncx0nwIb9/HOCOLS3fdThDG/XQ=="], + "enhanced-resolve": ["enhanced-resolve@5.26.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], + + "esrap": ["esrap@2.4.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-CQPcneEHw/czj32uwQC52nYnax8H4SXt25FCM+LIwGZ49cMIxpOj3bP8wmzcI+Z0HIy5KkClaPhRLfCySzwHPA=="], + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + "exsolve": ["exsolve@1.1.3", "", {}, "sha512-kuzL9zherLQoqDeQaS55AiiIg1kb4KYXaSusTtgZTPlvKP2gGCI82LNmBNh5RjDVUXIcmgx+/Mx7vPwRzBbhJA=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - "fast-xml-builder": ["fast-xml-builder@1.2.0", "", { "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" } }, "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q=="], + "fast-xml-builder": ["fast-xml-builder@1.3.1", "", { "dependencies": { "path-expression-matcher": "^1.6.2", "xml-naming": "^0.3.0" } }, "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug=="], - "fast-xml-parser": ["fast-xml-parser@5.8.0", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.2.0", "path-expression-matcher": "^1.5.0", "strnum": "^2.3.0", "xml-naming": "^0.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg=="], + "fast-xml-parser": ["fast-xml-parser@5.11.2", "", { "dependencies": { "@nodable/entities": "^3.0.1", "fast-xml-builder": "^1.2.0", "is-unsafe": "^2.0.0", "path-expression-matcher": "^1.6.2", "strnum": "^2.4.2", "xml-naming": "^0.3.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-R9iDuNrQYeQut46cn2r2wHKn4HYzVDvDm5J1wW+koZewykv0yuO2HChTYeZtrULyHIDM9cj9TUXloCsueCQUog=="], "faye-websocket": ["faye-websocket@0.11.4", "", { "dependencies": { "websocket-driver": ">=0.5.1" } }, "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], "firebase": ["firebase@12.19.0", "", { "dependencies": { "@firebase/ai": "2.16.0", "@firebase/analytics": "0.10.25", "@firebase/analytics-compat": "0.2.31", "@firebase/app": "0.16.2", "@firebase/app-check": "0.13.1", "@firebase/app-check-compat": "0.4.7", "@firebase/app-compat": "0.5.18", "@firebase/app-types": "0.9.6", "@firebase/auth": "1.13.6", "@firebase/auth-compat": "0.6.11", "@firebase/data-connect": "0.7.4", "@firebase/database": "1.1.5", "@firebase/database-compat": "2.1.7", "@firebase/firestore": "4.17.2", "@firebase/firestore-compat": "0.4.14", "@firebase/functions": "0.14.0", "@firebase/functions-compat": "0.5.0", "@firebase/installations": "0.6.24", "@firebase/installations-compat": "0.2.24", "@firebase/messaging": "0.13.3", "@firebase/messaging-compat": "0.2.30", "@firebase/performance": "0.7.14", "@firebase/performance-compat": "0.2.27", "@firebase/remote-config": "0.9.2", "@firebase/remote-config-compat": "0.2.29", "@firebase/storage": "0.14.5", "@firebase/storage-compat": "0.4.5", "@firebase/util": "1.15.3" } }, "sha512-kwXCLcI0ly2lkwygkbuRx6SsX3DSO96355YrWh9SWZ5ncsxK/y5cirn3sY6nZVSiBVE++2nL6Hchzu1EB8uohQ=="], @@ -442,7 +393,7 @@ "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], - "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "functional-red-black-tree": ["functional-red-black-tree@1.0.1", "", {}, "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g=="], @@ -458,7 +409,7 @@ "google-auth-library": ["google-auth-library@11.1.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "^9.0.0", "google-logging-utils": "^2.0.0", "jws": "^4.0.0" } }, "sha512-YIBq5KNoHQrZ9PM4PeodDBqqBGlSTIxh1zibN8u3zNIO9UJg8gvKuCpdKV7fWXhsZ2zRkU6FhGrgKNgLJ2IRgw=="], - "google-gax": ["google-gax@6.10.0", "", { "dependencies": { "@grpc/grpc-js": "^1.12.6", "@grpc/proto-loader": "^0.8.0", "@opentelemetry/api": "^1.9.0", "duplexify": "^4.1.3", "google-auth-library": "^11.0.0", "google-logging-utils": "^2.0.0", "node-fetch": "^3.3.2", "object-hash": "^3.0.0", "proto3-json-serializer": "^4.0.0", "protobufjs": "^7.5.4", "retry-request": "^9.0.0" } }, "sha512-P9+6mNoC97uSUj55MptrxNj9muoDwc05mcY49R8jyg3kqzkPK+uWHApLuAHsNPKh3dtk1M1IuVZIto8e/fvpQA=="], + "google-gax": ["google-gax@6.11.0", "", { "dependencies": { "@grpc/grpc-js": "^1.12.6", "@grpc/proto-loader": "^0.8.0", "@opentelemetry/api": "^1.9.0", "duplexify": "^4.1.3", "google-auth-library": "^11.0.0", "google-logging-utils": "^2.0.0", "node-fetch": "^3.3.2", "object-hash": "^3.0.0", "proto3-json-serializer": "^4.0.0", "protobufjs": "^7.5.4", "retry-request": "^9.0.0" } }, "sha512-ggXlbsrLC5xVkNrSd3izNqWnY4xLf3tTnANRWqx8sVCKL/lNgPWIktaQZxc2lW/wK30vNINWFTvoTP/kUH8xuA=="], "google-logging-utils": ["google-logging-utils@2.0.1", "", {}, "sha512-HMhaQghlOTvbcb3c4T5jmmOMtG3JUF1iOQMezaJXL86CDS+Tm2vHd0IeLFRAx3+ewd+bo9E1HFHoy17X5aJa9A=="], @@ -476,19 +427,25 @@ "idb": ["idb@7.1.1", "", {}, "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ=="], + "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], + "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + "is-unsafe": ["is-unsafe@2.0.2", "", {}, "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], - "jose": ["jose@6.2.4", "", {}, "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA=="], + "jose": ["jose@6.2.12", "", {}, "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw=="], "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], @@ -500,32 +457,36 @@ "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], - "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], - "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], - "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], - "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], - "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], - "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], - "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], - "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], - "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], - "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], - "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], - "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], "limiter": ["limiter@1.1.5", "", {}, "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA=="], + "local-pkg": ["local-pkg@1.2.1", "", { "dependencies": { "mlly": "^1.7.4", "pkg-types": "^2.3.0", "quansync": "^0.2.11" } }, "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q=="], + + "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], + "lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="], "lodash.clonedeep": ["lodash.clonedeep@4.5.0", "", {}, "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ=="], @@ -546,11 +507,11 @@ "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], - "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], + "lru-cache": ["lru-cache@11.5.3", "", {}, "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg=="], "lru-memoizer": ["lru-memoizer@3.0.0", "", { "dependencies": { "lodash.clonedeep": "^4.5.0", "lru-cache": "^11.0.1" } }, "sha512-m83w/cYXLdUIboKSPxzPAGfYnk+vqeDYXuoSrQRw1q+yVEd8IXhvMufN8Q5TIPe7e2jyX4SRNrDJI2Skw1yznQ=="], - "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "magic-string": ["magic-string@1.4.3", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ=="], "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], @@ -558,69 +519,75 @@ "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="], - "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], - "next": ["next@16.3.6", "", { "dependencies": { "@next/env": "16.3.6", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.6", "@next/swc-darwin-x64": "16.3.6", "@next/swc-linux-arm64-gnu": "16.3.6", "@next/swc-linux-arm64-musl": "16.3.6", "@next/swc-linux-x64-gnu": "16.3.6", "@next/swc-linux-x64-musl": "16.3.6", "@next/swc-win32-arm64-msvc": "16.3.6", "@next/swc-win32-x64-msvc": "16.3.6", "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-L+otWM/aQbYTx98aZhgEoMb4bZAXx1YVW4UMA/vuCyCoWG5HJyZUili8QAkqzrcC+5///tsz3s0M+SlyB5bLMw=="], + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], - "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="], "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], - "node-releases": ["node-releases@2.0.57", "", {}, "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw=="], - "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], + "obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], - "path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="], + "package-manager-detector": ["package-manager-detector@1.9.0", "", {}, "sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA=="], + + "path-expression-matcher": ["path-expression-matcher@1.6.2", "", {}, "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], - "postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], - "proto3-json-serializer": ["proto3-json-serializer@4.0.2", "", { "dependencies": { "protobufjs": "^7.5.4" } }, "sha512-VUQlb/J2WjOG8BKhdLwJUz+MKxSMtBGYh+TaNmRtiYVhlIzXjzeAGIx7gtc3lXlA2iURzfF2+cXaQvC2ofwzpg=="], + "pkg-types": ["pkg-types@2.3.3", "", { "dependencies": { "confbox": "^0.3.1", "exsolve": "^1.1.1", "pathe": "^2.0.3" } }, "sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w=="], - "protobufjs": ["protobufjs@7.6.2", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ=="], + "postcss": ["postcss@8.5.29", "", { "dependencies": { "nanoid": "^3.3.19", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ=="], - "re2js": ["re2js@2.8.6", "", {}, "sha512-xLgQil4kIUCrAzVk9fRSkxkFNwmygLFjVxXrLc65aE1F0+Zsb8rxumFBy4XKyvgMCTL6kilDq3EZ0piE2dP/Dg=="], + "proto3-json-serializer": ["proto3-json-serializer@4.0.2", "", { "dependencies": { "protobufjs": "^7.5.4" } }, "sha512-VUQlb/J2WjOG8BKhdLwJUz+MKxSMtBGYh+TaNmRtiYVhlIzXjzeAGIx7gtc3lXlA2iURzfF2+cXaQvC2ofwzpg=="], - "react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="], + "protobufjs": ["protobufjs@7.6.6", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg=="], - "react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="], + "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], - "react-icons": ["react-icons@5.7.0", "", { "peerDependencies": { "react": "*" } }, "sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw=="], + "re2js": ["re2js@2.8.6", "", {}, "sha512-xLgQil4kIUCrAzVk9fRSkxkFNwmygLFjVxXrLc65aE1F0+Zsb8rxumFBy4XKyvgMCTL6kilDq3EZ0piE2dP/Dg=="], "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], - "retry-request": ["retry-request@9.0.1", "", { "dependencies": { "extend": "^3.0.2", "teeny-request": "^11.0.0" } }, "sha512-4kGHEBl0ME6gR+8QB1sPMyg58jUxLUcCZNeqLDrfqzk0eWQN8XJFmeu7fmZCjlZCRW0S2u64Ik1HkO/0sWgCYQ=="], + "retry-request": ["retry-request@9.0.2", "", { "dependencies": { "extend": "^3.0.2" } }, "sha512-S73r2yk09DSWx7K4mTZG7v5wSOrCK5okQbYLlkgpDjzVsxkUkhKrUR2VpUp69e3iA06Btn0bDXa27gxfnCqZMw=="], "rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="], - "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "rolldown": ["rolldown@1.2.12", "", { "dependencies": { "@oxc-project/types": "=0.152.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.12", "@rolldown/binding-android-arm64": "1.2.12", "@rolldown/binding-darwin-arm64": "1.2.12", "@rolldown/binding-darwin-x64": "1.2.12", "@rolldown/binding-freebsd-x64": "1.2.12", "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", "@rolldown/binding-linux-arm64-gnu": "1.2.12", "@rolldown/binding-linux-arm64-musl": "1.2.12", "@rolldown/binding-linux-ppc64-gnu": "1.2.12", "@rolldown/binding-linux-s390x-gnu": "1.2.12", "@rolldown/binding-linux-x64-gnu": "1.2.12", "@rolldown/binding-linux-x64-musl": "1.2.12", "@rolldown/binding-openharmony-arm64": "1.2.12", "@rolldown/binding-win32-arm64-msvc": "1.2.12", "@rolldown/binding-win32-x64-msvc": "1.2.12" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ=="], - "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], + "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], - "semver": ["semver@7.8.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], - "sharp": ["sharp@0.35.5", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.5", "@img/sharp-darwin-x64": "0.35.5", "@img/sharp-freebsd-wasm32": "0.35.5", "@img/sharp-libvips-darwin-arm64": "1.3.4", "@img/sharp-libvips-darwin-x64": "1.3.4", "@img/sharp-libvips-linux-arm": "1.3.4", "@img/sharp-libvips-linux-arm64": "1.3.4", "@img/sharp-libvips-linux-ppc64": "1.3.4", "@img/sharp-libvips-linux-riscv64": "1.3.4", "@img/sharp-libvips-linux-s390x": "1.3.4", "@img/sharp-libvips-linux-x64": "1.3.4", "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", "@img/sharp-libvips-linuxmusl-x64": "1.3.4", "@img/sharp-linux-arm": "0.35.5", "@img/sharp-linux-arm64": "0.35.5", "@img/sharp-linux-ppc64": "0.35.5", "@img/sharp-linux-riscv64": "0.35.5", "@img/sharp-linux-s390x": "0.35.5", "@img/sharp-linux-x64": "0.35.5", "@img/sharp-linuxmusl-arm64": "0.35.5", "@img/sharp-linuxmusl-x64": "0.35.5", "@img/sharp-webcontainers-wasm32": "0.35.5", "@img/sharp-win32-arm64": "0.35.5", "@img/sharp-win32-ia32": "0.35.5", "@img/sharp-win32-x64": "0.35.5" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g=="], + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -628,7 +595,9 @@ "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], + + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], "stream-events": ["stream-events@1.0.5", "", { "dependencies": { "stubs": "^3.0.0" } }, "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg=="], @@ -644,11 +613,13 @@ "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "strnum": ["strnum@2.3.0", "", {}, "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q=="], + "strnum": ["strnum@2.4.2", "", { "dependencies": { "anynum": "^1.0.1" } }, "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw=="], "stubs": ["stubs@3.0.0", "", {}, "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw=="], - "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], + "svelte": ["svelte@5.57.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "@jridgewell/sourcemap-codec": "^1.6.0", "@sveltejs/acorn-typescript": "^1.0.13", "@types/estree": "^1.0.9", "acorn": "^8.18.0", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.9.2", "esm-env": "^1.2.1", "esrap": "^2.3.6", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-1YiPCAXT0Ct6g1vOMmSLa0Nsc3QRMvtDF32ihgglGVrugIuIK38RyDiFNXVDhtQ81TJjSO57NdMw6DyYLqSGDQ=="], + + "svelte-check": ["svelte-check@4.7.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "@sveltejs/load-config": "^0.2.3", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.0.0 || ^6.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw=="], "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], @@ -656,26 +627,42 @@ "teeny-request": ["teeny-request@11.0.1", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "stream-events": "^1.0.5" } }, "sha512-bNr5j2YjSdajgCVsp+8JVjRf1uHIbpNISLeKp/7V1NnVMX3Gh6H/kECNGlm2Q3I68ACODQ0iv6aZ3Rvm8WgLGA=="], + "tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + + "ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="], "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], - "update-browserslist-db": ["update-browserslist-db@1.3.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ=="], + "unplugin": ["unplugin@3.4.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.7", "webpack-virtual-modules": "^0.6.2" }, "peerDependencies": { "@farmfe/core": "*", "@rsbuild/core": "*", "@rspack/core": "*", "bun-types-no-globals": "*", "esbuild": "*", "rolldown": "*", "rollup": "*", "unloader": "*", "vite": "*", "webpack": "*" }, "optionalPeers": ["@farmfe/core", "@rsbuild/core", "@rspack/core", "bun-types-no-globals", "esbuild", "rolldown", "rollup", "unloader", "vite", "webpack"] }, "sha512-9skdIFlCsPdFV7wUfZxNsFInlW+7nJmGu2gkTu0OUhF56aXGsHab9x52/QhdJ4lC7ZDPWTxbiC4ANqVlsuaW3w=="], + + "unplugin-icons": ["unplugin-icons@24.0.0", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/utils": "^3.1.7", "local-pkg": "^1.2.1", "obug": "^2.2.1", "unplugin": "^3.3.0" }, "peerDependencies": { "@svgr/core": ">=7.0.0", "@svgx/core": "^1.0.1", "@vue/compiler-sfc": "^3.0.2", "@vue/compiler-vapor": "^3.6.0-rc.5", "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["@svgr/core", "@svgx/core", "@vue/compiler-sfc", "@vue/compiler-vapor", "svelte"] }, "sha512-wV6c7I0PtiIP8KIU7Z1Ukyc2uzgY9ind3RXNu7j63L9qTWDn7CzLnW9FD57DKbfGpWKQv3qyV2PCtBG8Y5Llcg=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + "vite": ["vite@8.3.3", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.11", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg=="], + + "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], "web-vitals": ["web-vitals@4.2.4", "", {}, "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw=="], "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + "websocket-driver": ["websocket-driver@0.7.5", "", { "dependencies": { "http-parser-js": ">=0.5.1", "safe-buffer": ">=5.1.0", "websocket-extensions": ">=0.1.1" } }, "sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA=="], "websocket-extensions": ["websocket-extensions@0.1.4", "", {}, "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg=="], @@ -690,21 +677,23 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="], + "xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], - "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + "yargs": ["yargs@17.7.3", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g=="], "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "zimmerframe": ["zimmerframe@1.1.5", "", {}, "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA=="], + "@google-cloud/firestore-api/google-gax": ["google-gax@5.0.8", "", { "dependencies": { "@grpc/grpc-js": "^1.12.6", "@grpc/proto-loader": "^0.8.0", "duplexify": "^4.1.3", "google-auth-library": "10.5.0", "google-logging-utils": "1.1.3", "node-fetch": "^3.3.2", "object-hash": "^3.0.0", "proto3-json-serializer": "3.0.4", "protobufjs": "^7.5.4", "retry-request": "^8.0.2", "rimraf": "^5.0.1" } }, "sha512-M4vpZcXQIC1gqIVGQ7eaU3jXQA6zecStyTXu514TYfThlgSurYJOxHZo9fzU6hAgwPWvuEynAVHWyaIk80VeEA=="], "@google-cloud/storage/google-auth-library": ["google-auth-library@9.15.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^6.1.1", "gcp-metadata": "^6.1.0", "gtoken": "^7.0.0", "jws": "^4.0.0" } }, "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng=="], - "@grpc/grpc-js/@types/node": ["@types/node@24.13.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-5vtOqGQr4NJKeEzV441FcOi2MeG9UTWq9LqVLGneDdu4vlX17H8kQ2PA2UmNwCUGPVDj4oBjNhS7ReVEIWJJrg=="], + "@iconify/utils/@antfu/install-pkg": ["@antfu/install-pkg@2.1.0", "", { "dependencies": { "package-manager-detector": "^1.8.0", "tinyexec": "^1.3.1" } }, "sha512-sdg9NxU3zR4Mnawfbc/x6GB5Wf17WYud5qOuEuxXjaKpYpMkISSJEjItGebXJ2bQ4DIcly4NYH23mtkGJjvKUw=="], "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], @@ -712,47 +701,39 @@ "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + "@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "@tailwindcss/node/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="], - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" }, "bundled": true }, "sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA=="], - "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.4", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A=="], "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@tailwindcss/postcss/postcss": ["postcss@8.5.25", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw=="], - - "@types/jsonwebtoken/@types/node": ["@types/node@24.13.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-5vtOqGQr4NJKeEzV441FcOi2MeG9UTWq9LqVLGneDdu4vlX17H8kQ2PA2UmNwCUGPVDj4oBjNhS7ReVEIWJJrg=="], - - "browserslist/baseline-browser-mapping": ["baseline-browser-mapping@2.11.26", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ=="], - - "bun-types/@types/node": ["@types/node@26.4.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ=="], - "gaxios/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], - "gcp-metadata/gaxios": ["gaxios@7.1.5", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg=="], + "gcp-metadata/gaxios": ["gaxios@7.3.1", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ=="], - "google-auth-library/gaxios": ["gaxios@7.1.5", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg=="], + "google-auth-library/gaxios": ["gaxios@7.3.1", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ=="], - "google-gax/@grpc/grpc-js": ["@grpc/grpc-js@1.14.4", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ=="], + "google-gax/@grpc/grpc-js": ["@grpc/grpc-js@1.14.5", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-7VZM+SVdEcUUqSQeNI3zM8Qs/BhQKZndPo2h5VkYkAM8Iz0wJIa8mKV5ekQGqG8UUsnkQ0NMxIxwkIHYvj0qOw=="], "google-gax/@grpc/proto-loader": ["@grpc/proto-loader@0.8.1", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg=="], - "next/caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="], - - "next/postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="], + "mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "protobufjs/@types/node": ["@types/node@24.13.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-5vtOqGQr4NJKeEzV441FcOi2MeG9UTWq9LqVLGneDdu4vlX17H8kQ2PA2UmNwCUGPVDj4oBjNhS7ReVEIWJJrg=="], - - "sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "svelte/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - "@google-cloud/firestore-api/google-gax/@grpc/grpc-js": ["@grpc/grpc-js@1.14.4", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ=="], + "@google-cloud/firestore-api/google-gax/@grpc/grpc-js": ["@grpc/grpc-js@1.14.5", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-7VZM+SVdEcUUqSQeNI3zM8Qs/BhQKZndPo2h5VkYkAM8Iz0wJIa8mKV5ekQGqG8UUsnkQ0NMxIxwkIHYvj0qOw=="], "@google-cloud/firestore-api/google-gax/@grpc/proto-loader": ["@grpc/proto-loader@0.8.1", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg=="], @@ -766,30 +747,46 @@ "@google-cloud/storage/google-auth-library/gcp-metadata": ["gcp-metadata@6.1.1", "", { "dependencies": { "gaxios": "^6.1.1", "google-logging-utils": "^0.0.2", "json-bigint": "^1.0.0" } }, "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A=="], - "@grpc/grpc-js/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], - "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], - "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.4.0", "", {}, "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw=="], "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], - "@tailwindcss/postcss/postcss/nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="], + "@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], - "@types/jsonwebtoken/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], - "bun-types/@types/node/undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + "@tailwindcss/node/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], - "protobufjs/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "@tailwindcss/node/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], - "@google-cloud/firestore-api/google-gax/google-auth-library/gaxios": ["gaxios@7.1.5", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg=="], + "@tailwindcss/node/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], - "@google-cloud/firestore-api/google-gax/google-auth-library/gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="], + "@tailwindcss/node/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "@tailwindcss/node/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], + + "@google-cloud/firestore-api/google-gax/google-auth-library/gaxios": ["gaxios@7.3.1", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ=="], + + "@google-cloud/firestore-api/google-gax/google-auth-library/gcp-metadata": ["gcp-metadata@8.1.4", "", { "dependencies": { "gaxios": "7.1.3", "google-logging-utils": "1.1.3", "json-bigint": "^1.0.0" } }, "sha512-iJ9KMsiu+xKtNRX0PmGLSaIU3bUBAyzWTyqKemKPzNPsmmsBCQYmlNg+brEbES7IHSXtdVwzBPzx1vz3FAaipw=="], "@google-cloud/firestore-api/google-gax/google-auth-library/gtoken": ["gtoken@8.0.0", "", { "dependencies": { "gaxios": "^7.0.0", "jws": "^4.0.0" } }, "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw=="], "@google-cloud/firestore-api/google-gax/retry-request/teeny-request": ["teeny-request@10.1.4", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "stream-events": "^1.0.5" } }, "sha512-R1Cg4Vu0UULeDfHL/kjABLaTW++9yD/B6n2g48y5dJ04hsEaxcfmAqbNDzNsbqAYJyIpZafjklLG9YxRu9uzOg=="], "@google-cloud/storage/google-auth-library/gcp-metadata/google-logging-utils": ["google-logging-utils@0.0.2", "", {}, "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ=="], + + "@google-cloud/firestore-api/google-gax/google-auth-library/gcp-metadata/gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="], } } diff --git a/web/components/add-dates-sheet.tsx b/web/components/add-dates-sheet.tsx deleted file mode 100644 index f42a353..0000000 --- a/web/components/add-dates-sheet.tsx +++ /dev/null @@ -1,208 +0,0 @@ -"use client"; - -import { type ReactElement, useState } from "react"; -import { LuCheck } from "react-icons/lu"; -import { isExpired, todayIso } from "../utils/format"; -import { findOverlaps, roomList, wholePlaceLabel } from "../utils/rooms"; -import { useKip } from "../utils/store"; -import type { AvailabilityWindow, Listing } from "../utils/types"; -import { useAction } from "./dialog"; -import { clashNote } from "./rooms"; -import Button from "./ui/button"; -import FieldNote from "./ui/field-note"; -import Segmented from "./ui/segmented"; -import Sheet from "./ui/sheet"; -import Switch from "./ui/switch"; - -export const DATE_FIELD = - "h-11 w-full rounded-xl border border-border bg-surface px-3.5 text-base outline-none transition focus:border-accent focus:ring-2 focus:ring-accent/20"; - -type Offering = "whole" | "rooms"; - -/** - * The sheet that offers new dates at a place. - * - * A place with rooms chooses what the dates offer: the whole place, or one set - * per ticked room. It opens on rooms with every one ticked; pass `roomId` to - * open with only that room ticked. A place with no rooms shows no such choice. - */ -export default function AddDatesSheet({ - listing, - existing, - roomId = null, - onClose, -}: { - listing: Listing; - existing: readonly AvailabilityWindow[]; - roomId?: string | null; - onClose: () => void; -}): ReactElement { - const { addWindow, addRoomWindows } = useKip(); - const run = useAction(); - const rooms = roomList(listing); - const [start, setStart] = useState(""); - const [end, setEnd] = useState(""); - const [details, setDetails] = useState(""); - const [autoAccept, setAutoAccept] = useState(false); - const [offering, setOffering] = useState( - rooms.length > 0 ? "rooms" : "whole", - ); - const [ticked, setTicked] = useState(() => - roomId ? [roomId] : rooms.map((room) => room.id), - ); - - // In the owner's order whatever order they were ticked in, and never a room - // removed while the sheet was open. - const chosen = rooms - .map((room) => room.id) - .filter((id) => ticked.includes(id)); - const targets: readonly (string | null)[] = - offering === "whole" ? [null] : chosen; - const ranged = Boolean(start && end && end > start); - const [first] = ranged ? findOverlaps(existing, { start, end }, targets) : []; - // `min` on the pickers is a hint a typed date walks straight past, and a slot - // that's expired the moment it exists is availability nobody can ever book. - const gone = Boolean(end) && isExpired(end); - const valid = ranged && !first && !gone && targets.length > 0; - - function toggle(id: string): void { - setTicked((current) => - current.includes(id) - ? current.filter((candidate) => candidate !== id) - : [...current, id], - ); - } - - function add(): void { - if (!valid) return; - const dates = { start, end, autoAccept, details: details.trim() }; - run(async () => { - if (offering === "whole") { - await addWindow(listing.id, dates); - } else { - await addRoomWindows(listing.id, chosen, dates); - } - onClose(); - }); - } - - const caption = - chosen.length === 0 - ? "Tick at least one room." - : chosen.length === 1 - ? "Adds 1 set of dates." - : `Adds ${chosen.length} sets of dates, one for each room.`; - - return ( - -
    -
    - - -
    - - {rooms.length > 0 ? ( -
    - What's free - - {offering === "rooms" ? ( - <> -
    - {rooms.map((room) => { - const on = ticked.includes(room.id); - return ( - // biome-ignore lint/a11y/useSemanticElements: a native checkbox can't hold the two-line label and the drawn tick as one tap target - - ); - })} -
    - {caption} - - ) : null} -
    - ) : null} - - -
    - -
    - {first ? ( - - {clashNote(listing, first.roomId, first.clash, chosen.length > 1)} - - ) : gone ? ( - Those dates have already passed. - ) : null} - -
    -
    - ); -} diff --git a/web/components/auth-menu.tsx b/web/components/auth-menu.tsx deleted file mode 100644 index bdcc332..0000000 --- a/web/components/auth-menu.tsx +++ /dev/null @@ -1,255 +0,0 @@ -"use client"; - -import { type ReactElement, useEffect, useRef, useState } from "react"; -import { - LuChevronDown, - LuDownload, - LuInbox, - LuLogOut, - LuMessageSquare, - LuSettings, - LuUser, -} from "react-icons/lu"; -import { credentialed } from "../utils/feedback"; -import { useInstall } from "../utils/install"; -import { useKip } from "../utils/store"; -import Avatar from "./avatar"; -import { useDialog } from "./dialog"; -import FeedbackSheet from "./feedback-sheet"; -import { useLeave } from "./use-leave"; - -// Only shown once signed in (the app gates on auth). Sign-in itself lives on -// the WelcomeScreen, so this is the profile menu: your profile, Settings (the -// dock has no room for it), feedback, installing kip where that is possible, -// and sign-out. -export default function AuthMenu(): ReactElement | null { - const { - user, - anonymous, - email, - doors, - emailVerified, - admin, - unreadFeedback, - profile, - signOut, - setView, - navigate, - } = useKip(); - const { leave, leaving } = useLeave(); - const { ready, byHand, install } = useInstall(); - const { alert } = useDialog(); - const [open, setOpen] = useState(false); - const [feedback, setFeedback] = useState(false); - - const name = profile?.displayName ?? user?.displayName ?? null; - const photoURL = profile?.photoURL ?? user?.photoURL ?? null; - - async function doSignOut() { - // Leaving is a teardown, not a sign-out, so it shares Settings' flow. The - // menu stays open meanwhile, so "Leaving…" has somewhere to show until the - // sign-out unmounts it. - if (anonymous) { - await leave(); - return; - } - setOpen(false); - try { - await signOut(); - } catch (error) { - console.error(error); - } - } - - const trigger = useRef(null); - const menu = useRef(null); - useEffect(() => { - if (!open) return; - const items = () => - Array.from( - menu.current?.querySelectorAll('[role="menuitem"]') ?? [], - ); - items()[0]?.focus(); - function onKey(event: KeyboardEvent): void { - if (event.key === "Escape") { - setOpen(false); - trigger.current?.focus(); - } else if (event.key === "ArrowDown" || event.key === "ArrowUp") { - event.preventDefault(); - const all = items(); - const at = all.indexOf(document.activeElement as HTMLElement); - const step = event.key === "ArrowDown" ? 1 : -1; - all[(at + step + all.length) % all.length]?.focus(); - } - } - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [open]); - - // portal/continue render neither the nav stack nor Settings, so every row - // would be a dead tap. - if ( - typeof window !== "undefined" && - /\/(portal|continue)\/?$/.test(window.location.pathname) - ) { - return null; - } - // A nameless ANONYMOUS session gets no menu, and wants none: it holds no - // profile, no ask and no friends, so leaving would swap one empty anonymous - // account for another. A nameless account with a credential keeps it — - // Settings and the exit live only here, and the name sheet can be dismissed. - if (!user || (anonymous && !name)) return null; - - return ( -
    - - {open ? ( - <> - - - {/* Only the operator, and only the rules make that true: the - fragment this opens is guessable, and reaching it without the - role renders an empty list rather than anything. */} - {admin ? ( - - ) : null} - {/* The rules refuse a report from an identity a page load mints, so - the row is hidden rather than offered and then denied. What that - costs is the visitor best placed to report a broken share link, - which is the trade the credential gate makes everywhere. */} - {credentialed(doors, emailVerified) ? ( - - ) : null} - {/* Only where it can do something. Chrome hands over a prompt and - this opens it; Safari has no such event, so on an iPhone the row - says where the control actually is rather than offering a button - that cannot work. Installed already, neither shows. */} - {ready || byHand ? ( - - ) : null} - {/* Everyone gets an exit, and it is the one thing in this menu - that reads as an action rather than a destination. What differs - is what it MEANS: with a credential it is an ordinary sign-out; - without one there is no way back in, so leaving is deletion and - the word says so before the confirm does. Hiding it from - unverified sessions left the people it matters most to with no - way out of the menu at all. */} - -
    - - ) : null} - setFeedback(false)} /> -
    - ); -} diff --git a/web/components/auth-panel.tsx b/web/components/auth-panel.tsx deleted file mode 100644 index 89c31cc..0000000 --- a/web/components/auth-panel.tsx +++ /dev/null @@ -1,206 +0,0 @@ -"use client"; - -import { type ReactElement, useRef, useState } from "react"; -import { FaGoogle } from "react-icons/fa"; -import { authErrorMessage } from "../utils/auth"; -import { useKip } from "../utils/store"; -import { useDialog } from "./dialog"; -import ReachField, { - codeReady, - confirmReach, - EMPTY_REACH, - type ReachState, - reachError, - sendReach, -} from "./reach-field"; -import Busy from "./ui/busy"; -import Button from "./ui/button"; - -// The door — the whole of it, since the same one-time link both makes an account -// and returns you to one, and the flow never asks which you are. No password: a -// one-time link or code is fewer things to remember and fewer to lose, and -// retiring it took the whole reset flow — its own screen, its own failure -// states, its own careful "if an account exists" notice — out of the product. -// -// It offers the SAME three doors as the identity sheet, and that is the point -// rather than a nicety: an account whose only credential is a phone number could -// not get back in on a new device while this offered email and Google alone. -// Anything addable must be returnable. -// -// Nothing here says sign in or sign up, and the omission is deliberate: the same -// link works whether or not kip has met this address, so there is no question to -// answer and no wrong door to pick. Nor does anything spell out the accepted -// formats — the placeholder and the "Use phone" link carry them, and the US-only -// limit is said by `reachError` at the moment it bites. -// -// It owns the CARD as well as the form, so that the standing notice under the -// card and the error that replaces it are one node rather than the same string -// synchronised into two components. `notice` is what that line says when -// nothing is wrong. -export default function AuthPanel({ - notice, -}: { - notice: string; -}): ReactElement { - const { configured, signIn } = useKip(); - const { alert } = useDialog(); - const [reach, setReach] = useState(EMPTY_REACH); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - const [sentTo, setSentTo] = useState(null); - const recaptcha = useRef(null); - - const invalid = reachError(reach.raw); - const problem = invalid ?? error; - - async function guardConfigured(): Promise { - if (configured) return true; - await alert({ - title: "Firebase isn't set up yet", - body: "Create a Firebase project, enable Email link, Phone and Google sign-in, and paste its config into web/utils/firebase.ts to continue.", - }); - return false; - } - - async function run(action: () => Promise): Promise { - if (!(await guardConfigured())) return; - setBusy(true); - setError(null); - try { - await action(); - } catch (caught) { - console.error(caught); - // Remedy-shaped, matching the other two surfaces: `authErrorMessage` - // ends at "Something went wrong. Try again." for most codes, which tells - // nobody what to do differently. Its specific cases still speak. - const mapped = authErrorMessage(caught); - setError( - mapped === "Something went wrong. Try again." - ? "Couldn't send that. Check or change it." - : mapped, - ); - } finally { - setBusy(false); - } - } - - async function submit(): Promise { - if (invalid || !reach.raw) return; - // A code already sent is waiting to be typed, so this finishes it. - if (reach.pending) { - // Caught apart from the send, and worded like its siblings: routed - // through `run`, a refused code came back as "couldn't send that" while - // the code step in front of them was asking for exactly that code. - setBusy(true); - setError(null); - try { - await confirmReach(reach.pending, reach.code); - } catch (caught) { - console.error(caught); - setError("Wrong code. Check it, or ask for another."); - } finally { - setBusy(false); - } - return; - } - const holder = recaptcha.current; - // Reported, not swallowed — the two sheets throw here and say so, and a - // dead Continue with no spinner and nothing in the console is the exact - // divergence these four surfaces exist to have eliminated. - if (!holder) { - setError("Couldn't start that. Reload and try again."); - return; - } - await run(async () => { - const sent = await sendReach(reach.raw, "", holder, "return"); - if (sent.pending) setReach({ ...reach, ...sent }); - else setSentTo(sent.sentTo); - }); - } - - // Deliberately identical whether or not kip knows the address — that is what - // preserves the non-enumeration the old reset notice had to spell out. - const body = sentTo ? ( -
    -

    - Open the link we sent to {sentTo} and you're in. It works for about an - hour. -

    - -
    - ) : ( -
    { - event.preventDefault(); - submit(); - }} - > - { - setError(null); - setReach(next); - }} - hostRef={recaptcha} - invalid={Boolean(problem)} - busy={busy} - /> - - - {/* Under the button it stands in for, the same as every other surface - that offers it — above, it reads as the recommended route, which is - a claim kip has no reason to make about one door of three. */} -
    - - or - -
    - - - ); - - return ( - <> - {/* No heading and no label. An email field over a Continue button is - self-evidently the way in, and "no sign-up, no password" is said by - the absence of a toggle and a password field rather than by a line - claiming it. */} -
    - {body} -
    - {/* Two lines reserved whatever speaks: the page is a centred column, so - a height change here would shift the door. One line of error is - pinned by `tests/auth-copy.test.ts`. */} -

    - {problem ?? notice} -

    - - ); -} diff --git a/web/components/avatar.tsx b/web/components/avatar.tsx deleted file mode 100644 index cd8afe3..0000000 --- a/web/components/avatar.tsx +++ /dev/null @@ -1,47 +0,0 @@ -"use client"; - -import type { ReactElement } from "react"; -import { photoSrc } from "../utils/photos"; - -// Pass sizing via className (e.g. "h-9 w-9 text-sm") — Tailwind needs literal -// classes, so size can't be a number prop. `ring` wraps the avatar in a 2px -// terracotta→amber gradient ring, used to feature a host. -export default function Avatar({ - name, - photoURL, - className = "h-9 w-9 text-sm", - ring = false, -}: { - name: string; - photoURL: string | null; - className?: string; - ring?: boolean; -}): ReactElement { - // Almost every avatar drawn here comes from a copy the person it describes - // wrote — a friend edge, a booking, a share link — so the address is theirs to - // choose and `photoSrc` is what keeps it from being an arbitrary one. A URL - // that fails the check falls back to the initial, same as no photo at all. - const src = photoURL === null ? null : photoSrc(photoURL); - const inner = src ? ( - // biome-ignore lint/performance/noImgElement: static export, no next/image loader - - ) : ( - - {(name || "?").charAt(0).toUpperCase()} - - ); - - if (!ring) return inner; - return ( - - {inner} - - ); -} diff --git a/web/components/booking-page.tsx b/web/components/booking-page.tsx deleted file mode 100644 index 63ff9f3..0000000 --- a/web/components/booking-page.tsx +++ /dev/null @@ -1,454 +0,0 @@ -"use client"; - -import { type ReactElement, type ReactNode, useEffect, useState } from "react"; -import { - LuCalendarDays, - LuCheck, - LuChevronRight, - LuClock, - LuMapPin, - LuX, -} from "react-icons/lu"; -import { fetchBookingIfVisible } from "../utils/bookings"; -import { - checkoutParts, - type StayCheckout, - stayOpensCheckout, -} from "../utils/checkout"; -import { formatDateRange, isExpired, nights } from "../utils/format"; -import { fetchStayCheckout, listingTypeIcon } from "../utils/listings"; -import { useKip } from "../utils/store"; -import type { Booking, CancelReason } from "../utils/types"; -import Avatar from "./avatar"; -import CoverPhoto from "./cover-photo"; -import { useAction, useDialog, useFailure } from "./dialog"; -import Button from "./ui/button"; -import Chip, { type ChipTone } from "./ui/chip"; -import { Group, Row, Section } from "./ui/list"; -import { useStayPlace } from "./use-stay-place"; - -// `byMe` is the only axis needed, because the reason and the side always agree — -// every reason but STAY_CANCELLED belongs to exactly one party. -function cancelNote( - reason: CancelReason | null, - byMe: boolean, - otherName: string, -): string { - switch (reason) { - case "DECLINED": - return byMe - ? "You declined this request" - : `${otherName} couldn't host these dates`; - case "WITHDRAWN": - return byMe - ? "You took this request back" - : `${otherName} took this request back`; - case "SLOT_MOVED": - return byMe - ? "You moved these dates, so this request was cancelled" - : `${otherName} moved these dates, so this request was cancelled`; - case "SLOT_CANCELLED": - return byMe - ? "You called these dates off" - : `${otherName} called these dates off`; - case "STAY_CANCELLED": - return byMe - ? "You cancelled this stay" - : `${otherName} cancelled this stay`; - default: - return "This booking was cancelled"; - } -} - -// The stay, guest side, or the request, owner side. -export default function BookingPage({ id }: { id: string }): ReactElement { - const { - user, - trips, - incomingBookings, - knownPerson, - cancelTrip, - confirmBooking, - declineBooking, - hideBooking, - navigate, - back, - } = useKip(); - const run = useAction(); - const { alert, confirm } = useDialog(); - const fail = useFailure(); - const [busy, setBusy] = useState(false); - // Neither subscription carries a stay you're only a spectator of, so a friend - // arriving from a held slot has to fetch it. `looked` keeps the not-available - // line from flashing before the answer is back. - const [fetchedBooking, setFetchedBooking] = useState( - undefined, - ); - const [looked, setLooked] = useState(false); - const found: Booking | undefined = - trips.find((candidate) => candidate.id === id) ?? - incomingBookings.find((candidate) => candidate.id === id) ?? - // Pinned to the id being rendered: the state outlives a move to another - // booking, so without this the previous one shows under the new URL. - (fetchedBooking?.id === id ? fetchedBooking : undefined); - const { listing: room, room: stayRoom } = useStayPlace(found); - const iAmPartyTo = - trips.some((candidate) => candidate.id === id) || - incomingBookings.some((candidate) => candidate.id === id); - - useEffect(() => { - if (iAmPartyTo) return; - let live = true; - setLooked(false); - fetchBookingIfVisible(id) - .then((found) => { - if (!live) return; - setFetchedBooking(found ?? undefined); - }) - .catch((error) => console.error("visitBooking", error)) - .finally(() => { - if (live) setLooked(true); - }); - return () => { - live = false; - }; - }, [id, iAmPartyTo]); - - // Only the guest of a confirmed stay may read these, and only until the - // rules' cut-off after check-out; they refuse everyone else, so nobody else - // asks. - const [checkout, setCheckout] = useState<{ - id: string; - found: StayCheckout; - } | null>(null); - const guestUid = found?.guestId === user?.uid ? user?.uid : undefined; - const staying = - found !== undefined && stayOpensCheckout(found) && guestUid !== undefined; - const stayListingId = found?.listingId; - const stayWindowId = found?.windowId; - useEffect(() => { - if (!staying || !guestUid || !stayListingId || !stayWindowId) return; - let live = true; - fetchStayCheckout( - { id, listingId: stayListingId, windowId: stayWindowId }, - guestUid, - ) - .then((answer) => { - if (live) setCheckout({ id, found: answer }); - }) - .catch((error) => console.error("stayCheckout", error)); - return () => { - live = false; - }; - }, [id, staying, guestUid, stayListingId, stayWindowId]); - - // The ordinary double-click only; the transaction is the real protection. - async function confirmStay(): Promise { - if (!booking) return; - setBusy(true); - try { - const outcome = await confirmBooking(booking); - if (outcome === "unavailable") { - // Either another stay took the dates or the ask was withdrawn, and the - // transaction can't tell which without claiming more than it knows. - await alert({ - title: "Too late for this one", - body: "Either those dates went to another stay or the guest took their request back — either way there's nothing left to confirm. This page will catch up in a moment.", - }); - } - } catch (error) { - fail(error, "Couldn't confirm this stay. Please try again."); - } finally { - setBusy(false); - } - } - - const booking = found; - if (!booking) { - return ( -

    - {looked ? "This booking isn't available." : ""} -

    - ); - } - - // A stay in one room names it ahead of the place it is in. - const title = room - ? stayRoom - ? `${stayRoom.name} · ${room.title}` - : room.title - : "A place"; - const cover = stayRoom?.photos[0] ?? room?.photos[0]; - const address = room?.location.label || "Address unavailable"; - const iAmGuest = booking.guestId === user?.uid; - const iAmHost = booking.ownerId === user?.uid; - // A friend of the guest can be here without being either party — the stay is - // theirs to see, none of it is theirs to change. - const iAmParty = iAmGuest || iAmHost; - // A spectator came for the guest; a guest came for their host. - const otherUid = iAmGuest ? booking.ownerId : booking.guestId; - // A pending ask authorises no read, so an unanswered stranger stays "Someone" - // with the role beneath carrying what is actually known. - const other = knownPerson(otherUid); - const otherName = other?.displayName || "Someone"; - const otherPhoto = other?.photoURL ?? null; - const PlaceIcon = room ? listingTypeIcon(room.type) : LuMapPin; - // A round 40px crop of a room is barely a picture. - const hero = ( - - ); - // Every row in this card leads with an icon, so dropping it for the place - // alone left the title out of line with the rows beneath. - const placeThumb = ( - - - - ); - - // Pinned to this stay and its current state: the answer outlives a move to - // another booking, and a cancel that lands while the page is open. - const stayCheckout = - iAmGuest && stayOpensCheckout(booking) && checkout?.id === id - ? checkout.found - : null; - const checkingOut = stayCheckout - ? checkoutParts( - stayCheckout.place, - stayCheckout.room, - (stayCheckout.roomId - ? room?.rooms[stayCheckout.roomId]?.name - : undefined) ?? null, - ) - : []; - - // The page it's on has nothing left to show once hidden. - async function clearFromList(cleared: Booking): Promise { - const agreed = await confirm({ - title: "Clear this from your list?", - body: `It disappears from your ${iAmGuest ? "trips" : "bookings"}. ${otherName} still has their copy — nothing is deleted.`, - confirmLabel: "Clear", - }); - if (!agreed) return; - await hideBooking(cleared.id); - back(); - } - - const moment: { - tone: ChipTone; - label: string; - note: string; - circle: string; - icon: ReactNode; - } = - booking.status === "CONFIRMED" - ? { - tone: "confirmed", - label: "Confirmed", - note: iAmGuest - ? "Your stay is all set" - : iAmHost - ? `You're hosting ${otherName}` - : `${otherName} is staying here`, - circle: "bg-success-soft text-success", - icon: , - } - : booking.status === "REQUESTED" - ? { - tone: "pending", - label: "Pending", - note: iAmGuest - ? `Waiting on ${otherName} to confirm` - : iAmHost - ? `${otherName} wants to book` - : `${otherName} has asked to stay here`, - circle: "bg-pending-soft text-pending", - icon: , - } - : { - tone: "neutral", - label: "Cancelled", - note: cancelNote( - booking.cancelReason, - booking.cancelledBy === user?.uid, - otherName, - ), - circle: "bg-surface-muted text-muted", - icon: , - }; - - return ( -
    - {/* With a photo the whole status — icon and label — sits ON it, because - the state is about that stay and the image is what the stay IS. The - header keeps only the sentence. Without one it all falls back to the - header, which is where it used to live. */} - {cover ? ( -
    - {hero} - - - {moment.icon} - - {moment.label} - -
    - ) : null} - -
    - {cover ? null : ( - <> - - {moment.icon} - - {moment.label} - - )} -

    {moment.note}

    -
    - - - {room ? ( - navigate({ kind: "room", id: room.id })} - ariaLabel={title} - > - {placeThumb} -
    - - {title} - - - {address} - -
    - -
    - ) : ( - - {placeThumb} -
    - - {title} - - - {address} - -
    -
    - )} - - - - -
    - - {formatDateRange(booking.start, booking.end)} - - - {nights(booking.start, booking.end)} nights - -
    -
    - {/* Tappable whether or not they're a friend: it's the one place a - share-link guest and their host can ask to connect, since neither is - searchable to the other and neither holds the other's link. */} - navigate({ kind: "person", id: otherUid })} - ariaLabel={otherName} - > - -
    - - {otherName} - - - {iAmGuest ? "Your host" : iAmHost ? "Your guest" : "Staying here"} - -
    - -
    -
    - - {checkingOut.length > 0 ? ( -
    -
    - {checkingOut.map((part) => ( -
    - {part.label ? ( -

    - {part.label} -

    - ) : null} -

    - {part.text} -

    -
    - ))} -
    -
    - ) : null} - - {!iAmParty ? null : booking.status !== "CANCELLED" ? ( -
    - {iAmGuest ? ( - - ) : booking.status === "REQUESTED" && isExpired(booking.end) ? ( - <> -

    - These dates have passed, so this can no longer be confirmed. - Declining lets the guest know. -

    - - - ) : booking.status === "REQUESTED" ? ( - <> - - - - ) : ( - - )} -
    - ) : ( - - )} -
    - ); -} diff --git a/web/components/booking-row.tsx b/web/components/booking-row.tsx deleted file mode 100644 index 28dac03..0000000 --- a/web/components/booking-row.tsx +++ /dev/null @@ -1,122 +0,0 @@ -"use client"; - -import type { ReactElement } from "react"; -import { LuChevronRight, LuMapPin } from "react-icons/lu"; -import { formatDateRange } from "../utils/format"; -import { listingTypeIcon } from "../utils/listings"; -import { wholePlaceLabel } from "../utils/rooms"; -import { useKip } from "../utils/store"; -import type { Booking, BookingStatus } from "../utils/types"; -import CoverPhoto from "./cover-photo"; -import { hasRooms, RoomThumb } from "./rooms"; -import Chip, { type ChipTone } from "./ui/chip"; -import { Row } from "./ui/list"; -import { useStayPlace } from "./use-stay-place"; - -const STATUS: Record = { - REQUESTED: { label: "Pending", tone: "pending" }, - CONFIRMED: { label: "Confirmed", tone: "confirmed" }, - CANCELLED: { label: "Cancelled", tone: "neutral" }, -}; - -const PIECE = - "relative max-w-full truncate pl-3 before:absolute before:left-[0.2rem] before:content-['·']"; - -// `lead` says which of the place and the person comes first: the place tells -// rows apart on a list spanning places, and repeats uselessly on one place's own -// Guests list. The cover follows it for the same reason. -export default function BookingRow({ - booking, - lead = "place", - showCounterpart = true, - showDates = true, -}: { - booking: Booking; - lead?: "place" | "person"; - // Off on a list that is already about one person — naming them on every row - // says nothing, and on a stay you're only watching there is no "with" to it. - showCounterpart?: boolean; - // Off inside one slot's own sheet, where the dates are the title. An ask is - // written against the slot's dates and `updateWindow` cancels any ask the - // dates move out from under, so repeating them there says nothing. Note the - // rules pin the two together only at create and at confirm, not in between — - // so this is about noise, and not a claim that they cannot differ. - showDates?: boolean; -}): ReactElement { - const { user, knownPerson, navigate } = useKip(); - const { listing: known, room } = useStayPlace(booking); - - const iAmGuest = booking.guestId === user?.uid; - const otherUid = iAmGuest ? booking.ownerId : booking.guestId; - // A pending ask authorises no read, so an unanswered stranger is "Someone". - const otherName = knownPerson(otherUid)?.displayName || "Someone"; - const title = known?.title || "A place"; - // An unreadable place falls back to a pin: the leading slot exists either way, - // and an empty one breaks the row rhythm. - const PlaceIcon = known ? listingTypeIcon(known.type) : LuMapPin; - const status = STATUS[booking.status]; - const person = iAmGuest ? `with ${otherName}` : otherName; - // A stay in one room leads with the room and names the house beneath, since - // the two side by side don't fit a phone's width. - const headline = lead === "person" ? otherName : room ? room.name : title; - const detail = lead === "person" || !showCounterpart ? "" : `${person} · `; - // On one place's own Guests list the place is given, so what tells two rows - // apart is the room — or that it was the whole place, where there are rooms. - const offered = - lead === "person" && showDates && known && hasRooms(known) - ? `${room ? room.name : wholePlaceLabel(known.type)} · ` - : ""; - const when = showDates ? formatDateRange(booking.start, booking.end) : null; - - return ( - navigate({ kind: "booking", id: booking.id })} - ariaLabel={headline} - > - {lead !== "place" ? null : room ? ( - // The room's own picture or none: the house's would pass for the room. - - ) : ( - - - - } - /> - )} -
    - - {headline} - - {lead === "place" && room ? ( - // The house and the rest share a line where they fit and break - // between the two where they don't. Each piece carries its own - // leading dot, hung in a gutter the outer box clips — so a piece - // that starts a line shows none. - - - {title} - {detail || when ? ( - - {detail} - {when} - - ) : null} - - - ) : showDates || detail || offered ? ( - - {offered} - {detail} - {when} - - ) : null} -
    - {status.label} - -
    - ); -} diff --git a/web/components/browse-view.tsx b/web/components/browse-view.tsx deleted file mode 100644 index 002b5a7..0000000 --- a/web/components/browse-view.tsx +++ /dev/null @@ -1,324 +0,0 @@ -"use client"; - -import { type ReactElement, useMemo, useState } from "react"; -import { - LuLocateFixed, - LuMapPin, - LuRotateCw, - LuSlidersHorizontal, - LuX, -} from "react-icons/lu"; -import { todayIso } from "../utils/format"; -import { geocodeAddress } from "../utils/geocode"; -import { - describeCriteria, - EMPTY_CRITERIA, - type SearchCriteria, - searchListings, -} from "../utils/search"; -import { useKip } from "../utils/store"; -import type { ListingType } from "../utils/types"; -import PlaceCard from "./place-card"; -import SavedSearches from "./saved-searches"; -import Button from "./ui/button"; -import IconButton from "./ui/icon-button"; -import Segmented from "./ui/segmented"; -import Sheet from "./ui/sheet"; - -const RADII = [10, 25, 50, 100, 250] as const; - -const FIELD = - "h-11 w-full rounded-xl border border-border bg-surface px-3.5 text-base outline-none transition focus:border-accent focus:ring-2 focus:ring-accent/20"; - -export default function BrowseView(): ReactElement | null { - const { - user, - friends, - friendListings, - friendWindows, - criteria, - setCriteria, - refreshBrowse, - } = useKip(); - const [filtersOpen, setFiltersOpen] = useState(false); - const [refreshing, setRefreshing] = useState(false); - - const matches = useMemo( - () => searchListings(friendListings, friendWindows, criteria), - [friendListings, friendWindows, criteria], - ); - - async function refresh(): Promise { - setRefreshing(true); - try { - await refreshBrowse(); - } finally { - setRefreshing(false); - } - } - - if (!user) return null; - - return ( -
    -
    - - - - -
    - - {friends.length === 0 ? ( -

    - Add some friends first — when they share a place, you'll see it here. -

    - ) : matches.length === 0 ? ( -

    - {criteria.near - ? "No friends' places match — try a wider radius or dates." - : "No friends' places free right now. Try widening your dates."} -

    - ) : ( -
    - {matches.map((match) => ( -
    - -
    - ))} -
    - )} - - setFiltersOpen(false)} - criteria={criteria} - setCriteria={setCriteria} - resultCount={matches.length} - /> -
    - ); -} - -function FilterSheet({ - open, - onClose, - criteria, - setCriteria, - resultCount, -}: { - open: boolean; - onClose: () => void; - criteria: SearchCriteria; - setCriteria: (criteria: SearchCriteria) => void; - resultCount: number; -}): ReactElement { - const [nearText, setNearText] = useState(""); - const [locating, setLocating] = useState(false); - const [geoMsg, setGeoMsg] = useState(null); - - function update(partial: Partial): void { - setCriteria({ ...criteria, ...partial }); - } - - async function findLocation(): Promise { - const queryText = nearText.trim(); - if (!queryText) return; - setLocating(true); - setGeoMsg(null); - const hit = await geocodeAddress(queryText); - setLocating(false); - if (!hit) { - setGeoMsg("Couldn't find that place — try a city or postcode."); - return; - } - update({ near: { lat: hit.lat, lng: hit.lng }, nearLabel: hit.label }); - setNearText(""); - } - - function useMyLocation(): void { - if (!navigator.geolocation) { - setGeoMsg("Location isn't available in this browser."); - return; - } - setLocating(true); - setGeoMsg(null); - navigator.geolocation.getCurrentPosition( - (position) => { - setLocating(false); - update({ - near: { - lat: position.coords.latitude, - lng: position.coords.longitude, - }, - nearLabel: "Your location", - }); - setNearText(""); - }, - (error) => { - setLocating(false); - setGeoMsg("Couldn't get your location."); - console.error(error); - }, - { timeout: 10000 }, - ); - } - - function clearLocation(): void { - update({ near: null, nearLabel: null }); - setNearText(""); - setGeoMsg(null); - } - - return ( - -
    -
    - - -
    - -
    - Type - - update({ type: value === "ANY" ? null : (value as ListingType) }) - } - options={[ - { value: "ANY", label: "Any" }, - { value: "ROOM", label: "Room" }, - { value: "FLAT", label: "Flat" }, - { value: "HOUSE", label: "House" }, - ]} - /> -
    - -
    - Location - {criteria.near ? ( -
    - - - - {criteria.nearLabel ?? "Nearby"} - - - - - - -
    - ) : ( - <> - setNearText(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - findLocation(); - } - }} - /> -
    - - -
    - - )} - {geoMsg ?

    {geoMsg}

    : null} -
    - -
    - - -
    - - {/* Below the footer, not above it: picking a saved search is the rarer - reason to be here, and the primary action must stay reachable - without scrolling past a list. */} - -
    -
    - ); -} diff --git a/web/components/checkout-field.tsx b/web/components/checkout-field.tsx deleted file mode 100644 index 3155682..0000000 --- a/web/components/checkout-field.tsx +++ /dev/null @@ -1,36 +0,0 @@ -"use client"; - -import { type ReactElement, useId } from "react"; -import { CHECKOUT_MAX } from "../utils/checkout"; -import FieldNote from "./ui/field-note"; -import { Textarea } from "./ui/input"; - -/** The check-out instructions field, shared by the place form and the room sheet. */ -export default function CheckoutField({ - value, - onChange, - disabled = false, -}: { - value: string; - onChange: (value: string) => void; - disabled?: boolean; -}): ReactElement { - const fieldId = useId(); - return ( - + +
    + Address +
    + { + coords = null; + matches = []; + geo = "idle"; + }} + onkeydown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + lookup(); + } + }} + /> + +
    + {#if matches.length > 0} +
      + {#each matches as match (`${match.lat},${match.lng},${match.label}`)} +
    • + +
    • + {/each} +
    + {:else if geo === "found"} +

    📍 Located — coordinates saved.

    + {:else if geo === "notfound"} +

    + Couldn't find that address. You can still save it as-is. +

    + {:else} +

    + Type an address and press Enter (or Find) to pin it on the map. +

    + {/if} +
    + +
    + Photos + { + uploading = now; + }} + /> +
    + + { + checkoutDraft = next; + }} + disabled={checkout === undefined} + /> + + {#if canHaveRooms(type)} +
    + Rooms + + {#each rooms as room (room.id)} + { + openRoom = { roomId: room.id }; + }} + /> + {/each} + + + + Rooms are optional. Add them if friends can stay in one without taking + the {wholePlaceLabel(type).toLowerCase()}. + +
    + {/if} + + + {#if uploading} +

    + Waiting for photos to finish uploading… +

    + {/if} + + {#if openRoom} + {@const roomId = openRoom.roomId} + {#key roomId ?? "new"} + {#if initial} + + {:else} + room.id === roomId) ?? null} + onsave={ondraftroom} + onremove={ondropdraftroom} + onclose={closeRoom} + /> + {/if} + {/key} + {/if} +
    diff --git a/web/src/lib/components/listing-row.svelte b/web/src/lib/components/listing-row.svelte new file mode 100644 index 0000000..50e4256 --- /dev/null +++ b/web/src/lib/components/listing-row.svelte @@ -0,0 +1,77 @@ + + + kip.navigate({ kind: "room", id: listing.id })} + ariaLabel={listing.title} +> + + + {#snippet fallback()} + + + + {/snippet} + +
    + + {listing.title} + + {meta} +
    + {#if pending > 0} + + {pending} + {pending === 1 ? "request" : "requests"} + + {/if} + +
    diff --git a/web/src/lib/components/mark.svelte b/web/src/lib/components/mark.svelte new file mode 100644 index 0000000..04dd9a1 --- /dev/null +++ b/web/src/lib/components/mark.svelte @@ -0,0 +1,6 @@ + + + k + diff --git a/web/src/lib/components/name-gate.svelte b/web/src/lib/components/name-gate.svelte new file mode 100644 index 0000000..8833052 --- /dev/null +++ b/web/src/lib/components/name-gate.svelte @@ -0,0 +1,382 @@ + + + + + + + {#if sentTo} +
    +

    + Check {sentTo} and open the link — that's what keeps your kip if you + switch phones. Nothing is waiting on it; you can close this. +

    + +
    + {:else} +
    { + event.preventDefault(); + void submit(); + }} + > + {#if needsName} + + {/if} + + {#if needsReach} + { + error = null; + reach = next; + }} + bind:host={recaptcha} + invalid={Boolean(reachInvalid || error)} + {busy} + /> + {/if} + + +

    + {problem ?? (needsReach ? message : null)} +

    + + + + +
    + + or + +
    + + + {/if} +
    diff --git a/web/src/lib/components/nav-items.ts b/web/src/lib/components/nav-items.ts new file mode 100644 index 0000000..8909441 --- /dev/null +++ b/web/src/lib/components/nav-items.ts @@ -0,0 +1,44 @@ +import LuDoorOpen from "~icons/lucide/door-open"; +import LuHouse from "~icons/lucide/house"; +import LuLuggage from "~icons/lucide/luggage"; +import LuSearch from "~icons/lucide/search"; +import LuUsers from "~icons/lucide/users"; +import type { Icon } from "../listing-icons"; +import { kip } from "../store.svelte"; +import type { View } from "../types"; + +export type NavItem = { + readonly view: View; + readonly label: string; + readonly icon: Icon; + readonly badge: number; +}; + +// The primary destinations, shared by the desktop top bar and the mobile dock. +// Settings lives in the profile menu (AuthMenu) instead, to keep it to five +// thumb-sized tabs. Reads the store, so call it from a `$derived`. +export function navItems(): NavItem[] { + const pendingBookings = kip.incomingBookings.filter( + (booking) => booking.status === "REQUESTED", + ).length; + + return [ + { view: "home", label: "Home", icon: LuHouse, badge: 0 }, + { view: "browse", label: "Browse", icon: LuSearch, badge: 0 }, + { + view: "places", + label: "Places", + icon: LuDoorOpen, + // Stays people are asking for. Friend asks are counted on Friends, not + // here — they were double-counted when the two request kinds merged names. + badge: pendingBookings, + }, + { view: "trips", label: "Trips", icon: LuLuggage, badge: 0 }, + { + view: "friends", + label: "Friends", + icon: LuUsers, + badge: kip.incomingRequests.length, + }, + ]; +} diff --git a/web/src/lib/components/notifications-section.svelte b/web/src/lib/components/notifications-section.svelte new file mode 100644 index 0000000..671eb36 --- /dev/null +++ b/web/src/lib/components/notifications-section.svelte @@ -0,0 +1,335 @@ + + + + +{#if kip.user} + +
    +
    + + {#if kip.email} + {#if !kip.emailVerified} +
    +

    + kip only emails an address that's been confirmed — otherwise + anyone could enter someone else's and have kip mail them. Confirm + {kip.email} + to start receiving these. +

    + {#if sent} + + Sent. Check your inbox, then reload kip. + + {:else} + + {/if} + {#if error} + {error} + {/if} +
    + {/if} + {:else} +
    +

    + {kip.phone && SMS_LIVE + ? "kip has no address for you, so none of this arrives by email. A text can still reach you — turn those on below." + : "kip has no way to reach you when you're not looking at it. Add an address to hear when things happen."} +

    + +
    + {/if} + + + {#each EVENTS as [key, event] (key)} + kip.setNotify(key as NotifyKind, next)} + label={event.label} + description={event.note} + srSuffix="by email" + /> + {/each} + +
    + +
    + + + + {#each EVENTS as [key, event] (key)} + {#if event.sms} + + toggleKind(key as NotifySmsKind, next)} + label={event.label} + srSuffix="by text" + /> + {:else} + + {}} + label={event.label} + unavailable="Email only" + /> + {/if} + {/each} + + + {#if textError} + {textError} + {/if} + + + {#if SMS_LIVE && blocked} + + + Your carrier is blocking kip's texts: STOP was replied from + {kip.phone ?? "your number"}. kip can't lift that. + {#if SMS_FROM} + Text START to + {formatUsNumber(SMS_FROM)}, then check below. + {:else} + Text START to the number kip texted you from, and this clears the + next time a text gets through. + {/if} + + {#if SMS_FROM} + + + {#if checkedStillBlocked} + kip tried a text; the block is still there. + {:else if probe === "stalled"} + That check didn't run. Try again. + {/if} + + {/if} + + {/if} + + + + See our + Privacy Policy{" "}and{" "}Terms. + + + + {#if !kip.prefs.notify.stayCancelled && + !(texting && kip.prefs.notifySms.stayCancelled)} + + Nothing will tell you if a stay you're counting on is called off — no + email, no text. You'd only find out by opening kip. + + {/if} +
    +
    +{/if} diff --git a/web/src/lib/components/offer-heading.svelte b/web/src/lib/components/offer-heading.svelte new file mode 100644 index 0000000..551209e --- /dev/null +++ b/web/src/lib/components/offer-heading.svelte @@ -0,0 +1,32 @@ + + +
    + {#if room} + + {/if} + + + {room ? room.name : wholePlaceLabel(type)} + + {#if room?.note} + {room.note} + {/if} + +
    diff --git a/web/src/lib/components/offer.svelte b/web/src/lib/components/offer.svelte new file mode 100644 index 0000000..1f512fe --- /dev/null +++ b/web/src/lib/components/offer.svelte @@ -0,0 +1,23 @@ + + + + + {label} + diff --git a/web/src/lib/components/past-slot-row.svelte b/web/src/lib/components/past-slot-row.svelte new file mode 100644 index 0000000..99e2bdf --- /dev/null +++ b/web/src/lib/components/past-slot-row.svelte @@ -0,0 +1,54 @@ + + + +
    + + {formatDateRange(window.start, window.end)} + + + {#if hasRooms(listing)} + + {/if} + + {window.status === "BOOKED" ? "Someone stayed" : "Nobody booked these"} + + + {#if asked > 0} + + {asked === 1 ? "1 asked" : `${asked} asked`} + + {/if} + +
    + +
    diff --git a/web/src/lib/components/person-page.svelte b/web/src/lib/components/person-page.svelte new file mode 100644 index 0000000..07df2a8 --- /dev/null +++ b/web/src/lib/components/person-page.svelte @@ -0,0 +1,351 @@ + + + + +
    +
    + {#if isSelf} + + {:else} + + {/if} +
    + {#if isSelf} + + {:else} +

    + {name} +

    + {/if} + {#if username} +

    @{username}

    + {/if} + {#if email} + + + {email} + + {/if} +
    +
    + + {#if isSelf} + + +
    +

    + A link anyone can open to see your places and ask to stay. Every ask + needs your approval, and saying yes doesn't make you friends. +

    + { + await kip.publishUserPortal(); + }} + onrevoke={() => kip.revokeUserPortal()} + /> +
    + {:else} + {#if incoming} + + {/if} + + {#if outgoing} + {@const sent = outgoing} +
    +

    + Friend request sent — waiting for {firstName} to answer. +

    + +
    + {/if} + + + {#if !friend && !incoming && !outgoing && sharedStay} + + {/if} + {/if} + + {#if upcomingStays.length > 0} +
    + + {#each upcomingStays as stay (stay.id)} + + {/each} + +
    + {/if} + + + {#if elsewhere.length > 0} +
    + + {#each elsewhere.slice(0, ELSEWHERE_PREVIEW) as stay (stay.id)} + + {/each} + +
    + {/if} + + {#if pastStays.length > 0} +
    + + {#each pastStays as stay (stay.id)} + + {/each} + +
    + {/if} + +
    + {#if !isSelf && !friend} + +

    + Only friends can see the places {firstName} shares. +

    + {:else if places.length === 0} +

    + {isSelf + ? "You're not sharing any places yet." + : `${firstName} isn't sharing any places right now.`} +

    + {:else} +
    + {#each places as place (place.id)} +
    + +
    + {/each} +
    + {/if} +
    + + {#if !isSelf && friend} +
    + +
    + {/if} +
    diff --git a/web/src/lib/components/photo-gallery.svelte b/web/src/lib/components/photo-gallery.svelte new file mode 100644 index 0000000..403ff65 --- /dev/null +++ b/web/src/lib/components/photo-gallery.svelte @@ -0,0 +1,122 @@ + + +{#if shown.length > 0} +
    +
    + +
    { + const node = event.currentTarget; + const at = Math.round(node.scrollLeft / node.clientWidth); + index = Math.max(0, Math.min(shown.length - 1, at)); + }} + class="flex h-full w-full snap-x snap-mandatory overflow-x-auto rounded-2xl" + > + {#each shown as photo (photo.id)} + + {/each} +
    + {#if many} + show(index - 1)} + class="absolute left-2 top-1/2 -translate-y-1/2" + > + + + show(index + 1)} + class="absolute right-2 top-1/2 -translate-y-1/2" + > + + + + {index + 1} + / {shown.length} + + {/if} +
    + + {#if thumbnails && many} + +
    + {#each shown as photo, position (photo.id)} + + {/each} +
    + {/if} +
    +{/if} diff --git a/web/src/lib/components/photo-strip.svelte b/web/src/lib/components/photo-strip.svelte new file mode 100644 index 0000000..01c3c28 --- /dev/null +++ b/web/src/lib/components/photo-strip.svelte @@ -0,0 +1,379 @@ + + + + +{#if editable || photos.length > 0} +
    + + + +
    { + // Only files: a photo being dragged within the strip, or text, or a + // link, must not arm the target for an upload. + if (accepting && event.dataTransfer?.types.includes("Files")) { + dropping = true; + } + }} + ondragover={(event) => { + // Without cancelling the drag-over the browser never fires a drop here. + // A photo drag is the thumbnails' business, so it is left alone: one + // released between them carries no files and lands as a no-op rather + // than being taken for an upload. + if (!accepting || isPhotoDrag(event)) return; + event.preventDefault(); + if (event.dataTransfer) event.dataTransfer.dropEffect = "copy"; + }} + ondragleave={(event) => { + // Crossing a child fires dragleave for the one being left, so ignore + // any whose destination is still inside the strip (null = left the + // window). + const next = event.relatedTarget; + if (!(next instanceof Node) || !event.currentTarget.contains(next)) { + dropping = false; + overId = null; + } + }} + {ondrop} + class="flex gap-2 overflow-x-auto rounded-2xl border px-1 py-1 transition {dropping + ? "border-accent bg-accent-soft" + : "border-transparent"}" + > + {#each order as { id: photoId, url }, index (photoId)} + + +
    { + // The id rides in a type of our own so a drop anywhere else reads + // as nothing, and so the file-drop handlers can ignore it. + event.dataTransfer?.setData(PHOTO_DRAG_TYPE, photoId); + if (event.dataTransfer) event.dataTransfer.effectAllowed = "move"; + draggingId = photoId; + }} + ondragend={() => { + draggingId = null; + overId = null; + }} + ondragover={(event) => { + if (!sortable || busy || !isPhotoDrag(event)) return; + event.preventDefault(); + if (event.dataTransfer) event.dataTransfer.dropEffect = "move"; + overId = photoId; + }} + ondrop={(event) => { + if (!isPhotoDrag(event)) return; + event.preventDefault(); + // The strip's own drop handler adds files; this one is already + // dealt with, so don't let it get there. + event.stopPropagation(); + overId = null; + draggingId = null; + const dragged = event.dataTransfer?.getData(PHOTO_DRAG_TYPE); + if (dragged && dragged !== photoId) { + reorder(reordered(order, dragged, index)).catch((caught) => + console.error("reorder", caught), + ); + } + }} + class="relative h-24 w-24 shrink-0 overflow-hidden rounded-2xl bg-surface-muted transition {draggingId === + photoId + ? "opacity-40" + : ""} {overId === photoId && draggingId !== photoId + ? "ring-2 ring-accent" + : ""}" + > + + + {#if editable && index === 0} + + Cover + + {/if} + {#if editable} + + {/if} + {#if sortable && index > 0} + + {/if} + {#if sortable && index < order.length - 1} + + {/if} +
    + {/each} + + {#if editable && !full} + + {/if} +
    + + {#if editable} + add(Array.from(event.currentTarget.files ?? []))} + > + {/if} + + {#if error} +

    {error}

    + {/if} + {#if editable && !full} +

    + Tap to choose photos, or drop them here. +

    + {/if} + {#if editable && full} +

    + That's the maximum of {MAX_PHOTOS} photos. +

    + {/if} + {#if sortable} +

    + Drag a photo, or use its arrows, to change the order. +

    + {/if} +
    +{/if} diff --git a/web/src/lib/components/place-card.svelte b/web/src/lib/components/place-card.svelte new file mode 100644 index 0000000..d589517 --- /dev/null +++ b/web/src/lib/components/place-card.svelte @@ -0,0 +1,121 @@ + + +
    + +
    + + +
    + {#if showHost && host && !isMine} + + {/if} + + {placeTypeLabel(listing.type, roomCount)} + +
    + +

    {listing.title}

    + +

    + + {listing.location.label} + {#if typeof distanceKm === "number"} + · {Math.round(distanceKm)} km + {/if} +

    + +
    + {#if open.length === 0} + No open dates right now + {:else} + + + {open.length} + open {open.length === 1 ? "window" : "windows"} + + + next {formatDateRange(open[0].start, open[0].end)}{roomCount > 0 + ? ` · ${offerLabel(listing, open[0].roomId)}` + : ""} + + + {/if} + {#if hasInstant} + + {#snippet icon()} + + {/snippet} + Instant + + {/if} +
    +
    +
    diff --git a/web/src/lib/components/places-view.svelte b/web/src/lib/components/places-view.svelte new file mode 100644 index 0000000..08aa548 --- /dev/null +++ b/web/src/lib/components/places-view.svelte @@ -0,0 +1,107 @@ + + +{#if kip.user} +
    +
    + {#snippet action()} + {#if kip.myListings.length > 0 && !kip.anonymous} + + {/if} + {/snippet} + + {#if kip.anonymous} +
    +

    + Hosting starts with an email — your guests need a host kip can + reach. Add yours and you can list a place. +

    + +
    + {:else if kip.myListings.length === 0} +
    +

    + You haven't listed anything yet. Add a room you're not using or your + whole place while you're away. +

    + +
    + {:else} + + {#each kip.myListings as listing (listing.id)} + + {/each} + + {/if} +
    + + {#if pendingAsks.length > 0} +
    + + {#each pendingAsks as booking (booking.id)} + + {/each} + +
    + {/if} + + {#if upcomingGuests.length > 0} +
    + + {#each upcomingGuests as booking (booking.id)} + + {/each} + +
    + {/if} +
    +{/if} diff --git a/web/src/lib/components/profile-photo.svelte b/web/src/lib/components/profile-photo.svelte new file mode 100644 index 0000000..1fbfa01 --- /dev/null +++ b/web/src/lib/components/profile-photo.svelte @@ -0,0 +1,100 @@ + + + + +
    +
    + + {#if photoURL && photoURL !== kip.providerPhotoURL} + + {/if} + + { + // Cancelling the picker fires nothing, so null would mean "remove". + const chosen = event.currentTarget.files?.[0]; + if (chosen) void change(chosen); + }} + > +
    + {#if error} + {error} + {/if} +
    diff --git a/web/components/pwa.tsx b/web/src/lib/components/pwa.svelte similarity index 55% rename from web/components/pwa.tsx rename to web/src/lib/components/pwa.svelte index c54677e..ce9924c 100644 --- a/web/components/pwa.tsx +++ b/web/src/lib/components/pwa.svelte @@ -1,26 +1,25 @@ -"use client"; + diff --git a/web/src/lib/components/rail-fade.svelte.ts b/web/src/lib/components/rail-fade.svelte.ts new file mode 100644 index 0000000..80d0df9 --- /dev/null +++ b/web/src/lib/components/rail-fade.svelte.ts @@ -0,0 +1,52 @@ +// How far a masked rail edge fades. +const FADE = "1.5rem"; + +/** + * Fades whichever edge of a horizontally scrolling rail has more beyond it. + * + * Masked rather than overlaid with a gradient, because the same rail sits on + * the canvas in one place and on a card in another, and an overlay would need + * to know. Create one while a component initializes, bind `node` to the rail + * and set `maskImage` as its `mask-image`. `count` re-measures when an item is + * added or removed, which the observer can't see, since the rail's own box + * doesn't change size when its contents do. + */ +export class RailFade { + /** The scrolling element; bind it with `bind:this`. */ + node = $state(); + #before = $state(false); + #after = $state(false); + + constructor(count: () => number) { + $effect(() => { + count(); + const { node } = this; + if (!node) return; + const measure = (): void => { + this.#before = node.scrollLeft > 1; + this.#after = node.scrollWidth - node.clientWidth - node.scrollLeft > 1; + }; + measure(); + // What fits changes on rotate and any layout shift, not only on add. + const observer = new ResizeObserver(measure); + observer.observe(node); + node.addEventListener("scroll", measure); + return () => { + observer.disconnect(); + node.removeEventListener("scroll", measure); + }; + }); + } + + /** The mask to apply, or undefined while neither edge has more beyond it. */ + get maskImage(): string | undefined { + if (!this.#before && !this.#after) return undefined; + const stops = [ + this.#before ? "transparent 0" : "black 0", + this.#before ? `black ${FADE}` : null, + this.#after ? `black calc(100% - ${FADE})` : null, + this.#after ? "transparent 100%" : "black 100%", + ].filter((stop): stop is string => stop !== null); + return `linear-gradient(to right, ${stops.join(", ")})`; + } +} diff --git a/web/src/lib/components/reach-field.svelte b/web/src/lib/components/reach-field.svelte new file mode 100644 index 0000000..9dfa1c4 --- /dev/null +++ b/web/src/lib/components/reach-field.svelte @@ -0,0 +1,104 @@ + + + +{#if reach.pending} + +

    + We texted a code to {reach.sentTo}. Standard message rates apply. +

    + + onchange({ ...reach, code })} + /> + + +{:else} + {#snippet swap()} + + {/snippet} + + onchange({ ...reach, raw: event.currentTarget.value })} + placeholder={phoneMode ? "(415) 555-0123" : "you@example.com"} + aria-label={phoneMode ? "Phone number" : "Email"} + wideSuffix + suffix={reach.raw || only ? undefined : swap} + /> + + +
    +{/if} diff --git a/web/src/lib/components/request-card.svelte b/web/src/lib/components/request-card.svelte new file mode 100644 index 0000000..9f225df --- /dev/null +++ b/web/src/lib/components/request-card.svelte @@ -0,0 +1,249 @@ + + + + + +{#snippet handle()} + {#if request.fromUsername} + @{request.fromUsername} + {/if} +{/snippet} + +{#snippet identity()} + +
    + + {request.fromName || "Someone"}{@render handle()} + + asked to be friends +
    +{/snippet} + +
    +
    +
    + {#if onTheirPage} + {@render identity()} + {:else} + + {/if} + + {#if viaLink} +
    + via your link + {#if link} + {@const target = link} + + {/if} +
    + {/if} +
    + + +
    +

    + Friends can see every place you share, whenever it's free. +

    +
    + + +
    +
    +
    +
    diff --git a/web/src/lib/components/room-detail.svelte b/web/src/lib/components/room-detail.svelte new file mode 100644 index 0000000..078055e --- /dev/null +++ b/web/src/lib/components/room-detail.svelte @@ -0,0 +1,42 @@ + + +
    + +

    {listing.title}

    +
    + + {placeTypeLabel(listing.type, roomList(listing).length)} + + + + {listing.location.label} + +
    + {#if listing.description} +

    + {listing.description} +

    + {/if} +
    diff --git a/web/src/lib/components/room-fields.svelte b/web/src/lib/components/room-fields.svelte new file mode 100644 index 0000000..3d75759 --- /dev/null +++ b/web/src/lib/components/room-fields.svelte @@ -0,0 +1,98 @@ + + +
    + + +
    + Photos + { + uploading = now; + }} + /> +
    + + + {@render children?.()} +
    diff --git a/web/src/lib/components/room-friend-view.svelte b/web/src/lib/components/room-friend-view.svelte new file mode 100644 index 0000000..4272a58 --- /dev/null +++ b/web/src/lib/components/room-friend-view.svelte @@ -0,0 +1,115 @@ + + +
    +
    + {#if host} + + {/if} + +
    + + +
    diff --git a/web/src/lib/components/room-owner-view.svelte b/web/src/lib/components/room-owner-view.svelte new file mode 100644 index 0000000..3407549 --- /dev/null +++ b/web/src/lib/components/room-owner-view.svelte @@ -0,0 +1,347 @@ + + +
    +
    +

    + Manage place +

    + + {#if placeCheckout} +
    +

    Check-out instructions

    +

    + {placeCheckout} +

    +
    + {/if} +
    + +
    + + +
    +
    +

    + The first one leads the page — put another first to change that. +

    + kip.setListingPhotos(listing.id, photos)} + /> +
    + +
    + { + await kip.publishListingPortal(listing); + }} + onrevoke={() => kip.revokeListingPortal(listing)} + /> +
    + + {#if activeBookings.length > 0} +
    + + {#each activeBookings as booking (booking.id)} + + {/each} + +
    + {/if} + +
    + + +
    +
    +
    + + {#if editingWindow} + {#key editingWindow.id} + + {/key} + {/if} + {#if addingSlot} + { + addingSlot = false; + }} + /> + {/if} + {#if openRoom} + + {#key openRoom.roomId ?? "new"} + { + openRoom = null; + }} + /> + {/key} + {/if} +
    diff --git a/web/src/lib/components/room-page.svelte b/web/src/lib/components/room-page.svelte new file mode 100644 index 0000000..9efa980 --- /dev/null +++ b/web/src/lib/components/room-page.svelte @@ -0,0 +1,63 @@ + + +{#if !room} + {#if looked} +

    This place isn't available right now.

    + {:else} +

    Loading…

    + {/if} +{:else if room.ownerId === kip.user?.uid} + +{:else} + +{/if} diff --git a/web/src/lib/components/room-row.svelte b/web/src/lib/components/room-row.svelte new file mode 100644 index 0000000..d3dad58 --- /dev/null +++ b/web/src/lib/components/room-row.svelte @@ -0,0 +1,30 @@ + + + + + + {name} + {#if note} + {note} + {/if} + + + diff --git a/web/src/lib/components/room-sheet.svelte b/web/src/lib/components/room-sheet.svelte new file mode 100644 index 0000000..cbfa7eb --- /dev/null +++ b/web/src/lib/components/room-sheet.svelte @@ -0,0 +1,208 @@ + + + + +{#if gone} + +{:else if room && addingDates} + { + addingDates = false; + }} + /> +{:else} + + { + checkoutDraft = next; + }} + photos={room ? room.photos : draftPhotos} + onphotos={async (photos) => { + if (roomId) { + await kip.setRoomPhotos(listing.id, roomId, photos); + } else { + draftPhotos = photos; + } + }} + saveLabel={room ? "Save changes" : "Add room"} + dirty={detailsChanged || checkoutChanged} + onsave={save} + > + {#if room} + {@const shared = room} +
    + Share this room + +
    + { + await kip.publishRoomPortal(listing, shared.id); + }} + onrevoke={() => kip.revokeRoomPortal(listing, shared.id)} + /> +
    +
    +
    + + +
    + {/if} +
    +
    +{/if} diff --git a/web/src/lib/components/room-thumb.svelte b/web/src/lib/components/room-thumb.svelte new file mode 100644 index 0000000..c300439 --- /dev/null +++ b/web/src/lib/components/room-thumb.svelte @@ -0,0 +1,18 @@ + + + + {#snippet fallback()} + + + + {/snippet} + diff --git a/web/components/rooms.tsx b/web/src/lib/components/rooms.ts similarity index 50% rename from web/components/rooms.tsx rename to web/src/lib/components/rooms.ts index 85c6363..9b5161c 100644 --- a/web/components/rooms.tsx +++ b/web/src/lib/components/rooms.ts @@ -1,19 +1,6 @@ -"use client"; - -import type { ReactElement } from "react"; -import { LuBed, LuChevronRight } from "react-icons/lu"; -import { formatDateRange } from "../utils/format"; -import { listingTypeIcon } from "../utils/listings"; -import { roomList, roomOf, wholePlaceLabel } from "../utils/rooms"; -import type { - AvailabilityWindow, - Listing, - ListingPhoto, - ListingType, - PortalRoom, -} from "../utils/types"; -import CoverPhoto from "./cover-photo"; -import { Row } from "./ui/list"; +import { formatDateRange } from "../format"; +import { roomList, roomOf, wholePlaceLabel } from "../rooms"; +import type { AvailabilityWindow, Listing, PortalRoom } from "../types"; /** Whether a place is split into rooms at all. */ export function hasRooms(listing: Pick): boolean { @@ -93,97 +80,3 @@ export function clashNote( return `Overlaps your ${range} dates.`; } } - -/** What a set of dates offers, as an icon and a name. */ -export function Offer({ - type, - room, - label, -}: { - type: ListingType; - room: boolean; - label: string; -}): ReactElement { - const Icon = room ? LuBed : listingTypeIcon(type); - return ( - - - {label} - - ); -} - -/** A room's cover, or a bed in a disc, so rows share one left edge either way. */ -export function RoomThumb({ - photos, -}: { - photos: readonly ListingPhoto[]; -}): ReactElement { - return ( - - - - } - /> - ); -} - -/** One room in a host's list; opens that room. */ -export function RoomRow({ - name, - note, - photos, - onOpen, -}: { - name: string; - note: string; - photos: readonly ListingPhoto[]; - onOpen: () => void; -}): ReactElement { - return ( - - - - - {name} - - {note ? ( - {note} - ) : null} - - - - ); -} - -/** The heading over one group of dates: a room with its cover and note, or the whole place. */ -export function OfferHeading({ - type, - room, -}: { - type: ListingType; - room: PortalRoom | null; -}): ReactElement { - return ( -
    - {room ? ( - - ) : null} - - - {room ? room.name : wholePlaceLabel(type)} - - {room?.note ? ( - {room.note} - ) : null} - -
    - ); -} diff --git a/web/src/lib/components/saved-search-row.svelte b/web/src/lib/components/saved-search-row.svelte new file mode 100644 index 0000000..ed5909e --- /dev/null +++ b/web/src/lib/components/saved-search-row.svelte @@ -0,0 +1,43 @@ + + + + + + + + + diff --git a/web/src/lib/components/saved-searches.svelte b/web/src/lib/components/saved-searches.svelte new file mode 100644 index 0000000..3610adb --- /dev/null +++ b/web/src/lib/components/saved-searches.svelte @@ -0,0 +1,173 @@ + + +
    + + {#snippet action()} + {#if already} + + Saved as "{already.label}" + + {:else if !naming && !full} + + {/if} + {/snippet} + + {#if naming} +
    + +
    + + +
    +

    + Named "{label.trim() || suggestion}" unless you change it. +

    +
    + {/if} + + {#if error} +

    {error}

    + {/if} + {#if full && !naming} +

    + That's the maximum of {MAX_SAVED_SEARCHES}. Remove one to save another. +

    + {/if} + + {#if hits.length > 0} + + {#each hits as hit (hit.search.id)} + apply(hit.search)} + onremove={() => remove(hit.search)} + /> + {/each} + + {:else if !naming} +

    + Nothing saved yet. Set some filters, then keep them here. +

    + {/if} +
    diff --git a/web/src/lib/components/self-identity.svelte b/web/src/lib/components/self-identity.svelte new file mode 100644 index 0000000..aa73199 --- /dev/null +++ b/web/src/lib/components/self-identity.svelte @@ -0,0 +1,165 @@ + + +{#snippet verdict()} + {#if checking} + + {:else if available === true} + + {:else if available === false} + + {/if} +{/snippet} + +{#if kip.profile} +
    + + + + {#if claiming && !handle} +
    + + {#if formatError} + {formatError} + {:else if available === false} + @{normalized} is taken. + {:else if available === true} + @{normalized} is available. + {:else} + + Letters, numbers and _, starting with a letter. Permanent once + claimed. + + {/if} +
    + + +
    +
    + {/if} +
    + + {#if error} + {error} + {/if} +
    +{/if} diff --git a/web/src/lib/components/settings-view.svelte b/web/src/lib/components/settings-view.svelte new file mode 100644 index 0000000..1e5b0fc --- /dev/null +++ b/web/src/lib/components/settings-view.svelte @@ -0,0 +1,35 @@ + + +
    + + + + + + +
    + + +
    +
    diff --git a/web/src/lib/components/share-link.svelte b/web/src/lib/components/share-link.svelte new file mode 100644 index 0000000..2f40d44 --- /dev/null +++ b/web/src/lib/components/share-link.svelte @@ -0,0 +1,142 @@ + + + + +{#if !portalId} + +{:else} +
    + + + {url.slice(0, -TOKEN_TAIL)} + {url.slice(-TOKEN_TAIL)} + + + {#if copied} + + {:else} + + {/if} + + + + + + + +
    +{/if} diff --git a/web/src/lib/components/site-footer.svelte b/web/src/lib/components/site-footer.svelte new file mode 100644 index 0000000..17bea66 --- /dev/null +++ b/web/src/lib/components/site-footer.svelte @@ -0,0 +1,40 @@ + + + + +
    + {#each PAGES as { href, label }, index (href)} + {#if index > 0} + + {/if} + {#if href === current} + {label} + {:else} + {label} + {/if} + {/each} + + Source +
    diff --git a/web/src/lib/components/slot-row.svelte b/web/src/lib/components/slot-row.svelte new file mode 100644 index 0000000..0565f6d --- /dev/null +++ b/web/src/lib/components/slot-row.svelte @@ -0,0 +1,147 @@ + + +{#snippet summary()} + + {#if thumbnail} + + {/if} +
    + + {formatDateRange(window.start, window.end)} + + + {nights(window.start, window.end)} + nights{window.details ? ` · ${window.details}` : ""} + +
    +{/snippet} + +{#snippet taken()} + {@render summary()} + Booked +{/snippet} + +{#if myBooking && (iHoldWindow || pendingRequest)} + + {@const mine = myBooking} + kip.navigate({ kind: "booking", id: mine.id })}> + {@render summary()} + {#if iHoldWindow} + Booked by you + {:else} + Pending + {/if} + + +{:else if window.status === "BOOKED"} + + {#if stay} + {@const held = stay} + kip.navigate({ kind: "booking", id: held.id })} + class="opacity-60" + > + {@render taken()} + + + {:else} +
    + {@render taken()} +
    + {/if} +{:else} + +
    +
    + {@render summary()} + {#if showInstant} + + {#snippet icon()} + + {/snippet} + Instant + + {/if} + +
    + {#if note} +

    {note}

    + {/if} +
    +{/if} diff --git a/web/src/lib/components/slot-sheet.svelte b/web/src/lib/components/slot-sheet.svelte new file mode 100644 index 0000000..3a8db3e --- /dev/null +++ b/web/src/lib/components/slot-sheet.svelte @@ -0,0 +1,312 @@ + + + +{#snippet asks()} + {#if pending.length > 0} +
    + + {pending.length === 1 + ? "1 person asked" + : `${pending.length} people asked`} + + + {#each pending as booking (booking.id)} + + {/each} + + + {#if booked || expired} + +

    + {expired + ? "These dates have passed, so these can only be declined." + : "These dates went to someone else, so these can only be declined."} +

    + {/if} +
    + {/if} +{/snippet} + +{#snippet link()} + { + await kip.publishSlotPortal(listing.id, window); + }} + onrevoke={() => kip.revokeSlotPortal(listing.id, window)} + /> +{/snippet} + + +
    + + + + {#if hasRooms(listing)} +
    + What's free + +
    + {/if} + + {#if expired} +

    + These dates have passed — + {booked ? "someone stayed" : "nobody booked them"}. There's nothing left + to change about the dates; new availability means new dates. You can + still clear these off your calendar. +

    + {#if window.details} +

    {window.details}

    + {/if} + {@render asks()} + + {#if window.publicPortalId} +
    + + Link to these dates + + {@render link()} +
    + {/if} + {:else if booked} + + {#if guestBooking} + + + + {/if} + +

    + {guestBooking + ? "Cancelling frees these dates and notifies your guest." + : "Cancelling frees these dates and notifies whoever booked them."} +

    + {@render asks()} + {:else} + {@render asks()} +
    + + +
    + + {#if clash} + + {clashNote(listing, window.roomId, clash)} + + {:else if gone} + Those dates have already passed. + {/if} + + +
    + + runAction(() => + kip.setWindowAutoAccept(listing.id, window.id, next), + )} + label="Instant book" + description="Friends book these dates instantly (first come, first served)." + /> +
    + +
    + Share these dates + {@render link()} +
    + {/if} + + +
    +
    diff --git a/web/src/lib/components/slot-summary-row.svelte b/web/src/lib/components/slot-summary-row.svelte new file mode 100644 index 0000000..7e1d691 --- /dev/null +++ b/web/src/lib/components/slot-summary-row.svelte @@ -0,0 +1,76 @@ + + + +
    + + {formatDateRange(window.start, window.end)} + + {` · ${nights(window.start, window.end)} nights`} + + + {#if chips} + + {#if offered} + + {/if} + {#if window.status === "BOOKED"} + Booked + {:else if window.autoAccept} + + {#snippet icon()} + + {/snippet} + Instant + + {/if} + + {#if asked > 0} + + {asked === 1 ? "1 asked" : `${asked} asked`} + + {/if} + {#if window.details} + {window.details} + {/if} + + {:else if window.details} + {window.details} + {/if} +
    + +
    diff --git a/web/src/lib/components/theme-button.svelte b/web/src/lib/components/theme-button.svelte new file mode 100644 index 0000000..5530343 --- /dev/null +++ b/web/src/lib/components/theme-button.svelte @@ -0,0 +1,25 @@ + + + theme.set(nextThemeChoice(theme.choice))} +> + {#if theme.choice === "dark"} + + {:else if theme.choice === "light"} + + {:else} + + {/if} + diff --git a/web/src/lib/components/theme-color.svelte b/web/src/lib/components/theme-color.svelte new file mode 100644 index 0000000..327274d --- /dev/null +++ b/web/src/lib/components/theme-color.svelte @@ -0,0 +1,20 @@ + diff --git a/web/src/lib/components/top-bar.svelte b/web/src/lib/components/top-bar.svelte new file mode 100644 index 0000000..dbc0645 --- /dev/null +++ b/web/src/lib/components/top-bar.svelte @@ -0,0 +1,47 @@ + + + diff --git a/web/src/lib/components/trips-view.svelte b/web/src/lib/components/trips-view.svelte new file mode 100644 index 0000000..3820daa --- /dev/null +++ b/web/src/lib/components/trips-view.svelte @@ -0,0 +1,87 @@ + + +{#if kip.user} +
    +
    + {#if upcoming.length === 0} +

    + No upcoming trips. Find a place in Browse and request some dates. +

    + {:else} + + {#each upcoming as trip (trip.id)} + + {/each} + + {/if} +
    + + {#if past.length > 0} +
    + + {#each past as trip (trip.id)} + + {/each} + +
    + {/if} + + {#if cancelled.length > 0} +
    + {#snippet action()} + + {/snippet} + + {#each cancelled as trip (trip.id)} + + {/each} + +
    + {/if} +
    +{/if} diff --git a/web/src/lib/components/ui/busy.svelte b/web/src/lib/components/ui/busy.svelte new file mode 100644 index 0000000..f7f1337 --- /dev/null +++ b/web/src/lib/components/ui/busy.svelte @@ -0,0 +1,10 @@ + + +