@eifi1/ui-kit 0.51.4 → 0.51.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/account/account-settings-labels.js.map +1 -1
- package/dist/account/account-settings.js +11 -9
- package/dist/account/account-settings.js.map +1 -1
- package/dist/account/delete-account-setting.js +56 -65
- package/dist/account/delete-account-setting.js.map +1 -1
- package/dist/account/email-change-setting-labels.js.map +1 -1
- package/dist/account/email-change-setting.js.map +1 -1
- package/dist/account/passkeys-setting.js.map +1 -1
- package/dist/account/sessions-setting.js.map +1 -1
- package/dist/admin/admin-action-confirm.js +1 -3
- package/dist/admin/admin-action-confirm.js.map +1 -1
- package/dist/admin/admin-action-log.js.map +1 -1
- package/dist/admin/admin-area-labels.js +4 -1
- package/dist/admin/admin-area-labels.js.map +1 -1
- package/dist/admin/admin-overview.js +19 -11
- package/dist/admin/admin-overview.js.map +1 -1
- package/dist/admin/admin-sections.js.map +1 -1
- package/dist/admin/admin-tool-links.d.ts +1 -1
- package/dist/admin/admin-tool-links.js +7 -1
- package/dist/admin/admin-tool-links.js.map +1 -1
- package/dist/admin/invitations-panel.js +2 -13
- package/dist/admin/invitations-panel.js.map +1 -1
- package/dist/admin/mail-test-card.js.map +1 -1
- package/dist/admin/reviewer-scope-editor.js +4 -11
- package/dist/admin/reviewer-scope-editor.js.map +1 -1
- package/dist/admin/role-select.js.map +1 -1
- package/dist/admin/user-roster.js +1 -9
- package/dist/admin/user-roster.js.map +1 -1
- package/dist/auth/accept-invitation.js.map +1 -1
- package/dist/auth/auth-errors.js.map +1 -1
- package/dist/auth/forgot-password-form.js +23 -31
- package/dist/auth/forgot-password-form.js.map +1 -1
- package/dist/auth/register-form.js.map +1 -1
- package/dist/auth/reset-password-form.js.map +1 -1
- package/dist/auth/sign-in-form.js +13 -5
- package/dist/auth/sign-in-form.js.map +1 -1
- package/dist/auth/verify-email.js +1 -8
- package/dist/auth/verify-email.js.map +1 -1
- package/dist/billing/billing-banner.js.map +1 -1
- package/dist/billing/billing-labels.js.map +1 -1
- package/dist/billing/checkout-processing.js.map +1 -1
- package/dist/billing/plan-card.js +91 -85
- package/dist/billing/plan-card.js.map +1 -1
- package/dist/billing/plan-limit-toast.js.map +1 -1
- package/dist/billing/plan-limit.js.map +1 -1
- package/dist/billing/plan-price.js.map +1 -1
- package/dist/billing/plan-switch.js.map +1 -1
- package/dist/billing/subscription-actions.js.map +1 -1
- package/dist/bin/eifi1-pay-page.mjs +6 -1
- package/dist/components/account-chips.js.map +1 -1
- package/dist/components/alert-banner.js +1 -5
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/amount-input.js +29 -12
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/authed-image.js.map +1 -1
- package/dist/components/autocomplete.js.map +1 -1
- package/dist/components/bulk-action-bar.js +1 -4
- package/dist/components/bulk-action-bar.js.map +1 -1
- package/dist/components/button-group.js.map +1 -1
- package/dist/components/button-look.js.map +1 -1
- package/dist/components/button.js +1 -7
- package/dist/components/button.js.map +1 -1
- package/dist/components/calculator.js +5 -5
- package/dist/components/calculator.js.map +1 -1
- package/dist/components/calendar-heatmap.js +4 -10
- package/dist/components/calendar-heatmap.js.map +1 -1
- package/dist/components/card.js +1 -8
- package/dist/components/card.js.map +1 -1
- package/dist/components/chart-zoom-core.js +3 -17
- package/dist/components/chart-zoom-core.js.map +1 -1
- package/dist/components/chart.d.ts +1 -1
- package/dist/components/chart.js +4 -22
- package/dist/components/chart.js.map +1 -1
- package/dist/components/checkbox-group.js +1 -4
- package/dist/components/checkbox-group.js.map +1 -1
- package/dist/components/chip-input.js +4 -1
- package/dist/components/chip-input.js.map +1 -1
- package/dist/components/chip.js +10 -5
- package/dist/components/chip.js.map +1 -1
- package/dist/components/choice-card.js +58 -74
- package/dist/components/choice-card.js.map +1 -1
- package/dist/components/column-mapper-labels.js.map +1 -1
- package/dist/components/column-mapper.js +167 -227
- package/dist/components/column-mapper.js.map +1 -1
- package/dist/components/combobox-core.js +1 -4
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/combobox.js +1 -4
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/confirm-dialog.js.map +1 -1
- package/dist/components/copy-button.js.map +1 -1
- package/dist/components/country-select.d.ts +5 -4
- package/dist/components/country-select.js +3 -19
- package/dist/components/country-select.js.map +1 -1
- package/dist/components/currency-option-list.js +10 -8
- package/dist/components/currency-option-list.js.map +1 -1
- package/dist/components/currency-select.js +1 -6
- package/dist/components/currency-select.js.map +1 -1
- package/dist/components/danger-confirm.js.map +1 -1
- package/dist/components/data-table-cells.js.map +1 -1
- package/dist/components/data-table-column-settings.js.map +1 -1
- package/dist/components/data-table-filter-popover.js +2 -18
- package/dist/components/data-table-filter-popover.js.map +1 -1
- package/dist/components/data-table-filters.js.map +1 -1
- package/dist/components/data-table-fit.js.map +1 -1
- package/dist/components/data-table-groups.js +3 -1
- package/dist/components/data-table-groups.js.map +1 -1
- package/dist/components/data-table-head.js +44 -56
- package/dist/components/data-table-head.js.map +1 -1
- package/dist/components/data-table-mobile.js +31 -44
- package/dist/components/data-table-mobile.js.map +1 -1
- package/dist/components/data-table-pagination.js.map +1 -1
- package/dist/components/data-table-row-actions.js +19 -16
- package/dist/components/data-table-row-actions.js.map +1 -1
- package/dist/components/data-table-row.js.map +1 -1
- package/dist/components/data-table-shared.js.map +1 -1
- package/dist/components/data-table-sort.js.map +1 -1
- package/dist/components/data-table-virtual.js +4 -1
- package/dist/components/data-table-virtual.js.map +1 -1
- package/dist/components/data-table.js +30 -39
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.js +1 -6
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/date-range-picker.js.map +1 -1
- package/dist/components/description-list.js +10 -10
- package/dist/components/description-list.js.map +1 -1
- package/dist/components/dialog-frame.js +9 -2
- package/dist/components/dialog-frame.js.map +1 -1
- package/dist/components/disclosure.js +17 -27
- package/dist/components/disclosure.js.map +1 -1
- package/dist/components/dropdown.js +25 -35
- package/dist/components/dropdown.js.map +1 -1
- package/dist/components/empty-state.js.map +1 -1
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/error-boundary.js.map +1 -1
- package/dist/components/field-base.js +5 -5
- package/dist/components/field-base.js.map +1 -1
- package/dist/components/field-parts.d.ts +1 -1
- package/dist/components/field-parts.js +10 -14
- package/dist/components/field-parts.js.map +1 -1
- package/dist/components/field-sync.js +8 -10
- package/dist/components/field-sync.js.map +1 -1
- package/dist/components/file-button.js.map +1 -1
- package/dist/components/file-dropzone.js.map +1 -1
- package/dist/components/floating-panel.js +4 -1
- package/dist/components/floating-panel.js.map +1 -1
- package/dist/components/focus-ring.js.map +1 -1
- package/dist/components/form-actions.js.map +1 -1
- package/dist/components/grouped-picker.js +6 -8
- package/dist/components/grouped-picker.js.map +1 -1
- package/dist/components/hover-menu.js +1 -4
- package/dist/components/hover-menu.js.map +1 -1
- package/dist/components/iban-input-labels.js.map +1 -1
- package/dist/components/icon-button.js +4 -1
- package/dist/components/icon-button.js.map +1 -1
- package/dist/components/icon-picker.js +2 -7
- package/dist/components/icon-picker.js.map +1 -1
- package/dist/components/image-grid.js +41 -50
- package/dist/components/image-grid.js.map +1 -1
- package/dist/components/inline-edit-field.js.map +1 -1
- package/dist/components/inline-entity-combobox.js +2 -8
- package/dist/components/inline-entity-combobox.js.map +1 -1
- package/dist/components/input.js +14 -2
- package/dist/components/input.js.map +1 -1
- package/dist/components/jump-calendar.js +1 -7
- package/dist/components/jump-calendar.js.map +1 -1
- package/dist/components/legal-labels.js.map +1 -1
- package/dist/components/legal-page.js +14 -2
- package/dist/components/legal-page.js.map +1 -1
- package/dist/components/legal.js +12 -2
- package/dist/components/legal.js.map +1 -1
- package/dist/components/lightbox.js.map +1 -1
- package/dist/components/line-items.js +26 -36
- package/dist/components/line-items.js.map +1 -1
- package/dist/components/list.js +22 -10
- package/dist/components/list.js.map +1 -1
- package/dist/components/measured-grid.js +29 -53
- package/dist/components/measured-grid.js.map +1 -1
- package/dist/components/menu-item.js.map +1 -1
- package/dist/components/mini-calendar.js +13 -31
- package/dist/components/mini-calendar.js.map +1 -1
- package/dist/components/money-field.js.map +1 -1
- package/dist/components/month-picker.js +10 -21
- package/dist/components/month-picker.js.map +1 -1
- package/dist/components/multi-entity-combobox.js +3 -16
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/multi-select.js.map +1 -1
- package/dist/components/nav-pills.js +10 -1
- package/dist/components/nav-pills.js.map +1 -1
- package/dist/components/number-field.js.map +1 -1
- package/dist/components/number-input.js +1 -7
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.js +1 -4
- package/dist/components/numpad-sheet.js.map +1 -1
- package/dist/components/one-time-code-input.js +1 -7
- package/dist/components/one-time-code-input.js.map +1 -1
- package/dist/components/page-contents.js +13 -22
- package/dist/components/page-contents.js.map +1 -1
- package/dist/components/page-header.js +1 -11
- package/dist/components/page-header.js.map +1 -1
- package/dist/components/password-strength-labels.js.map +1 -1
- package/dist/components/password-strength.js +2 -8
- package/dist/components/password-strength.js.map +1 -1
- package/dist/components/phone-input.js +42 -57
- package/dist/components/phone-input.js.map +1 -1
- package/dist/components/picker-sheet.js.map +1 -1
- package/dist/components/pie-chart.js +1 -8
- package/dist/components/pie-chart.js.map +1 -1
- package/dist/components/popover.js.map +1 -1
- package/dist/components/progress-bar.js +16 -9
- package/dist/components/progress-bar.js.map +1 -1
- package/dist/components/qr-code.js +1 -8
- package/dist/components/qr-code.js.map +1 -1
- package/dist/components/row-actions.js +5 -1
- package/dist/components/row-actions.js.map +1 -1
- package/dist/components/search-field.js.map +1 -1
- package/dist/components/select.js +28 -38
- package/dist/components/select.js.map +1 -1
- package/dist/components/separator.js +1 -5
- package/dist/components/separator.js.map +1 -1
- package/dist/components/series-chart-axes.js +4 -1
- package/dist/components/series-chart-axes.js.map +1 -1
- package/dist/components/series-chart-budget.js.map +1 -1
- package/dist/components/series-chart-ticks.js.map +1 -1
- package/dist/components/series-chart.js +29 -45
- package/dist/components/series-chart.js.map +1 -1
- package/dist/components/settings-fields.js +21 -17
- package/dist/components/settings-fields.js.map +1 -1
- package/dist/components/share-card.js +4 -9
- package/dist/components/share-card.js.map +1 -1
- package/dist/components/sign-chip.js.map +1 -1
- package/dist/components/signature-pad.js +4 -13
- package/dist/components/signature-pad.js.map +1 -1
- package/dist/components/signed-amount.js +12 -5
- package/dist/components/signed-amount.js.map +1 -1
- package/dist/components/skeleton.js +2 -17
- package/dist/components/skeleton.js.map +1 -1
- package/dist/components/slider.js +13 -36
- package/dist/components/slider.js.map +1 -1
- package/dist/components/sparkline.js.map +1 -1
- package/dist/components/stat-tile.js +14 -10
- package/dist/components/stat-tile.js.map +1 -1
- package/dist/components/swatch-picker.js +14 -4
- package/dist/components/swatch-picker.js.map +1 -1
- package/dist/components/swipeable-row.d.ts +1 -1
- package/dist/components/swipeable-row.js +1 -8
- package/dist/components/swipeable-row.js.map +1 -1
- package/dist/components/table-skeleton.js +4 -1
- package/dist/components/table-skeleton.js.map +1 -1
- package/dist/components/table.js +12 -9
- package/dist/components/table.js.map +1 -1
- package/dist/components/tabs.js +5 -24
- package/dist/components/tabs.js.map +1 -1
- package/dist/components/text-link.js.map +1 -1
- package/dist/components/text.js.map +1 -1
- package/dist/components/tile-radio.js +1 -7
- package/dist/components/tile-radio.js.map +1 -1
- package/dist/components/time-input.js.map +1 -1
- package/dist/components/toast.js.map +1 -1
- package/dist/components/toggle-group.js +6 -1
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/toggle-legend.js +2 -11
- package/dist/components/toggle-legend.js.map +1 -1
- package/dist/components/tooltip.js +2 -22
- package/dist/components/tooltip.js.map +1 -1
- package/dist/components/translation-review-editor.js +1 -4
- package/dist/components/translation-review-editor.js.map +1 -1
- package/dist/components/translation-review-parts.d.ts +1 -1
- package/dist/components/translation-review-parts.js +7 -1
- package/dist/components/translation-review-parts.js.map +1 -1
- package/dist/components/translation-review.js +18 -7
- package/dist/components/translation-review.js.map +1 -1
- package/dist/components/tree-view.js +14 -10
- package/dist/components/tree-view.js.map +1 -1
- package/dist/components/treemap.js +1 -13
- package/dist/components/treemap.js.map +1 -1
- package/dist/components/trigger-aria.js.map +1 -1
- package/dist/components/use-table-state.js +14 -11
- package/dist/components/use-table-state.js.map +1 -1
- package/dist/components/user-avatar.js +3 -26
- package/dist/components/user-avatar.js.map +1 -1
- package/dist/demo/demo-start.js +8 -4
- package/dist/demo/demo-start.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/feedback/feedback-attachment-labels.js.map +1 -1
- package/dist/feedback/feedback-attachment.js +1 -8
- package/dist/feedback/feedback-attachment.js.map +1 -1
- package/dist/feedback/feedback-capture.d.ts +1 -1
- package/dist/feedback/feedback-capture.js +3 -1
- package/dist/feedback/feedback-capture.js.map +1 -1
- package/dist/feedback/feedback-comment-thread.js +27 -13
- package/dist/feedback/feedback-comment-thread.js.map +1 -1
- package/dist/feedback/feedback-context.js +1 -4
- package/dist/feedback/feedback-context.js.map +1 -1
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-inbox.js.map +1 -1
- package/dist/feedback/feedback-public-list.js +7 -1
- package/dist/feedback/feedback-public-list.js.map +1 -1
- package/dist/feedback/feedback-record.js +1 -5
- package/dist/feedback/feedback-record.js.map +1 -1
- package/dist/feedback/feedback-replies.js +25 -10
- package/dist/feedback/feedback-replies.js.map +1 -1
- package/dist/feedback/feedback-row-detail.js.map +1 -1
- package/dist/feedback/feedback-status-undo.js.map +1 -1
- package/dist/feedback/feedback-submit-dialog.js.map +1 -1
- package/dist/feedback/feedback-table.js +4 -1
- package/dist/feedback/feedback-table.js.map +1 -1
- package/dist/feedback/feedback-thread.js +2 -13
- package/dist/feedback/feedback-thread.js.map +1 -1
- package/dist/feedback/feedback-vocabulary.js.map +1 -1
- package/dist/feedback/feedback-vote-button.js.map +1 -1
- package/dist/hooks/use-anchored-rect.js.map +1 -1
- package/dist/hooks/use-document-title.js.map +1 -1
- package/dist/hooks/use-file-drop.js.map +1 -1
- package/dist/hooks/use-file-picker-labels.js.map +1 -1
- package/dist/hooks/use-file-picker.js.map +1 -1
- package/dist/hooks/use-focus-trap.js.map +1 -1
- package/dist/hooks/use-overlay-history.js +10 -5
- package/dist/hooks/use-overlay-history.js.map +1 -1
- package/dist/hooks/use-row-swipe.d.ts +1 -1
- package/dist/hooks/use-row-swipe.js +1 -6
- package/dist/hooks/use-row-swipe.js.map +1 -1
- package/dist/i18n/defaults.js +1 -4
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.js +4 -1
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.js +2 -8
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/languages.js.map +1 -1
- package/dist/i18n/locales/en.js +4 -1
- package/dist/i18n/locales/en.js.map +2 -2
- package/dist/i18n/locales/es.js +4 -1
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +4 -1
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +4 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +4 -1
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/plurals.js.map +1 -1
- package/dist/i18n/review.js +13 -7
- package/dist/i18n/review.js.map +1 -1
- package/dist/i18n/shared-labels.js.map +1 -1
- package/dist/index.d.ts +16 -16
- package/dist/index.js +40 -23
- package/dist/index.js.map +1 -1
- package/dist/landing/access.js +1 -4
- package/dist/landing/access.js.map +1 -1
- package/dist/landing/landing-sections.js +35 -38
- package/dist/landing/landing-sections.js.map +1 -1
- package/dist/landing/page-seo.js.map +1 -1
- package/dist/lib/calc.js.map +1 -1
- package/dist/lib/clipboard-minus.js.map +1 -1
- package/dist/lib/dates.js.map +1 -1
- package/dist/lib/decimal-marks.js.map +1 -1
- package/dist/lib/device-settings.js +2 -1
- package/dist/lib/device-settings.js.map +1 -1
- package/dist/lib/direction.js.map +1 -1
- package/dist/lib/iban.js.map +1 -1
- package/dist/lib/logger.js +1 -5
- package/dist/lib/logger.js.map +1 -1
- package/dist/lib/person-name.js.map +1 -1
- package/dist/lib/phone.js.map +1 -1
- package/dist/lib/qr-encode.js +344 -8
- package/dist/lib/qr-encode.js.map +1 -1
- package/dist/lib/session-refresh.js.map +1 -1
- package/dist/lib/table-text.js +15 -3
- package/dist/lib/table-text.js.map +1 -1
- package/dist/lib/text-match.d.ts +4 -0
- package/dist/lib/text-match.js.map +1 -1
- package/dist/lib/translation-review.js.map +1 -1
- package/dist/notifications/notification-inbox.js +63 -44
- package/dist/notifications/notification-inbox.js.map +1 -1
- package/dist/notifications/notification-parts.js.map +1 -1
- package/dist/notifications/notification-types-setting.js.map +1 -1
- package/dist/notifications/notifications-labels.js.map +1 -1
- package/dist/notifications/push-device-setting.js +6 -1
- package/dist/notifications/push-device-setting.js.map +1 -1
- package/dist/notifications/push-devices-setting.js.map +1 -1
- package/dist/notifications/push-worker.js.map +1 -1
- package/dist/notifications/quiet-hours-setting.js.map +1 -1
- package/dist/notifications/use-push-device.js +4 -1
- package/dist/notifications/use-push-device.js.map +1 -1
- package/dist/offline/pwa-update-prompt.d.ts +1 -1
- package/dist/offline/pwa-update-prompt.js +7 -1
- package/dist/offline/pwa-update-prompt.js.map +1 -1
- package/dist/offline/query-persistence.js.map +1 -1
- package/dist/offline.d.ts +1 -1
- package/dist/offline.js +6 -1
- package/dist/offline.js.map +1 -1
- package/dist/pay/pay.css +8 -1
- package/dist/rhf/fields.d.ts +1 -2
- package/dist/rhf/fields.js +1 -10
- package/dist/rhf/fields.js.map +1 -1
- package/dist/rhf/form-core.js +1 -4
- package/dist/rhf/form-core.js.map +1 -1
- package/dist/rhf/line-items.js.map +1 -1
- package/dist/rhf/use-rhf-wizard-step.js.map +1 -1
- package/dist/search/command-palette.js +71 -62
- package/dist/search/command-palette.js.map +1 -1
- package/dist/search/global-search.js +34 -5
- package/dist/search/global-search.js.map +1 -1
- package/dist/search/search-index.js.map +1 -1
- package/dist/settings/device-reset-setting.js +1 -6
- package/dist/settings/device-reset-setting.js.map +1 -1
- package/dist/settings/settings-heading.js.map +1 -1
- package/dist/settings/settings-layout.js +9 -20
- package/dist/settings/settings-layout.js.map +1 -1
- package/dist/settings/use-settings-route.js.map +1 -1
- package/dist/shell/app-shell.js +62 -31
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/auth-layout.js +1 -10
- package/dist/shell/auth-layout.js.map +1 -1
- package/dist/shell/dev-menu.js +12 -6
- package/dist/shell/dev-menu.js.map +1 -1
- package/dist/shell/option-switcher-menu.js.map +1 -1
- package/dist/shell/sync-status-indicator.js +7 -6
- package/dist/shell/sync-status-indicator.js.map +1 -1
- package/dist/shell/top-bar-parts-labels.js.map +1 -1
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell/topbar-controls.js +1 -10
- package/dist/shell/topbar-controls.js.map +1 -1
- package/dist/shell/tour-menu.js +9 -1
- package/dist/shell/tour-menu.js.map +1 -1
- package/dist/sync/db.js.map +1 -1
- package/dist/sync/engine.js +24 -6
- package/dist/sync/engine.js.map +1 -1
- package/dist/sync.d.ts +2 -2
- package/dist/sync.js +7 -1
- package/dist/sync.js.map +1 -1
- package/dist/testing/css-variables.js.map +1 -1
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +1 -3
- package/dist/testing/index.js.map +1 -1
- package/dist/testing/react.js.map +1 -1
- package/dist/testing/viewport.js +7 -4
- package/dist/testing/viewport.js.map +1 -1
- package/dist/theme/contrast-tokens.js +13 -2
- package/dist/theme/contrast-tokens.js.map +1 -1
- package/dist/theme/palette-derive.d.ts +1 -1
- package/dist/theme/palette-derive.js +18 -29
- package/dist/theme/palette-derive.js.map +1 -1
- package/dist/theme/palette-presets.js.map +1 -1
- package/dist/theme/palette-store.js.map +1 -1
- package/dist/theme/text-size.js.map +1 -1
- package/dist/theme/theme-store.js +1 -3
- package/dist/theme/theme-store.js.map +1 -1
- package/dist/tour/tour-catalogue.js.map +1 -1
- package/dist/tour/tour-dock.js +5 -1
- package/dist/tour/tour-dock.js.map +1 -1
- package/dist/tour/tour-engine.js +1 -4
- package/dist/tour/tour-engine.js.map +1 -1
- package/dist/tour/tour.js +21 -12
- package/dist/tour/tour.js.map +1 -1
- package/dist/tour/use-tour-progress.js.map +1 -1
- package/dist/tour/use-tour-url-sync.js.map +1 -1
- package/dist/wizard/stepper-nav.js +3 -24
- package/dist/wizard/stepper-nav.js.map +1 -1
- package/dist/wizard/types.js.map +1 -1
- package/dist/wizard/use-wizard.js +24 -28
- package/dist/wizard/use-wizard.js.map +1 -1
- package/dist/wizard/validation.js.map +1 -1
- package/dist/wizard/wizard-context.js.map +1 -1
- package/dist/wizard/wizard-step.d.ts +1 -1
- package/dist/wizard/wizard-step.js +1 -8
- package/dist/wizard/wizard-step.js.map +1 -1
- package/dist/wizard/wizard-summary.js.map +1 -1
- package/package.json +5 -2
- package/src/account/account-settings-labels.ts +1 -2
- package/src/account/account-settings.tsx +11 -17
- package/src/account/delete-account-setting.tsx +1 -6
- package/src/account/email-change-setting-labels.ts +1 -2
- package/src/account/email-change-setting.tsx +3 -1
- package/src/account/passkeys-setting.tsx +1 -3
- package/src/account/sessions-setting.tsx +4 -2
- package/src/admin/admin-action-confirm.tsx +4 -7
- package/src/admin/admin-action-log.tsx +29 -29
- package/src/admin/admin-area-labels.ts +4 -1
- package/src/admin/admin-overview.tsx +8 -2
- package/src/admin/admin-sections.tsx +3 -1
- package/src/admin/admin-tool-links.tsx +7 -1
- package/src/admin/invitations-panel.tsx +5 -11
- package/src/admin/mail-test-card.tsx +1 -2
- package/src/admin/reviewer-scope-editor.tsx +4 -9
- package/src/admin/role-select.tsx +4 -2
- package/src/admin/user-roster.tsx +2 -11
- package/src/auth/accept-invitation.tsx +1 -7
- package/src/auth/auth-errors.ts +1 -6
- package/src/auth/forgot-password-form.tsx +2 -7
- package/src/auth/register-form.tsx +7 -8
- package/src/auth/reset-password-form.tsx +2 -5
- package/src/auth/sign-in-form.tsx +15 -14
- package/src/auth/verify-email.tsx +2 -7
- package/src/billing/billing-banner.tsx +3 -7
- package/src/billing/billing-labels.ts +6 -4
- package/src/billing/checkout-processing.ts +0 -1
- package/src/billing/plan-card.tsx +9 -8
- package/src/billing/plan-limit-toast.tsx +1 -2
- package/src/billing/plan-limit.ts +1 -2
- package/src/billing/plan-price.ts +1 -4
- package/src/billing/plan-switch.tsx +1 -2
- package/src/billing/subscription-actions.tsx +5 -2
- package/src/components/account-chips.tsx +1 -7
- package/src/components/alert-banner.tsx +2 -8
- package/src/components/amount-input.tsx +206 -178
- package/src/components/authed-image.tsx +7 -4
- package/src/components/autocomplete.tsx +7 -9
- package/src/components/bulk-action-bar.tsx +2 -7
- package/src/components/button-group.tsx +1 -2
- package/src/components/button-look.ts +6 -17
- package/src/components/button.tsx +1 -4
- package/src/components/calculator.tsx +6 -9
- package/src/components/calendar-heatmap.tsx +5 -10
- package/src/components/card.tsx +9 -13
- package/src/components/chart-zoom-core.tsx +6 -33
- package/src/components/chart.tsx +8 -31
- package/src/components/checkbox-group.tsx +1 -4
- package/src/components/chip-input.tsx +6 -6
- package/src/components/chip.tsx +14 -26
- package/src/components/choice-card.tsx +4 -18
- package/src/components/column-mapper-labels.ts +3 -10
- package/src/components/column-mapper.tsx +33 -122
- package/src/components/combobox-core.tsx +12 -42
- package/src/components/combobox.tsx +2 -6
- package/src/components/confirm-dialog.tsx +1 -3
- package/src/components/copy-button.tsx +2 -4
- package/src/components/country-select.tsx +9 -21
- package/src/components/currency-option-list.tsx +13 -14
- package/src/components/currency-select.tsx +2 -7
- package/src/components/danger-confirm.tsx +8 -9
- package/src/components/data-table-cells.tsx +1 -2
- package/src/components/data-table-column-settings.tsx +1 -3
- package/src/components/data-table-filter-popover.tsx +5 -19
- package/src/components/data-table-filters.ts +1 -6
- package/src/components/data-table-fit.ts +1 -3
- package/src/components/data-table-groups.tsx +3 -1
- package/src/components/data-table-head.tsx +27 -45
- package/src/components/data-table-mobile.tsx +99 -108
- package/src/components/data-table-pagination.tsx +2 -6
- package/src/components/data-table-row-actions.tsx +25 -24
- package/src/components/data-table-row.tsx +16 -17
- package/src/components/data-table-shared.tsx +10 -16
- package/src/components/data-table-sort.ts +1 -2
- package/src/components/data-table-virtual.ts +9 -4
- package/src/components/data-table.tsx +489 -501
- package/src/components/date-picker.tsx +1 -7
- package/src/components/date-range-picker.tsx +3 -13
- package/src/components/description-list.tsx +7 -13
- package/src/components/dialog-frame.tsx +12 -3
- package/src/components/disclosure.tsx +8 -12
- package/src/components/dropdown.tsx +3 -11
- package/src/components/empty-state.tsx +1 -3
- package/src/components/entity-combobox.tsx +2 -6
- package/src/components/error-boundary.tsx +7 -7
- package/src/components/field-base.tsx +8 -14
- package/src/components/field-parts.tsx +12 -23
- package/src/components/field-sync.tsx +7 -11
- package/src/components/file-button.tsx +7 -3
- package/src/components/file-dropzone.tsx +7 -12
- package/src/components/floating-panel.tsx +44 -51
- package/src/components/focus-ring.ts +1 -2
- package/src/components/form-actions.tsx +1 -4
- package/src/components/grouped-picker.tsx +6 -8
- package/src/components/hover-menu.tsx +1 -4
- package/src/components/iban-input-labels.ts +1 -2
- package/src/components/icon-button.tsx +46 -48
- package/src/components/icon-picker.tsx +4 -17
- package/src/components/image-grid.tsx +1 -6
- package/src/components/inline-edit-field.tsx +1 -5
- package/src/components/inline-entity-combobox.tsx +6 -21
- package/src/components/input.tsx +14 -2
- package/src/components/jump-calendar.tsx +2 -6
- package/src/components/legal-labels.ts +1 -2
- package/src/components/legal-page.tsx +18 -7
- package/src/components/legal.tsx +14 -5
- package/src/components/lightbox.tsx +1 -3
- package/src/components/line-items.tsx +7 -15
- package/src/components/list.tsx +29 -25
- package/src/components/measured-grid.tsx +8 -30
- package/src/components/menu-item.tsx +1 -3
- package/src/components/mini-calendar.tsx +129 -159
- package/src/components/money-field.tsx +4 -2
- package/src/components/month-picker.tsx +14 -21
- package/src/components/multi-entity-combobox.tsx +6 -26
- package/src/components/multi-select.tsx +5 -9
- package/src/components/nav-pills.tsx +10 -2
- package/src/components/number-field.tsx +18 -15
- package/src/components/number-input.tsx +3 -19
- package/src/components/numpad-sheet.tsx +9 -13
- package/src/components/one-time-code-input.tsx +6 -11
- package/src/components/page-contents.tsx +2 -9
- package/src/components/page-header.tsx +5 -10
- package/src/components/password-strength-labels.ts +1 -2
- package/src/components/password-strength.tsx +6 -9
- package/src/components/phone-input.tsx +2 -13
- package/src/components/picker-sheet.tsx +2 -5
- package/src/components/pie-chart.tsx +6 -12
- package/src/components/popover.tsx +2 -1
- package/src/components/progress-bar.tsx +16 -25
- package/src/components/qr-code.tsx +2 -9
- package/src/components/row-actions.tsx +9 -1
- package/src/components/search-field.tsx +4 -5
- package/src/components/select.tsx +2 -10
- package/src/components/separator.tsx +1 -5
- package/src/components/series-chart-axes.ts +5 -5
- package/src/components/series-chart-budget.ts +1 -4
- package/src/components/series-chart-ticks.ts +1 -4
- package/src/components/series-chart.tsx +43 -83
- package/src/components/settings-fields.tsx +20 -15
- package/src/components/share-card.tsx +6 -14
- package/src/components/sign-chip.tsx +16 -16
- package/src/components/signature-pad.tsx +21 -13
- package/src/components/signed-amount.tsx +6 -14
- package/src/components/skeleton.tsx +2 -13
- package/src/components/slider.tsx +9 -20
- package/src/components/sparkline.tsx +2 -13
- package/src/components/stat-tile.tsx +13 -24
- package/src/components/swatch-picker.tsx +8 -3
- package/src/components/swipeable-row.tsx +2 -11
- package/src/components/table-skeleton.tsx +4 -1
- package/src/components/table.tsx +12 -8
- package/src/components/tabs.tsx +24 -39
- package/src/components/text-link.tsx +6 -6
- package/src/components/text.tsx +1 -2
- package/src/components/tile-radio.tsx +6 -19
- package/src/components/time-input.tsx +4 -5
- package/src/components/toast.tsx +3 -12
- package/src/components/toggle-group.tsx +9 -9
- package/src/components/toggle-legend.tsx +4 -16
- package/src/components/tooltip.tsx +9 -48
- package/src/components/translation-review-editor.tsx +4 -4
- package/src/components/translation-review-parts.tsx +7 -1
- package/src/components/translation-review.tsx +15 -16
- package/src/components/tree-view.tsx +17 -20
- package/src/components/treemap.tsx +3 -15
- package/src/components/trigger-aria.ts +1 -4
- package/src/components/use-table-state.ts +16 -17
- package/src/components/user-avatar.tsx +4 -21
- package/src/demo/demo-start.tsx +8 -4
- package/src/feedback/feedback-attachment-labels.ts +2 -1
- package/src/feedback/feedback-attachment.tsx +16 -29
- package/src/feedback/feedback-capture.ts +3 -1
- package/src/feedback/feedback-comment-thread.tsx +9 -6
- package/src/feedback/feedback-context.tsx +1 -4
- package/src/feedback/feedback-dialog.tsx +3 -4
- package/src/feedback/feedback-inbox.tsx +3 -9
- package/src/feedback/feedback-public-list.tsx +7 -1
- package/src/feedback/feedback-record.ts +6 -14
- package/src/feedback/feedback-replies.tsx +10 -5
- package/src/feedback/feedback-row-detail.tsx +2 -5
- package/src/feedback/feedback-status-undo.ts +1 -4
- package/src/feedback/feedback-submit-dialog.tsx +4 -4
- package/src/feedback/feedback-table.tsx +12 -8
- package/src/feedback/feedback-thread.tsx +4 -16
- package/src/feedback/feedback-vocabulary.ts +72 -82
- package/src/feedback/feedback-vote-button.tsx +9 -8
- package/src/hooks/use-anchored-rect.ts +1 -4
- package/src/hooks/use-document-title.ts +3 -1
- package/src/hooks/use-file-drop.ts +1 -3
- package/src/hooks/use-file-picker-labels.ts +1 -2
- package/src/hooks/use-file-picker.tsx +1 -5
- package/src/hooks/use-focus-trap.ts +1 -2
- package/src/hooks/use-overlay-history.ts +10 -5
- package/src/hooks/use-row-swipe.ts +1 -6
- package/src/i18n/defaults.ts +1 -4
- package/src/i18n/german.ts +33 -49
- package/src/i18n/kit-labels.tsx +5 -20
- package/src/i18n/languages.ts +2 -8
- package/src/i18n/locales/en.ts +11 -31
- package/src/i18n/locales/es.ts +35 -49
- package/src/i18n/locales/fr.ts +34 -51
- package/src/i18n/locales/hu.ts +26 -37
- package/src/i18n/locales/it.ts +35 -52
- package/src/i18n/locales/zh.ts +14 -32
- package/src/i18n/plurals.ts +4 -1
- package/src/i18n/review.ts +18 -7
- package/src/i18n/shared-labels.ts +1 -2
- package/src/index.ts +106 -32
- package/src/landing/access.ts +2 -7
- package/src/landing/landing-sections.tsx +14 -15
- package/src/landing/page-seo.ts +1 -3
- package/src/lib/calc.ts +4 -1
- package/src/lib/clipboard-minus.ts +4 -1
- package/src/lib/dates.ts +2 -10
- package/src/lib/decimal-marks.ts +1 -3
- package/src/lib/device-settings.ts +2 -1
- package/src/lib/direction.ts +1 -3
- package/src/lib/iban.ts +89 -9
- package/src/lib/logger.ts +1 -5
- package/src/lib/person-name.ts +1 -3
- package/src/lib/phone.ts +5 -8
- package/src/lib/qr-encode.ts +32 -8
- package/src/lib/session-refresh.ts +5 -6
- package/src/lib/table-text.ts +24 -6
- package/src/lib/text-match.ts +15 -3
- package/src/lib/translation-review.ts +1 -3
- package/src/notifications/notification-inbox.tsx +17 -4
- package/src/notifications/notification-parts.tsx +1 -2
- package/src/notifications/notification-types-setting.tsx +19 -18
- package/src/notifications/notifications-labels.ts +1 -2
- package/src/notifications/push-device-setting.tsx +7 -1
- package/src/notifications/push-devices-setting.tsx +7 -3
- package/src/notifications/push-worker.ts +10 -2
- package/src/notifications/quiet-hours-setting.tsx +4 -1
- package/src/notifications/use-push-device.ts +4 -1
- package/src/offline/pwa-update-prompt.tsx +7 -1
- package/src/offline/query-persistence.ts +9 -6
- package/src/offline.ts +6 -1
- package/src/rhf/fields.tsx +50 -19
- package/src/rhf/form-core.tsx +2 -6
- package/src/rhf/line-items.tsx +5 -7
- package/src/rhf/use-rhf-wizard-step.ts +3 -12
- package/src/search/command-palette.tsx +15 -23
- package/src/search/global-search.tsx +38 -8
- package/src/search/search-index.ts +5 -1
- package/src/settings/device-reset-setting.tsx +5 -8
- package/src/settings/settings-heading.tsx +3 -1
- package/src/settings/settings-layout.tsx +6 -13
- package/src/settings/use-settings-route.ts +4 -3
- package/src/shell/app-shell.tsx +48 -24
- package/src/shell/auth-layout.tsx +1 -6
- package/src/shell/dev-menu.tsx +12 -6
- package/src/shell/option-switcher-menu.tsx +1 -3
- package/src/shell/sync-status-indicator.tsx +14 -12
- package/src/shell/top-bar-parts-labels.ts +1 -3
- package/src/shell/topbar-action-menu.tsx +9 -8
- package/src/shell/topbar-controls.tsx +5 -19
- package/src/shell/tour-menu.tsx +9 -1
- package/src/sync/db.ts +5 -1
- package/src/sync/engine.ts +44 -12
- package/src/sync.ts +16 -2
- package/src/testing/css-variables.ts +1 -2
- package/src/testing/index.ts +1 -6
- package/src/testing/react.ts +1 -2
- package/src/testing/viewport.ts +9 -9
- package/src/theme/contrast-tokens.ts +13 -2
- package/src/theme/palette-derive.ts +24 -50
- package/src/theme/palette-presets.ts +0 -1
- package/src/theme/palette-store.ts +1 -4
- package/src/theme/text-size.ts +1 -1
- package/src/theme/theme-store.ts +2 -6
- package/src/tour/tour-catalogue.tsx +4 -2
- package/src/tour/tour-dock.ts +5 -1
- package/src/tour/tour-engine.ts +2 -6
- package/src/tour/tour.tsx +28 -12
- package/src/tour/use-tour-progress.ts +8 -2
- package/src/tour/use-tour-url-sync.ts +3 -1
- package/src/vite-env.d.ts +1 -4
- package/src/wizard/stepper-nav.tsx +10 -35
- package/src/wizard/types.ts +1 -4
- package/src/wizard/use-wizard.ts +28 -43
- package/src/wizard/validation.ts +1 -5
- package/src/wizard/wizard-context.tsx +2 -6
- package/src/wizard/wizard-step.tsx +1 -8
- package/src/wizard/wizard-summary.tsx +2 -6
- package/tokens.css +393 -152
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/landing/page-seo.ts"],"sourcesContent":["import { useEffect } from \"react\";\n\n/**\n * Search-engine helpers for the public landing page (docs/landing-demo-harmonization.md\n * §4.3): the hook that declares a page's canonical URL, title, description and JSON-LD,\n * and the two pure helpers each app's SEO test is built from.\n */\n\n/** What {@link usePageSeo} declares for a page. */\nexport interface PageSeo {\n canonicalPath: string;\n title?: string;\n description?: string;\n /** Structured data for this page, as the object (not a string). Injected as an\n * `application/ld+json` DATA BLOCK — a script element the browser never executes,\n * which is also why it can be added here rather than in `index.html`: the CSP\n * carries a HASH of the one inline script that file is allowed to have, and\n * `csp-inline-script.test.ts` fails the build on a second one (live #250). */\n jsonLd?: Record<string, unknown>;\n}\n\n/**\n * Declares the current page's canonical URL — and optionally its title and meta\n * description — while the calling component is mounted (feedback #129).\n *\n * Client-side because there is nowhere else to put it: the SPA is served by\n * Caddy's `try_files … /index.html`, so every route shares one static head.\n * Googlebot renders JS and reads the canonical from the RENDERED DOM, which is\n * what makes this work; non-rendering scrapers (WhatsApp, Slack) only ever see\n * index.html's static tags, which is why those stay in place as the fallback.\n *\n * The href is resolved against `window.location.origin` rather than hardcoded, so\n * a preview deployment or the VPS host stays self-referential instead of pointing\n * every copy of the app at production.\n *\n * The cleanup matters for the same reason as in {@link useNoIndex}: an SPA\n * navigation from the landing page into the app must not leave the landing's\n * canonical (or title) behind on an app page.\n *\n * 0.31 (§4.3): keksdose's `usePageSeo` (shared/hooks/use-page-seo.ts), verbatim. The\n * landing calls it with `canonicalPath: \"/\"` on both `/` and `/welcome`, so the two URLs\n * of one page are one result; there are no per-language URLs and no hreflang.\n */\nexport function usePageSeo(seo: PageSeo): void {\n usePageSeoIf(seo);\n}\n\n/**\n * @internal {@link usePageSeo}, or nothing for `null` — for a component that declares a\n * page only sometimes (`PublicPage`'s `seo`): hooks cannot be called conditionally, so the\n * switch is the argument, as `useNoIndex(active)` has it.\n */\nexport function usePageSeoIf(seo: PageSeo | null): void {\n const canonicalPath = seo?.canonicalPath;\n const title = seo?.title;\n const description = seo?.description;\n const jsonLd = seo?.jsonLd;\n useEffect(() => {\n if (canonicalPath === undefined) return;\n const link = document.createElement(\"link\");\n link.rel = \"canonical\";\n link.href = new URL(canonicalPath, window.location.origin).toString();\n document.head.appendChild(link);\n\n const previousTitle = document.title;\n if (title) document.title = title;\n\n // The description tag ships in index.html, so upsert rather than append —\n // two `meta[name=description]` tags would be a coin flip for the scraper.\n const meta = description\n ? document.head.querySelector<HTMLMetaElement>('meta[name=\"description\"]')\n : null;\n const previousDescription = meta?.content;\n if (meta && description) meta.content = description;\n\n let ld: HTMLScriptElement | null = null;\n if (jsonLd) {\n ld = document.createElement(\"script\");\n ld.type = \"application/ld+json\";\n ld.textContent = JSON.stringify(jsonLd);\n document.head.appendChild(ld);\n }\n\n return () => {\n link.remove();\n ld?.remove();\n if (title) document.title = previousTitle;\n if (meta && previousDescription !== undefined) meta.content = previousDescription;\n };\n // `jsonLd` is built inline by the caller, so it is a new object every render and\n // cannot be a dependency — it is derived from `title`/`description`, which are.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [canonicalPath, title, description]);\n}\n\n/* ── The SEO test's helpers ──────────────────────────────────────────────── */\n\n/** What a search result shows of the landing, in one language. */\nexport interface SeoCopy {\n /** The page title (`landing.seo.title`): \"Keksdose — envelope budgeting\". */\n title: string;\n /** The meta description. */\n description: string;\n /** The product name the title carries, before or after its tagline. */\n brand: string;\n}\n\n/** The width budgets, as characters. */\nexport interface SeoCopyLimits {\n /** Default 60: a result's title is cut around 580px. */\n maxTitle?: number;\n /** Default 155: a snippet is cut around 920px. */\n maxDescription?: number;\n}\n\nexport type SeoCopyProblemCode =\n | \"title-empty\"\n | \"description-empty\"\n | \"title-too-long\"\n | \"description-too-long\"\n | \"title-without-brand\"\n | \"description-repeats-title\";\n\n/** One thing wrong with a language's copy. `message` is for the test's failure output. */\nexport interface SeoCopyProblem {\n code: SeoCopyProblemCode;\n message: string;\n}\n\n/** Characters as a reader counts them: code points, so an emoji or a rare CJK character\n * is one, not two UTF-16 units. */\nconst length = (text: string) => Array.from(text).length;\n\nconst escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n\n/** The separators a title puts between the brand and its tagline. */\nconst SEPARATOR = \"\\\\s*[—–\\\\-|:·•]\\\\s*\";\n\n/** The title without the brand and its separator, or `null` when the brand is in\n * neither place. */\nfunction taglineOf(title: string, brand: string): string | null {\n const name = escapeRegExp(brand.trim());\n const leading = new RegExp(`^${name}${SEPARATOR}`, \"i\");\n if (leading.test(title)) return title.replace(leading, \"\").trim();\n const trailing = new RegExp(`${SEPARATOR}${name}$`, \"i\");\n if (trailing.test(title)) return title.replace(trailing, \"\").trim();\n return null;\n}\n\n/**\n * What is wrong with one language's search-result copy (§4.3) — an empty list when\n * nothing is. keksdose's landing-seo.test.ts (live #305), made reusable:\n *\n * - the title fits `maxTitle` characters (60) and the description `maxDescription`\n * (155) — conservative stand-ins for Google's pixel budgets; German is the language\n * most likely to blow through them, and what gets cut is always the end;\n * - the title carries the brand, before or after its tagline (\"Keksdose — …\");\n * - the description does not open with the title's tagline again — a snippet that\n * repeats the line above it spends its first forty characters saying nothing new.\n *\n * ```ts\n * for (const [locale, bundle] of Object.entries(LOCALES)) {\n * it(`fits ${locale} into a search result`, () => {\n * expect(seoCopyProblems({ ...bundle.landing.seo, brand: \"Keksdose\" })).toEqual([]);\n * });\n * }\n * ```\n */\nexport function seoCopyProblems(copy: SeoCopy, limits: SeoCopyLimits = {}): SeoCopyProblem[] {\n const { maxTitle = 60, maxDescription = 155 } = limits;\n const title = copy.title.replace(/\\s+/g, \" \").trim();\n const description = copy.description.replace(/\\s+/g, \" \").trim();\n const problems: SeoCopyProblem[] = [];\n if (!title) problems.push({ code: \"title-empty\", message: \"the title is empty\" });\n if (!description) problems.push({ code: \"description-empty\", message: \"the description is empty\" });\n if (length(title) > maxTitle) {\n problems.push({\n code: \"title-too-long\",\n message: `the title is ${length(title)} characters, over ${maxTitle}: \"${title}\"`,\n });\n }\n if (length(description) > maxDescription) {\n problems.push({\n code: \"description-too-long\",\n message: `the description is ${length(description)} characters, over ${maxDescription}: \"${description}\"`,\n });\n }\n if (!title) return problems;\n const tagline = taglineOf(title, copy.brand);\n if (tagline === null) {\n problems.push({\n code: \"title-without-brand\",\n message: `the title does not start or end with \"${copy.brand}\" and a separator: \"${title}\"`,\n });\n }\n const repeated = (tagline ?? title).toLowerCase();\n if (repeated && description.toLowerCase().startsWith(repeated)) {\n problems.push({\n code: \"description-repeats-title\",\n message: `the description opens with the title's tagline \"${tagline ?? title}\"`,\n });\n }\n return problems;\n}\n\n/** The five named references and the numeric ones — enough for a meta tag's text. */\nfunction decodeEntities(text: string): string {\n const named: Record<string, string> = { amp: \"&\", lt: \"<\", gt: \">\", quot: '\"', apos: \"'\", nbsp: \" \" };\n return text.replace(/&(#x[\\da-f]+|#\\d+|[a-z]+);/gi, (whole, ref: string) => {\n if (ref[0] === \"#\") {\n const code = ref[1] === \"x\" || ref[1] === \"X\" ? parseInt(ref.slice(2), 16) : parseInt(ref.slice(1), 10);\n return Number.isFinite(code) ? String.fromCodePoint(code) : whole;\n }\n return named[ref.toLowerCase()] ?? whole;\n });\n}\n\n/** A tag's attributes, names lower-cased; values double-, single- or un-quoted. */\nfunction attributesOf(tag: string): Map<string, string> {\n const found = new Map<string, string>();\n for (const m of tag.matchAll(/([^\\s=/<>\"']+)\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)'|([^\\s\"'=<>`]+))/g)) {\n found.set(m[1]!.toLowerCase(), m[2] ?? m[3] ?? m[4] ?? \"\");\n }\n return found;\n}\n\n/**\n * The `content` of the first `<meta>` in `html` that `selector` picks, whitespace\n * collapsed and entities decoded — or `undefined` when there is none. For each app's\n * parity test (§4.3): `index.html`'s static description and `og:` tags must equal the\n * default language's strings, because a scraper that runs no script (WhatsApp, Slack)\n * sees only those.\n *\n * `selector` names attributes, as in CSS — `meta[name=\"description\"]`,\n * `[property=\"og:title\"]` or just `property=\"og:description\"` — and every one named must\n * match. A `RegExp` is matched against the tag's source instead (keksdose's form).\n * String parsing, not a DOM: the test runs under `@vitest-environment node`.\n *\n * ```ts\n * expect(metaContent(html, 'meta[name=\"description\"]')).toBe(de.landing.seo.description);\n * ```\n */\nexport function metaContent(html: string, selector: string | RegExp): string | undefined {\n const wanted =\n typeof selector === \"string\"\n ? Array.from(selector.matchAll(/([\\w:.-]+)\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)')/g), (m) => [\n m[1]!.toLowerCase(),\n m[2] ?? m[3] ?? \"\",\n ])\n : null;\n if (wanted !== null && wanted.length === 0) {\n throw new Error(`metaContent: the selector names no attribute: ${selector as string}`);\n }\n const pattern = selector instanceof RegExp ? new RegExp(selector.source, selector.flags.replace(\"g\", \"\")) : null;\n for (const tag of html.match(/<meta\\b[^>]*>/gi) ?? []) {\n const attrs = attributesOf(tag);\n const matches = pattern ? pattern.test(tag) : wanted!.every(([name, value]) => attrs.get(name!) === value);\n if (!matches) continue;\n const content = attrs.get(\"content\");\n return content === undefined ? undefined : decodeEntities(content).replace(/\\s+/g, \" \").trim();\n }\n return undefined;\n}\n"],"mappings":";AAAA,SAAS,iBAAiB;AA2CnB,SAAS,WAAW,KAAoB;AAC7C,eAAa,GAAG;AAClB;AAOO,SAAS,aAAa,KAA2B;AACtD,QAAM,gBAAgB,KAAK;AAC3B,QAAM,QAAQ,KAAK;AACnB,QAAM,cAAc,KAAK;AACzB,QAAM,SAAS,KAAK;AACpB,YAAU,MAAM;AACd,QAAI,kBAAkB,OAAW;AACjC,UAAM,OAAO,SAAS,cAAc,MAAM;AAC1C,SAAK,MAAM;AACX,SAAK,OAAO,IAAI,IAAI,eAAe,OAAO,SAAS,MAAM,EAAE,SAAS;AACpE,aAAS,KAAK,YAAY,IAAI;AAE9B,UAAM,gBAAgB,SAAS;AAC/B,QAAI,MAAO,UAAS,QAAQ;AAI5B,UAAM,OAAO,cACT,SAAS,KAAK,cAA+B,0BAA0B,IACvE;AACJ,UAAM,sBAAsB,MAAM;AAClC,QAAI,QAAQ,YAAa,MAAK,UAAU;AAExC,QAAI,KAA+B;AACnC,QAAI,QAAQ;AACV,WAAK,SAAS,cAAc,QAAQ;AACpC,SAAG,OAAO;AACV,SAAG,cAAc,KAAK,UAAU,MAAM;AACtC,eAAS,KAAK,YAAY,EAAE;AAAA,IAC9B;AAEA,WAAO,MAAM;AACX,WAAK,OAAO;AACZ,UAAI,OAAO;AACX,UAAI,MAAO,UAAS,QAAQ;AAC5B,UAAI,QAAQ,wBAAwB,OAAW,MAAK,UAAU;AAAA,IAChE;AAAA,EAIF,GAAG,CAAC,eAAe,OAAO,WAAW,CAAC;AACxC;AAsCA,MAAM,SAAS,CAAC,SAAiB,MAAM,KAAK,IAAI,EAAE;AAElD,MAAM,eAAe,CAAC,SAAiB,KAAK,QAAQ,uBAAuB,MAAM;AAGjF,MAAM,YAAY;AAIlB,SAAS,UAAU,OAAe,OAA8B;AAC9D,QAAM,OAAO,aAAa,MAAM,KAAK,CAAC;AACtC,QAAM,UAAU,IAAI,OAAO,IAAI,IAAI,GAAG,SAAS,IAAI,GAAG;AACtD,MAAI,QAAQ,KAAK,KAAK,EAAG,QAAO,MAAM,QAAQ,SAAS,EAAE,EAAE,KAAK;AAChE,QAAM,WAAW,IAAI,OAAO,GAAG,SAAS,GAAG,IAAI,KAAK,GAAG;AACvD,MAAI,SAAS,KAAK,KAAK,EAAG,QAAO,MAAM,QAAQ,UAAU,EAAE,EAAE,KAAK;AAClE,SAAO;AACT;AAqBO,SAAS,gBAAgB,MAAe,SAAwB,CAAC,GAAqB;AAC3F,QAAM,EAAE,WAAW,IAAI,iBAAiB,IAAI,IAAI;AAChD,QAAM,QAAQ,KAAK,MAAM,QAAQ,QAAQ,GAAG,EAAE,KAAK;AACnD,QAAM,cAAc,KAAK,YAAY,QAAQ,QAAQ,GAAG,EAAE,KAAK;AAC/D,QAAM,WAA6B,CAAC;AACpC,MAAI,CAAC,MAAO,UAAS,KAAK,EAAE,MAAM,eAAe,SAAS,qBAAqB,CAAC;AAChF,MAAI,CAAC,YAAa,UAAS,KAAK,EAAE,MAAM,qBAAqB,SAAS,2BAA2B,CAAC;AAClG,MAAI,OAAO,KAAK,IAAI,UAAU;AAC5B,aAAS,KAAK;AAAA,MACZ,MAAM;AAAA,MACN,SAAS,gBAAgB,OAAO,KAAK,CAAC,qBAAqB,QAAQ,MAAM,KAAK;AAAA,IAChF,CAAC;AAAA,EACH;AACA,MAAI,OAAO,WAAW,IAAI,gBAAgB;AACxC,aAAS,KAAK;AAAA,MACZ,MAAM;AAAA,MACN,SAAS,sBAAsB,OAAO,WAAW,CAAC,qBAAqB,cAAc,MAAM,WAAW;AAAA,IACxG,CAAC;AAAA,EACH;AACA,MAAI,CAAC,MAAO,QAAO;AACnB,QAAM,UAAU,UAAU,OAAO,KAAK,KAAK;AAC3C,MAAI,YAAY,MAAM;AACpB,aAAS,KAAK;AAAA,MACZ,MAAM;AAAA,MACN,SAAS,yCAAyC,KAAK,KAAK,uBAAuB,KAAK;AAAA,IAC1F,CAAC;AAAA,EACH;AACA,QAAM,YAAY,WAAW,OAAO,YAAY;AAChD,MAAI,YAAY,YAAY,YAAY,EAAE,WAAW,QAAQ,GAAG;AAC9D,aAAS,KAAK;AAAA,MACZ,MAAM;AAAA,MACN,SAAS,mDAAmD,WAAW,KAAK;AAAA,IAC9E,CAAC;AAAA,EACH;AACA,SAAO;AACT;AAGA,SAAS,eAAe,MAAsB;AAC5C,QAAM,QAAgC,EAAE,KAAK,KAAK,IAAI,KAAK,IAAI,KAAK,MAAM,KAAK,MAAM,KAAK,MAAM,OAAI;AACpG,SAAO,KAAK,QAAQ,gCAAgC,CAAC,OAAO,QAAgB;AAC1E,QAAI,IAAI,CAAC,MAAM,KAAK;AAClB,YAAM,OAAO,IAAI,CAAC,MAAM,OAAO,IAAI,CAAC,MAAM,MAAM,SAAS,IAAI,MAAM,CAAC,GAAG,EAAE,IAAI,SAAS,IAAI,MAAM,CAAC,GAAG,EAAE;AACtG,aAAO,OAAO,SAAS,IAAI,IAAI,OAAO,cAAc,IAAI,IAAI;AAAA,IAC9D;AACA,WAAO,MAAM,IAAI,YAAY,CAAC,KAAK;AAAA,EACrC,CAAC;AACH;AAGA,SAAS,aAAa,KAAkC;AACtD,QAAM,QAAQ,oBAAI,IAAoB;AACtC,aAAW,KAAK,IAAI,SAAS,8DAA8D,GAAG;AAC5F,UAAM,IAAI,EAAE,CAAC,EAAG,YAAY,GAAG,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,EAAE;AAAA,EAC3D;AACA,SAAO;AACT;AAkBO,SAAS,YAAY,MAAc,UAA+C;AACvF,QAAM,SACJ,OAAO,aAAa,WAChB,MAAM,KAAK,SAAS,SAAS,2CAA2C,GAAG,CAAC,MAAM;AAAA,IAChF,EAAE,CAAC,EAAG,YAAY;AAAA,IAClB,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK;AAAA,EAClB,CAAC,IACD;AACN,MAAI,WAAW,QAAQ,OAAO,WAAW,GAAG;AAC1C,UAAM,IAAI,MAAM,iDAAiD,QAAkB,EAAE;AAAA,EACvF;AACA,QAAM,UAAU,oBAAoB,SAAS,IAAI,OAAO,SAAS,QAAQ,SAAS,MAAM,QAAQ,KAAK,EAAE,CAAC,IAAI;AAC5G,aAAW,OAAO,KAAK,MAAM,iBAAiB,KAAK,CAAC,GAAG;AACrD,UAAM,QAAQ,aAAa,GAAG;AAC9B,UAAM,UAAU,UAAU,QAAQ,KAAK,GAAG,IAAI,OAAQ,MAAM,CAAC,CAAC,MAAM,KAAK,MAAM,MAAM,IAAI,IAAK,MAAM,KAAK;AACzG,QAAI,CAAC,QAAS;AACd,UAAM,UAAU,MAAM,IAAI,SAAS;AACnC,WAAO,YAAY,SAAY,SAAY,eAAe,OAAO,EAAE,QAAQ,QAAQ,GAAG,EAAE,KAAK;AAAA,EAC/F;AACA,SAAO;AACT;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/landing/page-seo.ts"],"sourcesContent":["import { useEffect } from \"react\";\n\n/**\n * Search-engine helpers for the public landing page (docs/landing-demo-harmonization.md\n * §4.3): the hook that declares a page's canonical URL, title, description and JSON-LD,\n * and the two pure helpers each app's SEO test is built from.\n */\n\n/** What {@link usePageSeo} declares for a page. */\nexport interface PageSeo {\n canonicalPath: string;\n title?: string;\n description?: string;\n /** Structured data for this page, as the object (not a string). Injected as an\n * `application/ld+json` DATA BLOCK — a script element the browser never executes,\n * which is also why it can be added here rather than in `index.html`: the CSP\n * carries a HASH of the one inline script that file is allowed to have, and\n * `csp-inline-script.test.ts` fails the build on a second one (live #250). */\n jsonLd?: Record<string, unknown>;\n}\n\n/**\n * Declares the current page's canonical URL — and optionally its title and meta\n * description — while the calling component is mounted (feedback #129).\n *\n * Client-side because there is nowhere else to put it: the SPA is served by\n * Caddy's `try_files … /index.html`, so every route shares one static head.\n * Googlebot renders JS and reads the canonical from the RENDERED DOM, which is\n * what makes this work; non-rendering scrapers (WhatsApp, Slack) only ever see\n * index.html's static tags, which is why those stay in place as the fallback.\n *\n * The href is resolved against `window.location.origin` rather than hardcoded, so\n * a preview deployment or the VPS host stays self-referential instead of pointing\n * every copy of the app at production.\n *\n * The cleanup matters for the same reason as in {@link useNoIndex}: an SPA\n * navigation from the landing page into the app must not leave the landing's\n * canonical (or title) behind on an app page.\n *\n * 0.31 (§4.3): keksdose's `usePageSeo` (shared/hooks/use-page-seo.ts), verbatim. The\n * landing calls it with `canonicalPath: \"/\"` on both `/` and `/welcome`, so the two URLs\n * of one page are one result; there are no per-language URLs and no hreflang.\n */\nexport function usePageSeo(seo: PageSeo): void {\n usePageSeoIf(seo);\n}\n\n/**\n * @internal {@link usePageSeo}, or nothing for `null` — for a component that declares a\n * page only sometimes (`PublicPage`'s `seo`): hooks cannot be called conditionally, so the\n * switch is the argument, as `useNoIndex(active)` has it.\n */\nexport function usePageSeoIf(seo: PageSeo | null): void {\n const canonicalPath = seo?.canonicalPath;\n const title = seo?.title;\n const description = seo?.description;\n const jsonLd = seo?.jsonLd;\n useEffect(() => {\n if (canonicalPath === undefined) return;\n const link = document.createElement(\"link\");\n link.rel = \"canonical\";\n link.href = new URL(canonicalPath, window.location.origin).toString();\n document.head.appendChild(link);\n\n const previousTitle = document.title;\n if (title) document.title = title;\n\n // The description tag ships in index.html, so upsert rather than append —\n // two `meta[name=description]` tags would be a coin flip for the scraper.\n const meta = description ? document.head.querySelector<HTMLMetaElement>('meta[name=\"description\"]') : null;\n const previousDescription = meta?.content;\n if (meta && description) meta.content = description;\n\n let ld: HTMLScriptElement | null = null;\n if (jsonLd) {\n ld = document.createElement(\"script\");\n ld.type = \"application/ld+json\";\n ld.textContent = JSON.stringify(jsonLd);\n document.head.appendChild(ld);\n }\n\n return () => {\n link.remove();\n ld?.remove();\n if (title) document.title = previousTitle;\n if (meta && previousDescription !== undefined) meta.content = previousDescription;\n };\n // `jsonLd` is built inline by the caller, so it is a new object every render and\n // cannot be a dependency — it is derived from `title`/`description`, which are.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [canonicalPath, title, description]);\n}\n\n/* ── The SEO test's helpers ──────────────────────────────────────────────── */\n\n/** What a search result shows of the landing, in one language. */\nexport interface SeoCopy {\n /** The page title (`landing.seo.title`): \"Keksdose — envelope budgeting\". */\n title: string;\n /** The meta description. */\n description: string;\n /** The product name the title carries, before or after its tagline. */\n brand: string;\n}\n\n/** The width budgets, as characters. */\nexport interface SeoCopyLimits {\n /** Default 60: a result's title is cut around 580px. */\n maxTitle?: number;\n /** Default 155: a snippet is cut around 920px. */\n maxDescription?: number;\n}\n\nexport type SeoCopyProblemCode =\n | \"title-empty\"\n | \"description-empty\"\n | \"title-too-long\"\n | \"description-too-long\"\n | \"title-without-brand\"\n | \"description-repeats-title\";\n\n/** One thing wrong with a language's copy. `message` is for the test's failure output. */\nexport interface SeoCopyProblem {\n code: SeoCopyProblemCode;\n message: string;\n}\n\n/** Characters as a reader counts them: code points, so an emoji or a rare CJK character\n * is one, not two UTF-16 units. */\nconst length = (text: string) => Array.from(text).length;\n\nconst escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n\n/** The separators a title puts between the brand and its tagline. */\nconst SEPARATOR = \"\\\\s*[—–\\\\-|:·•]\\\\s*\";\n\n/** The title without the brand and its separator, or `null` when the brand is in\n * neither place. */\nfunction taglineOf(title: string, brand: string): string | null {\n const name = escapeRegExp(brand.trim());\n const leading = new RegExp(`^${name}${SEPARATOR}`, \"i\");\n if (leading.test(title)) return title.replace(leading, \"\").trim();\n const trailing = new RegExp(`${SEPARATOR}${name}$`, \"i\");\n if (trailing.test(title)) return title.replace(trailing, \"\").trim();\n return null;\n}\n\n/**\n * What is wrong with one language's search-result copy (§4.3) — an empty list when\n * nothing is. keksdose's landing-seo.test.ts (live #305), made reusable:\n *\n * - the title fits `maxTitle` characters (60) and the description `maxDescription`\n * (155) — conservative stand-ins for Google's pixel budgets; German is the language\n * most likely to blow through them, and what gets cut is always the end;\n * - the title carries the brand, before or after its tagline (\"Keksdose — …\");\n * - the description does not open with the title's tagline again — a snippet that\n * repeats the line above it spends its first forty characters saying nothing new.\n *\n * ```ts\n * for (const [locale, bundle] of Object.entries(LOCALES)) {\n * it(`fits ${locale} into a search result`, () => {\n * expect(seoCopyProblems({ ...bundle.landing.seo, brand: \"Keksdose\" })).toEqual([]);\n * });\n * }\n * ```\n */\nexport function seoCopyProblems(copy: SeoCopy, limits: SeoCopyLimits = {}): SeoCopyProblem[] {\n const { maxTitle = 60, maxDescription = 155 } = limits;\n const title = copy.title.replace(/\\s+/g, \" \").trim();\n const description = copy.description.replace(/\\s+/g, \" \").trim();\n const problems: SeoCopyProblem[] = [];\n if (!title) problems.push({ code: \"title-empty\", message: \"the title is empty\" });\n if (!description) problems.push({ code: \"description-empty\", message: \"the description is empty\" });\n if (length(title) > maxTitle) {\n problems.push({\n code: \"title-too-long\",\n message: `the title is ${length(title)} characters, over ${maxTitle}: \"${title}\"`,\n });\n }\n if (length(description) > maxDescription) {\n problems.push({\n code: \"description-too-long\",\n message: `the description is ${length(description)} characters, over ${maxDescription}: \"${description}\"`,\n });\n }\n if (!title) return problems;\n const tagline = taglineOf(title, copy.brand);\n if (tagline === null) {\n problems.push({\n code: \"title-without-brand\",\n message: `the title does not start or end with \"${copy.brand}\" and a separator: \"${title}\"`,\n });\n }\n const repeated = (tagline ?? title).toLowerCase();\n if (repeated && description.toLowerCase().startsWith(repeated)) {\n problems.push({\n code: \"description-repeats-title\",\n message: `the description opens with the title's tagline \"${tagline ?? title}\"`,\n });\n }\n return problems;\n}\n\n/** The five named references and the numeric ones — enough for a meta tag's text. */\nfunction decodeEntities(text: string): string {\n const named: Record<string, string> = { amp: \"&\", lt: \"<\", gt: \">\", quot: '\"', apos: \"'\", nbsp: \" \" };\n return text.replace(/&(#x[\\da-f]+|#\\d+|[a-z]+);/gi, (whole, ref: string) => {\n if (ref[0] === \"#\") {\n const code = ref[1] === \"x\" || ref[1] === \"X\" ? parseInt(ref.slice(2), 16) : parseInt(ref.slice(1), 10);\n return Number.isFinite(code) ? String.fromCodePoint(code) : whole;\n }\n return named[ref.toLowerCase()] ?? whole;\n });\n}\n\n/** A tag's attributes, names lower-cased; values double-, single- or un-quoted. */\nfunction attributesOf(tag: string): Map<string, string> {\n const found = new Map<string, string>();\n for (const m of tag.matchAll(/([^\\s=/<>\"']+)\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)'|([^\\s\"'=<>`]+))/g)) {\n found.set(m[1]!.toLowerCase(), m[2] ?? m[3] ?? m[4] ?? \"\");\n }\n return found;\n}\n\n/**\n * The `content` of the first `<meta>` in `html` that `selector` picks, whitespace\n * collapsed and entities decoded — or `undefined` when there is none. For each app's\n * parity test (§4.3): `index.html`'s static description and `og:` tags must equal the\n * default language's strings, because a scraper that runs no script (WhatsApp, Slack)\n * sees only those.\n *\n * `selector` names attributes, as in CSS — `meta[name=\"description\"]`,\n * `[property=\"og:title\"]` or just `property=\"og:description\"` — and every one named must\n * match. A `RegExp` is matched against the tag's source instead (keksdose's form).\n * String parsing, not a DOM: the test runs under `@vitest-environment node`.\n *\n * ```ts\n * expect(metaContent(html, 'meta[name=\"description\"]')).toBe(de.landing.seo.description);\n * ```\n */\nexport function metaContent(html: string, selector: string | RegExp): string | undefined {\n const wanted =\n typeof selector === \"string\"\n ? Array.from(selector.matchAll(/([\\w:.-]+)\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)')/g), (m) => [\n m[1]!.toLowerCase(),\n m[2] ?? m[3] ?? \"\",\n ])\n : null;\n if (wanted !== null && wanted.length === 0) {\n throw new Error(`metaContent: the selector names no attribute: ${selector as string}`);\n }\n const pattern = selector instanceof RegExp ? new RegExp(selector.source, selector.flags.replace(\"g\", \"\")) : null;\n for (const tag of html.match(/<meta\\b[^>]*>/gi) ?? []) {\n const attrs = attributesOf(tag);\n const matches = pattern ? pattern.test(tag) : wanted!.every(([name, value]) => attrs.get(name!) === value);\n if (!matches) continue;\n const content = attrs.get(\"content\");\n return content === undefined ? undefined : decodeEntities(content).replace(/\\s+/g, \" \").trim();\n }\n return undefined;\n}\n"],"mappings":"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","names":[]}
|
package/dist/lib/calc.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/lib/calc.ts"],"sourcesContent":["/**\n * A tiny, dependency-free arithmetic evaluator for the numeric input fields.\n * It lets users type a calculation (e.g. \"12.50+3.20\" or \"100/3\") straight into\n * an amount field, or build one in the calculator popover, and have it resolve\n * to a number. Deliberately NOT backed by `eval`/`Function` — it's a hand-rolled\n * tokenizer + recursive-descent parser over a closed grammar (digits, decimals,\n * `+ - * /`, unary minus, parentheses), so no arbitrary code can run.\n */\n\ntype Token =\n | { kind: \"num\"; value: number }\n | { kind: \"op\"; value: \"+\" | \"-\" | \"*\" | \"/\" }\n | { kind: \"lparen\" }\n | { kind: \"rparen\" };\n\nfunction tokenize(input: string): Token[] | null {\n const tokens: Token[] = [];\n let i = 0;\n while (i < input.length) {\n const ch = input[i];\n if (ch === \" \") {\n i++;\n continue;\n }\n if (ch === \"+\" || ch === \"-\" || ch === \"*\" || ch === \"/\") {\n tokens.push({ kind: \"op\", value: ch });\n i++;\n continue;\n }\n if (ch === \"(\") {\n tokens.push({ kind: \"lparen\" });\n i++;\n continue;\n }\n if (ch === \")\") {\n tokens.push({ kind: \"rparen\" });\n i++;\n continue;\n }\n if ((ch >= \"0\" && ch <= \"9\") || ch === \".\") {\n let j = i;\n let dotSeen = false;\n while (j < input.length && ((input[j] >= \"0\" && input[j] <= \"9\") || input[j] === \".\")) {\n if (input[j] === \".\") {\n if (dotSeen) return null; // two dots in one number → invalid\n dotSeen = true;\n }\n j++;\n }\n const num = Number(input.slice(i, j));\n if (!Number.isFinite(num)) return null;\n tokens.push({ kind: \"num\", value: num });\n i = j;\n continue;\n }\n return null; // unknown character\n }\n return tokens;\n}\n\n/**\n * Recursive-descent parse honouring `* /` over `+ -` and left-associativity.\n * Returns `null` for any malformed input (the `null` sentinel is safe because a\n * valid result of 0 is distinct from it). Division by zero is treated as invalid.\n *\n * expr := term (('+' | '-') term)*\n * term := factor (('*' | '/') factor)*\n * factor := ('+' | '-') factor | number | '(' expr ')'\n */\nfunction parse(tokens: Token[]): number | null {\n let pos = 0;\n\n function parseExpr(): number | null {\n let value = parseTerm();\n if (value === null) return null;\n while (pos < tokens.length) {\n const tok = tokens[pos];\n if (tok.kind === \"op\" && (tok.value === \"+\" || tok.value === \"-\")) {\n pos++;\n const rhs = parseTerm();\n if (rhs === null) return null;\n value = tok.value === \"+\" ? value + rhs : value - rhs;\n } else break;\n }\n return value;\n }\n\n function parseTerm(): number | null {\n let value = parseFactor();\n if (value === null) return null;\n while (pos < tokens.length) {\n const tok = tokens[pos];\n if (tok.kind === \"op\" && (tok.value === \"*\" || tok.value === \"/\")) {\n pos++;\n const rhs = parseFactor();\n if (rhs === null) return null;\n if (tok.value === \"/\") {\n if (rhs === 0) return null;\n value = value / rhs;\n } else {\n value = value * rhs;\n }\n } else break;\n }\n return value;\n }\n\n function parseFactor(): number | null {\n const tok = tokens[pos];\n if (!tok) return null;\n if (tok.kind === \"op\" && (tok.value === \"+\" || tok.value === \"-\")) {\n pos++;\n const operand = parseFactor();\n if (operand === null) return null;\n return tok.value === \"-\" ? -operand : operand;\n }\n if (tok.kind === \"lparen\") {\n pos++;\n const inner = parseExpr();\n if (inner === null) return null;\n if (tokens[pos]?.kind !== \"rparen\") return null;\n pos++;\n return inner;\n }\n if (tok.kind === \"num\") {\n pos++;\n return tok.value;\n }\n return null;\n }\n\n const result = parseExpr();\n if (result === null) return null;\n if (pos !== tokens.length) return null; // leftover tokens → invalid\n return result;\n}\n\n/** Evaluate an arithmetic string, returning `null` if it isn't a valid sum.\n * Accepts the on-screen operator glyphs (`× ÷ −`) and comma decimals too. */\nexport function evaluateExpression(expr: string): number | null {\n const normalized = expr\n .replace(/×/g, \"*\")\n .replace(/÷/g, \"/\")\n .replace(/[−–—]/g, \"-\") // unicode minus / en / em dash → ascii\n .replace(/,/g, \".\")\n .trim();\n if (normalized === \"\") return null;\n const tokens = tokenize(normalized);\n if (tokens === null || tokens.length === 0) return null;\n const result = parse(tokens);\n if (result === null || !Number.isFinite(result)) return null;\n return result;\n}\n\n/** True when `s` carries arithmetic beyond a single leading minus — i.e. a plain\n * number (or a lone negative like \"-5\") is NOT an expression, but \"12+5\" is.\n * Used to decide whether a field's text should be evaluated on commit.\n *\n * The operator set is the one {@link evaluateExpression} accepts, on-screen glyphs\n * included: the calculator keypad inserts `× ÷ −`, and a `−` that this said was not\n * arithmetic went on to be read as a plain number — where the digits-only fallback\n * in {@link commitExpression} silently ate it, turning \"-42−−50\" (= 8) into a bare\n * 8 with no record that a sum had happened at all. */\nexport function looksLikeExpression(s: string): boolean {\n const t = s.trim();\n if (t === \"\") return false;\n if (/^-?\\d*[.,]?\\d*$/.test(t)) return false; // plain (optionally negative) decimal\n return /[+\\-*/×÷−–—()]/.test(t.replace(/^-/, \"\"));\n}\n\n/** Render an evaluated number back to a field-friendly string, trimming binary\n * float noise (so 0.1 + 0.2 reads \"0.3\", not \"0.30000000000000004\").\n *\n * FIXED notation, always, and that is the whole point of the function rather than a\n * detail of it. This used to end in `parseFloat(n.toFixed(10)).toString()`, and\n * `Number.prototype.toString` switches to EXPONENT notation below 1e-6 and at or\n * above 1e21 — an alphabet containing `e`, `+` and `-`. Neither function that reads a\n * field's text back accepts it: {@link tokenize} rejects `e` as an unknown character\n * and {@link sanitizeLive} deletes it. So {@link commitExpression} fell through to\n * its digits-only cleanup, which stripped the `e` and CONCATENATED the mantissa to\n * the exponent. A field committed its own output as a different number:\n *\n * \"1/10000000\" -> \"1e-7\" -> \"17\" (AmountInput: \"6\")\n * \"999999999999*999999999999\" -> \"9.99999999998e+23\" -> \"9.9999999999823\"\n *\n * Both controls commit on blur AND on Enter, so Enter-then-click-away was enough on\n * its own, it was silent, and it was in a money field.\n *\n * The invariant, which the test asserts directly rather than by example:\n * **`commitExpression(formatResult(n))` equals `formatResult(n)` for every finite\n * `n`.** Anything rendered here has to survive being read back. */\nexport function formatResult(n: number): string {\n if (!Number.isFinite(n)) return \"\";\n // At or above 1e21 `toFixed` gives up and hands back `toString`'s exponent form —\n // but every double that large is an integer, so BigInt renders it exactly.\n if (Math.abs(n) >= 1e21) return BigInt(n).toString();\n return trimTrailingZeros(n.toFixed(10));\n}\n\n/** \"15.7000000000\" -> \"15.7\", \"0.0000000000\" -> \"0\". The ten decimals are where the\n * binary float noise gets rounded off; this is only about not showing the padding. */\nfunction trimTrailingZeros(fixed: string): string {\n if (!fixed.includes(\".\")) return fixed;\n const trimmed = fixed.replace(/0+$/, \"\").replace(/\\.$/, \"\");\n // `toFixed` keeps the sign on a magnitude that rounded away to nothing\n // (\"-0.0000000000\"); `toString` did not, and \"-0\" in an amount field is noise.\n return trimmed === \"-0\" || trimmed === \"\" ? \"0\" : trimmed;\n}\n\n/** The single \"on blur / Enter\" transform a numeric field applies: evaluate the\n * text if it's an expression, otherwise normalise it to a bare number string\n * (comma → dot, dropping stray characters, non-leading minuses and anything after\n * a second decimal point).\n *\n * The last clause is the backstop for Keksdose live #201. A field the user typed in\n * cannot reach here malformed any more — {@link sanitizeLive} runs on every change,\n * including a paste — but a value handed in from elsewhere (a recurring rule, an\n * import, a test) still can, and \"1.2.3\" is not an expression (no operator), so it\n * used to fall straight through unchanged and be read as NaN. Here the text is CUT\n * at the second dot (\"1.2\") rather than joined (\"1.23\"): with no keystroke to\n * ignore there is nothing to say which reading was meant, and the shorter one\n * cannot silently invent a bigger number. */\nexport function commitExpression(raw: string): string {\n const s = raw.trim();\n if (looksLikeExpression(s)) {\n const n = evaluateExpression(s);\n if (n !== null) return formatResult(n);\n // Unparseable (e.g. a half-typed \"12+\"): fall through to a digits-only clean.\n }\n // Global, like sanitizeLive's: these two are the two halves of one rule and must\n // not appear to state it differently. (The `[^0-9.-]` strip on the same line\n // already ate whatever commas a non-global replace missed, so this changes no\n // behaviour — it removes the need for the next reader to run it to find that out.)\n const cleaned = s.replace(/,/g, \".\").replace(/[^0-9.-]/g, \"\").replace(/(?!^)-/g, \"\");\n return cleaned.replace(/^(-?\\d*\\.?\\d*).*$/, \"$1\");\n}\n\n/** The live, per-keystroke transform a numeric field applies: comma → dot and\n * drop anything that isn't a digit, decimal point, arithmetic operator (incl.\n * the on-screen `× ÷` glyphs) or paren — so a typed calculation survives\n * keystroke-by-keystroke but stray characters can't be entered. A lone leading\n * `-` is kept — in a plain field it is simply a negative amount; in a field whose\n * caller owns the sign it is read off again by {@link splitLeadingSign}. The text\n * is resolved to a plain number on blur/Enter via {@link commitExpression}. */\nexport function sanitizeLive(raw: string): string {\n return dropExtraDots(raw.replace(/,/g, \".\").replace(/[^0-9.+\\-*/×÷()]/g, \"\"));\n}\n\n/**\n * At most ONE decimal point per number (Keksdose live #201).\n *\n * Per NUMBER, not per string: \"1.5+2.5\" is a legitimate calculation with two dots\n * in two operands, so the counter resets at every operator and parenthesis.\n *\n * A second dot inside one operand is a **no-op keystroke** — the digits after it\n * keep landing, so \"1.2\" + \".\" + \"3\" is \"1.23\". That is what every calculator does\n * with a repeated decimal point, and it is the only option that does not make the\n * field feel broken (\"my 3 disappeared\"). This runs on paste as well as on typing,\n * so the malformed value never reaches the rest of the app: everything downstream\n * reads the field with `Number()`, NaN became a silent 0, and on the create form\n * that 0 was painted with the income green — a figure that was not a number at all\n * asserting money was coming in.\n */\nfunction dropExtraDots(s: string): string {\n let out = \"\";\n let dotInNumber = false;\n for (const ch of s) {\n if (ch === \".\") {\n if (dotInNumber) continue;\n dotInNumber = true;\n } else if (ch < \"0\" || ch > \"9\") {\n // An operator or a paren ends the current operand.\n dotInNumber = false;\n }\n out += ch;\n }\n return out;\n}\n\n/** A bare, unsigned amount: digits with at most one decimal separator and nothing\n * else — \"42\", \"42.\", \".5\", \"0,5\". NOT \"\" (an empty field has no figure to put a\n * sign in front of), not \"-42\", not \"12+5\". It is the only shape a DISPLAYED sign\n * prefix makes sense in front of; an expression carries its own arithmetic and has\n * to survive keystroke-for-keystroke. */\nexport function isBareAmount(s: string): boolean {\n const t = s.trim();\n return t !== \"\" && /^\\d*[.,]?\\d*$/.test(t);\n}\n\n/**\n * Split a LEADING sign off a plain signed number (Keksdose live #201 rework):\n * `\"-42\"` → `{ sign: \"-\", rest: \"42\" }`, everything else → `{ sign: \"\", rest: text }`.\n *\n * Only in front of a bare amount, because a minus with arithmetic after it IS\n * arithmetic: \"12-30\" is a subtraction, and \"-12+5\" is -7, not -(12+5). Those come\n * back untouched and are resolved by {@link commitExpression} on blur — at which\n * point the RESULT is a plain signed number and this function does get to speak.\n *\n * A lone \"-\" splits (rest \"\"): the sign is a statement about the direction the\n * moment it is typed, before any digit exists.\n */\nexport function splitLeadingSign(text: string): { sign: \"\" | \"-\" | \"+\"; rest: string } {\n const trimmed = text.trimStart();\n const sign = trimmed.charAt(0);\n if (sign !== \"-\" && sign !== \"+\") return { sign: \"\", rest: text };\n const rest = trimmed.slice(1);\n if (rest !== \"\" && !isBareAmount(rest)) return { sign: \"\", rest: text };\n return { sign, rest };\n}\n"],"mappings":";AAeA,SAAS,SAAS,OAA+B;AAC/C,QAAM,SAAkB,CAAC;AACzB,MAAI,IAAI;AACR,SAAO,IAAI,MAAM,QAAQ;AACvB,UAAM,KAAK,MAAM,CAAC;AAClB,QAAI,OAAO,KAAK;AACd;AACA;AAAA,IACF;AACA,QAAI,OAAO,OAAO,OAAO,OAAO,OAAO,OAAO,OAAO,KAAK;AACxD,aAAO,KAAK,EAAE,MAAM,MAAM,OAAO,GAAG,CAAC;AACrC;AACA;AAAA,IACF;AACA,QAAI,OAAO,KAAK;AACd,aAAO,KAAK,EAAE,MAAM,SAAS,CAAC;AAC9B;AACA;AAAA,IACF;AACA,QAAI,OAAO,KAAK;AACd,aAAO,KAAK,EAAE,MAAM,SAAS,CAAC;AAC9B;AACA;AAAA,IACF;AACA,QAAK,MAAM,OAAO,MAAM,OAAQ,OAAO,KAAK;AAC1C,UAAI,IAAI;AACR,UAAI,UAAU;AACd,aAAO,IAAI,MAAM,WAAY,MAAM,CAAC,KAAK,OAAO,MAAM,CAAC,KAAK,OAAQ,MAAM,CAAC,MAAM,MAAM;AACrF,YAAI,MAAM,CAAC,MAAM,KAAK;AACpB,cAAI,QAAS,QAAO;AACpB,oBAAU;AAAA,QACZ;AACA;AAAA,MACF;AACA,YAAM,MAAM,OAAO,MAAM,MAAM,GAAG,CAAC,CAAC;AACpC,UAAI,CAAC,OAAO,SAAS,GAAG,EAAG,QAAO;AAClC,aAAO,KAAK,EAAE,MAAM,OAAO,OAAO,IAAI,CAAC;AACvC,UAAI;AACJ;AAAA,IACF;AACA,WAAO;AAAA,EACT;AACA,SAAO;AACT;AAWA,SAAS,MAAM,QAAgC;AAC7C,MAAI,MAAM;AAEV,WAAS,YAA2B;AAClC,QAAI,QAAQ,UAAU;AACtB,QAAI,UAAU,KAAM,QAAO;AAC3B,WAAO,MAAM,OAAO,QAAQ;AAC1B,YAAM,MAAM,OAAO,GAAG;AACtB,UAAI,IAAI,SAAS,SAAS,IAAI,UAAU,OAAO,IAAI,UAAU,MAAM;AACjE;AACA,cAAM,MAAM,UAAU;AACtB,YAAI,QAAQ,KAAM,QAAO;AACzB,gBAAQ,IAAI,UAAU,MAAM,QAAQ,MAAM,QAAQ;AAAA,MACpD,MAAO;AAAA,IACT;AACA,WAAO;AAAA,EACT;AAEA,WAAS,YAA2B;AAClC,QAAI,QAAQ,YAAY;AACxB,QAAI,UAAU,KAAM,QAAO;AAC3B,WAAO,MAAM,OAAO,QAAQ;AAC1B,YAAM,MAAM,OAAO,GAAG;AACtB,UAAI,IAAI,SAAS,SAAS,IAAI,UAAU,OAAO,IAAI,UAAU,MAAM;AACjE;AACA,cAAM,MAAM,YAAY;AACxB,YAAI,QAAQ,KAAM,QAAO;AACzB,YAAI,IAAI,UAAU,KAAK;AACrB,cAAI,QAAQ,EAAG,QAAO;AACtB,kBAAQ,QAAQ;AAAA,QAClB,OAAO;AACL,kBAAQ,QAAQ;AAAA,QAClB;AAAA,MACF,MAAO;AAAA,IACT;AACA,WAAO;AAAA,EACT;AAEA,WAAS,cAA6B;AACpC,UAAM,MAAM,OAAO,GAAG;AACtB,QAAI,CAAC,IAAK,QAAO;AACjB,QAAI,IAAI,SAAS,SAAS,IAAI,UAAU,OAAO,IAAI,UAAU,MAAM;AACjE;AACA,YAAM,UAAU,YAAY;AAC5B,UAAI,YAAY,KAAM,QAAO;AAC7B,aAAO,IAAI,UAAU,MAAM,CAAC,UAAU;AAAA,IACxC;AACA,QAAI,IAAI,SAAS,UAAU;AACzB;AACA,YAAM,QAAQ,UAAU;AACxB,UAAI,UAAU,KAAM,QAAO;AAC3B,UAAI,OAAO,GAAG,GAAG,SAAS,SAAU,QAAO;AAC3C;AACA,aAAO;AAAA,IACT;AACA,QAAI,IAAI,SAAS,OAAO;AACtB;AACA,aAAO,IAAI;AAAA,IACb;AACA,WAAO;AAAA,EACT;AAEA,QAAM,SAAS,UAAU;AACzB,MAAI,WAAW,KAAM,QAAO;AAC5B,MAAI,QAAQ,OAAO,OAAQ,QAAO;AAClC,SAAO;AACT;AAIO,SAAS,mBAAmB,MAA6B;AAC9D,QAAM,aAAa,KAChB,QAAQ,MAAM,GAAG,EACjB,QAAQ,MAAM,GAAG,EACjB,QAAQ,UAAU,GAAG,EACrB,QAAQ,MAAM,GAAG,EACjB,KAAK;AACR,MAAI,eAAe,GAAI,QAAO;AAC9B,QAAM,SAAS,SAAS,UAAU;AAClC,MAAI,WAAW,QAAQ,OAAO,WAAW,EAAG,QAAO;AACnD,QAAM,SAAS,MAAM,MAAM;AAC3B,MAAI,WAAW,QAAQ,CAAC,OAAO,SAAS,MAAM,EAAG,QAAO;AACxD,SAAO;AACT;AAWO,SAAS,oBAAoB,GAAoB;AACtD,QAAM,IAAI,EAAE,KAAK;AACjB,MAAI,MAAM,GAAI,QAAO;AACrB,MAAI,kBAAkB,KAAK,CAAC,EAAG,QAAO;AACtC,SAAO,iBAAiB,KAAK,EAAE,QAAQ,MAAM,EAAE,CAAC;AAClD;AAuBO,SAAS,aAAa,GAAmB;AAC9C,MAAI,CAAC,OAAO,SAAS,CAAC,EAAG,QAAO;AAGhC,MAAI,KAAK,IAAI,CAAC,KAAK,KAAM,QAAO,OAAO,CAAC,EAAE,SAAS;AACnD,SAAO,kBAAkB,EAAE,QAAQ,EAAE,CAAC;AACxC;AAIA,SAAS,kBAAkB,OAAuB;AAChD,MAAI,CAAC,MAAM,SAAS,GAAG,EAAG,QAAO;AACjC,QAAM,UAAU,MAAM,QAAQ,OAAO,EAAE,EAAE,QAAQ,OAAO,EAAE;AAG1D,SAAO,YAAY,QAAQ,YAAY,KAAK,MAAM;AACpD;AAeO,SAAS,iBAAiB,KAAqB;AACpD,QAAM,IAAI,IAAI,KAAK;AACnB,MAAI,oBAAoB,CAAC,GAAG;AAC1B,UAAM,IAAI,mBAAmB,CAAC;AAC9B,QAAI,MAAM,KAAM,QAAO,aAAa,CAAC;AAAA,EAEvC;AAKA,QAAM,UAAU,EAAE,QAAQ,MAAM,GAAG,EAAE,QAAQ,aAAa,EAAE,EAAE,QAAQ,WAAW,EAAE;AACnF,SAAO,QAAQ,QAAQ,qBAAqB,IAAI;AAClD;AASO,SAAS,aAAa,KAAqB;AAChD,SAAO,cAAc,IAAI,QAAQ,MAAM,GAAG,EAAE,QAAQ,qBAAqB,EAAE,CAAC;AAC9E;AAiBA,SAAS,cAAc,GAAmB;AACxC,MAAI,MAAM;AACV,MAAI,cAAc;AAClB,aAAW,MAAM,GAAG;AAClB,QAAI,OAAO,KAAK;AACd,UAAI,YAAa;AACjB,oBAAc;AAAA,IAChB,WAAW,KAAK,OAAO,KAAK,KAAK;AAE/B,oBAAc;AAAA,IAChB;AACA,WAAO;AAAA,EACT;AACA,SAAO;AACT;AAOO,SAAS,aAAa,GAAoB;AAC/C,QAAM,IAAI,EAAE,KAAK;AACjB,SAAO,MAAM,MAAM,gBAAgB,KAAK,CAAC;AAC3C;AAcO,SAAS,iBAAiB,MAAsD;AACrF,QAAM,UAAU,KAAK,UAAU;AAC/B,QAAM,OAAO,QAAQ,OAAO,CAAC;AAC7B,MAAI,SAAS,OAAO,SAAS,IAAK,QAAO,EAAE,MAAM,IAAI,MAAM,KAAK;AAChE,QAAM,OAAO,QAAQ,MAAM,CAAC;AAC5B,MAAI,SAAS,MAAM,CAAC,aAAa,IAAI,EAAG,QAAO,EAAE,MAAM,IAAI,MAAM,KAAK;AACtE,SAAO,EAAE,MAAM,KAAK;AACtB;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/lib/calc.ts"],"sourcesContent":["/**\n * A tiny, dependency-free arithmetic evaluator for the numeric input fields.\n * It lets users type a calculation (e.g. \"12.50+3.20\" or \"100/3\") straight into\n * an amount field, or build one in the calculator popover, and have it resolve\n * to a number. Deliberately NOT backed by `eval`/`Function` — it's a hand-rolled\n * tokenizer + recursive-descent parser over a closed grammar (digits, decimals,\n * `+ - * /`, unary minus, parentheses), so no arbitrary code can run.\n */\n\ntype Token =\n | { kind: \"num\"; value: number }\n | { kind: \"op\"; value: \"+\" | \"-\" | \"*\" | \"/\" }\n | { kind: \"lparen\" }\n | { kind: \"rparen\" };\n\nfunction tokenize(input: string): Token[] | null {\n const tokens: Token[] = [];\n let i = 0;\n while (i < input.length) {\n const ch = input[i];\n if (ch === \" \") {\n i++;\n continue;\n }\n if (ch === \"+\" || ch === \"-\" || ch === \"*\" || ch === \"/\") {\n tokens.push({ kind: \"op\", value: ch });\n i++;\n continue;\n }\n if (ch === \"(\") {\n tokens.push({ kind: \"lparen\" });\n i++;\n continue;\n }\n if (ch === \")\") {\n tokens.push({ kind: \"rparen\" });\n i++;\n continue;\n }\n if ((ch >= \"0\" && ch <= \"9\") || ch === \".\") {\n let j = i;\n let dotSeen = false;\n while (j < input.length && ((input[j] >= \"0\" && input[j] <= \"9\") || input[j] === \".\")) {\n if (input[j] === \".\") {\n if (dotSeen) return null; // two dots in one number → invalid\n dotSeen = true;\n }\n j++;\n }\n const num = Number(input.slice(i, j));\n if (!Number.isFinite(num)) return null;\n tokens.push({ kind: \"num\", value: num });\n i = j;\n continue;\n }\n return null; // unknown character\n }\n return tokens;\n}\n\n/**\n * Recursive-descent parse honouring `* /` over `+ -` and left-associativity.\n * Returns `null` for any malformed input (the `null` sentinel is safe because a\n * valid result of 0 is distinct from it). Division by zero is treated as invalid.\n *\n * expr := term (('+' | '-') term)*\n * term := factor (('*' | '/') factor)*\n * factor := ('+' | '-') factor | number | '(' expr ')'\n */\nfunction parse(tokens: Token[]): number | null {\n let pos = 0;\n\n function parseExpr(): number | null {\n let value = parseTerm();\n if (value === null) return null;\n while (pos < tokens.length) {\n const tok = tokens[pos];\n if (tok.kind === \"op\" && (tok.value === \"+\" || tok.value === \"-\")) {\n pos++;\n const rhs = parseTerm();\n if (rhs === null) return null;\n value = tok.value === \"+\" ? value + rhs : value - rhs;\n } else break;\n }\n return value;\n }\n\n function parseTerm(): number | null {\n let value = parseFactor();\n if (value === null) return null;\n while (pos < tokens.length) {\n const tok = tokens[pos];\n if (tok.kind === \"op\" && (tok.value === \"*\" || tok.value === \"/\")) {\n pos++;\n const rhs = parseFactor();\n if (rhs === null) return null;\n if (tok.value === \"/\") {\n if (rhs === 0) return null;\n value = value / rhs;\n } else {\n value = value * rhs;\n }\n } else break;\n }\n return value;\n }\n\n function parseFactor(): number | null {\n const tok = tokens[pos];\n if (!tok) return null;\n if (tok.kind === \"op\" && (tok.value === \"+\" || tok.value === \"-\")) {\n pos++;\n const operand = parseFactor();\n if (operand === null) return null;\n return tok.value === \"-\" ? -operand : operand;\n }\n if (tok.kind === \"lparen\") {\n pos++;\n const inner = parseExpr();\n if (inner === null) return null;\n if (tokens[pos]?.kind !== \"rparen\") return null;\n pos++;\n return inner;\n }\n if (tok.kind === \"num\") {\n pos++;\n return tok.value;\n }\n return null;\n }\n\n const result = parseExpr();\n if (result === null) return null;\n if (pos !== tokens.length) return null; // leftover tokens → invalid\n return result;\n}\n\n/** Evaluate an arithmetic string, returning `null` if it isn't a valid sum.\n * Accepts the on-screen operator glyphs (`× ÷ −`) and comma decimals too. */\nexport function evaluateExpression(expr: string): number | null {\n const normalized = expr\n .replace(/×/g, \"*\")\n .replace(/÷/g, \"/\")\n .replace(/[−–—]/g, \"-\") // unicode minus / en / em dash → ascii\n .replace(/,/g, \".\")\n .trim();\n if (normalized === \"\") return null;\n const tokens = tokenize(normalized);\n if (tokens === null || tokens.length === 0) return null;\n const result = parse(tokens);\n if (result === null || !Number.isFinite(result)) return null;\n return result;\n}\n\n/** True when `s` carries arithmetic beyond a single leading minus — i.e. a plain\n * number (or a lone negative like \"-5\") is NOT an expression, but \"12+5\" is.\n * Used to decide whether a field's text should be evaluated on commit.\n *\n * The operator set is the one {@link evaluateExpression} accepts, on-screen glyphs\n * included: the calculator keypad inserts `× ÷ −`, and a `−` that this said was not\n * arithmetic went on to be read as a plain number — where the digits-only fallback\n * in {@link commitExpression} silently ate it, turning \"-42−−50\" (= 8) into a bare\n * 8 with no record that a sum had happened at all. */\nexport function looksLikeExpression(s: string): boolean {\n const t = s.trim();\n if (t === \"\") return false;\n if (/^-?\\d*[.,]?\\d*$/.test(t)) return false; // plain (optionally negative) decimal\n return /[+\\-*/×÷−–—()]/.test(t.replace(/^-/, \"\"));\n}\n\n/** Render an evaluated number back to a field-friendly string, trimming binary\n * float noise (so 0.1 + 0.2 reads \"0.3\", not \"0.30000000000000004\").\n *\n * FIXED notation, always, and that is the whole point of the function rather than a\n * detail of it. This used to end in `parseFloat(n.toFixed(10)).toString()`, and\n * `Number.prototype.toString` switches to EXPONENT notation below 1e-6 and at or\n * above 1e21 — an alphabet containing `e`, `+` and `-`. Neither function that reads a\n * field's text back accepts it: {@link tokenize} rejects `e` as an unknown character\n * and {@link sanitizeLive} deletes it. So {@link commitExpression} fell through to\n * its digits-only cleanup, which stripped the `e` and CONCATENATED the mantissa to\n * the exponent. A field committed its own output as a different number:\n *\n * \"1/10000000\" -> \"1e-7\" -> \"17\" (AmountInput: \"6\")\n * \"999999999999*999999999999\" -> \"9.99999999998e+23\" -> \"9.9999999999823\"\n *\n * Both controls commit on blur AND on Enter, so Enter-then-click-away was enough on\n * its own, it was silent, and it was in a money field.\n *\n * The invariant, which the test asserts directly rather than by example:\n * **`commitExpression(formatResult(n))` equals `formatResult(n)` for every finite\n * `n`.** Anything rendered here has to survive being read back. */\nexport function formatResult(n: number): string {\n if (!Number.isFinite(n)) return \"\";\n // At or above 1e21 `toFixed` gives up and hands back `toString`'s exponent form —\n // but every double that large is an integer, so BigInt renders it exactly.\n if (Math.abs(n) >= 1e21) return BigInt(n).toString();\n return trimTrailingZeros(n.toFixed(10));\n}\n\n/** \"15.7000000000\" -> \"15.7\", \"0.0000000000\" -> \"0\". The ten decimals are where the\n * binary float noise gets rounded off; this is only about not showing the padding. */\nfunction trimTrailingZeros(fixed: string): string {\n if (!fixed.includes(\".\")) return fixed;\n const trimmed = fixed.replace(/0+$/, \"\").replace(/\\.$/, \"\");\n // `toFixed` keeps the sign on a magnitude that rounded away to nothing\n // (\"-0.0000000000\"); `toString` did not, and \"-0\" in an amount field is noise.\n return trimmed === \"-0\" || trimmed === \"\" ? \"0\" : trimmed;\n}\n\n/** The single \"on blur / Enter\" transform a numeric field applies: evaluate the\n * text if it's an expression, otherwise normalise it to a bare number string\n * (comma → dot, dropping stray characters, non-leading minuses and anything after\n * a second decimal point).\n *\n * The last clause is the backstop for Keksdose live #201. A field the user typed in\n * cannot reach here malformed any more — {@link sanitizeLive} runs on every change,\n * including a paste — but a value handed in from elsewhere (a recurring rule, an\n * import, a test) still can, and \"1.2.3\" is not an expression (no operator), so it\n * used to fall straight through unchanged and be read as NaN. Here the text is CUT\n * at the second dot (\"1.2\") rather than joined (\"1.23\"): with no keystroke to\n * ignore there is nothing to say which reading was meant, and the shorter one\n * cannot silently invent a bigger number. */\nexport function commitExpression(raw: string): string {\n const s = raw.trim();\n if (looksLikeExpression(s)) {\n const n = evaluateExpression(s);\n if (n !== null) return formatResult(n);\n // Unparseable (e.g. a half-typed \"12+\"): fall through to a digits-only clean.\n }\n // Global, like sanitizeLive's: these two are the two halves of one rule and must\n // not appear to state it differently. (The `[^0-9.-]` strip on the same line\n // already ate whatever commas a non-global replace missed, so this changes no\n // behaviour — it removes the need for the next reader to run it to find that out.)\n const cleaned = s\n .replace(/,/g, \".\")\n .replace(/[^0-9.-]/g, \"\")\n .replace(/(?!^)-/g, \"\");\n return cleaned.replace(/^(-?\\d*\\.?\\d*).*$/, \"$1\");\n}\n\n/** The live, per-keystroke transform a numeric field applies: comma → dot and\n * drop anything that isn't a digit, decimal point, arithmetic operator (incl.\n * the on-screen `× ÷` glyphs) or paren — so a typed calculation survives\n * keystroke-by-keystroke but stray characters can't be entered. A lone leading\n * `-` is kept — in a plain field it is simply a negative amount; in a field whose\n * caller owns the sign it is read off again by {@link splitLeadingSign}. The text\n * is resolved to a plain number on blur/Enter via {@link commitExpression}. */\nexport function sanitizeLive(raw: string): string {\n return dropExtraDots(raw.replace(/,/g, \".\").replace(/[^0-9.+\\-*/×÷()]/g, \"\"));\n}\n\n/**\n * At most ONE decimal point per number (Keksdose live #201).\n *\n * Per NUMBER, not per string: \"1.5+2.5\" is a legitimate calculation with two dots\n * in two operands, so the counter resets at every operator and parenthesis.\n *\n * A second dot inside one operand is a **no-op keystroke** — the digits after it\n * keep landing, so \"1.2\" + \".\" + \"3\" is \"1.23\". That is what every calculator does\n * with a repeated decimal point, and it is the only option that does not make the\n * field feel broken (\"my 3 disappeared\"). This runs on paste as well as on typing,\n * so the malformed value never reaches the rest of the app: everything downstream\n * reads the field with `Number()`, NaN became a silent 0, and on the create form\n * that 0 was painted with the income green — a figure that was not a number at all\n * asserting money was coming in.\n */\nfunction dropExtraDots(s: string): string {\n let out = \"\";\n let dotInNumber = false;\n for (const ch of s) {\n if (ch === \".\") {\n if (dotInNumber) continue;\n dotInNumber = true;\n } else if (ch < \"0\" || ch > \"9\") {\n // An operator or a paren ends the current operand.\n dotInNumber = false;\n }\n out += ch;\n }\n return out;\n}\n\n/** A bare, unsigned amount: digits with at most one decimal separator and nothing\n * else — \"42\", \"42.\", \".5\", \"0,5\". NOT \"\" (an empty field has no figure to put a\n * sign in front of), not \"-42\", not \"12+5\". It is the only shape a DISPLAYED sign\n * prefix makes sense in front of; an expression carries its own arithmetic and has\n * to survive keystroke-for-keystroke. */\nexport function isBareAmount(s: string): boolean {\n const t = s.trim();\n return t !== \"\" && /^\\d*[.,]?\\d*$/.test(t);\n}\n\n/**\n * Split a LEADING sign off a plain signed number (Keksdose live #201 rework):\n * `\"-42\"` → `{ sign: \"-\", rest: \"42\" }`, everything else → `{ sign: \"\", rest: text }`.\n *\n * Only in front of a bare amount, because a minus with arithmetic after it IS\n * arithmetic: \"12-30\" is a subtraction, and \"-12+5\" is -7, not -(12+5). Those come\n * back untouched and are resolved by {@link commitExpression} on blur — at which\n * point the RESULT is a plain signed number and this function does get to speak.\n *\n * A lone \"-\" splits (rest \"\"): the sign is a statement about the direction the\n * moment it is typed, before any digit exists.\n */\nexport function splitLeadingSign(text: string): { sign: \"\" | \"-\" | \"+\"; rest: string } {\n const trimmed = text.trimStart();\n const sign = trimmed.charAt(0);\n if (sign !== \"-\" && sign !== \"+\") return { sign: \"\", rest: text };\n const rest = trimmed.slice(1);\n if (rest !== \"\" && !isBareAmount(rest)) return { sign: \"\", rest: text };\n return { sign, rest };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/lib/clipboard-minus.ts"],"sourcesContent":["/**\n * The copy handler for the one minus sign (0.49 KIT-1b, Marcel's follow-up to KIT-1).\n *\n * The kit prints a negative with U+2212 (lib/minus.ts). A spreadsheet does not read that\n * as a number: a figure copied out of a kit table and pasted into Excel landed as text.\n * So while a selection inside the page is copied, every U+2212 standing directly before a\n * number — optionally after a currency code or symbol and spaces: \"−CHF 1,250.50\",\n * \"−€2,000\", \"−12%\" — becomes the ASCII \"-\" in the clipboard, in `text/plain` AND in\n * `text/html` (Excel pastes the HTML flavour, as cells). The page keeps \"−\".\n *\n * A copy with no such minus is left to the browser untouched, and so is any copy out of an\n * input, a textarea or an editable element: what the person typed is theirs.\n *\n * Installed once per document by `UiKitProvider` (`copyAsciiMinus`, default `true`) —\n * the one part every app mounts, the signed-out pages included.\n */\n\n/** U+2212, then (optionally) a currency code or symbol, then spaces, then a digit. */\nconst MINUS_BEFORE_NUMBER = /\\u2212(?=(?:[A-Z]{3}|\\p{Sc})?[\\s\\u00a0\\u202f]*\\d)/gu;\n\n/**\n * `text` with each U+2212 that stands before a number (in the sense above) as the ASCII\n * hyphen-minus, every other character as it was — for text an app hands a machine that\n * did not come from a formatter's `minus: \"ascii\"`.\n */\nexport function asciiMinusInNumbers(text: string): string {\n return text.replace(MINUS_BEFORE_NUMBER, \"-\");\n}\n\nfunction hasMinusBeforeNumber(text: string): boolean {\n MINUS_BEFORE_NUMBER.lastIndex = 0;\n const found = MINUS_BEFORE_NUMBER.test(text);\n MINUS_BEFORE_NUMBER.lastIndex = 0;\n return found;\n}\n\n/** An input, a textarea or an editable element: a copy there is the person's own text. */\nfunction isEditable(node: Node | null | undefined): boolean {\n const el = node instanceof Element ? node : node?.parentElement;\n if (!el) return false;\n if (el.closest(\"input, textarea, select\")) return true;\n return (el as HTMLElement).isContentEditable === true
|
|
1
|
+
{"version":3,"sources":["../../src/lib/clipboard-minus.ts"],"sourcesContent":["/**\n * The copy handler for the one minus sign (0.49 KIT-1b, Marcel's follow-up to KIT-1).\n *\n * The kit prints a negative with U+2212 (lib/minus.ts). A spreadsheet does not read that\n * as a number: a figure copied out of a kit table and pasted into Excel landed as text.\n * So while a selection inside the page is copied, every U+2212 standing directly before a\n * number — optionally after a currency code or symbol and spaces: \"−CHF 1,250.50\",\n * \"−€2,000\", \"−12%\" — becomes the ASCII \"-\" in the clipboard, in `text/plain` AND in\n * `text/html` (Excel pastes the HTML flavour, as cells). The page keeps \"−\".\n *\n * A copy with no such minus is left to the browser untouched, and so is any copy out of an\n * input, a textarea or an editable element: what the person typed is theirs.\n *\n * Installed once per document by `UiKitProvider` (`copyAsciiMinus`, default `true`) —\n * the one part every app mounts, the signed-out pages included.\n */\n\n/** U+2212, then (optionally) a currency code or symbol, then spaces, then a digit. */\nconst MINUS_BEFORE_NUMBER = /\\u2212(?=(?:[A-Z]{3}|\\p{Sc})?[\\s\\u00a0\\u202f]*\\d)/gu;\n\n/**\n * `text` with each U+2212 that stands before a number (in the sense above) as the ASCII\n * hyphen-minus, every other character as it was — for text an app hands a machine that\n * did not come from a formatter's `minus: \"ascii\"`.\n */\nexport function asciiMinusInNumbers(text: string): string {\n return text.replace(MINUS_BEFORE_NUMBER, \"-\");\n}\n\nfunction hasMinusBeforeNumber(text: string): boolean {\n MINUS_BEFORE_NUMBER.lastIndex = 0;\n const found = MINUS_BEFORE_NUMBER.test(text);\n MINUS_BEFORE_NUMBER.lastIndex = 0;\n return found;\n}\n\n/** An input, a textarea or an editable element: a copy there is the person's own text. */\nfunction isEditable(node: Node | null | undefined): boolean {\n const el = node instanceof Element ? node : node?.parentElement;\n if (!el) return false;\n if (el.closest(\"input, textarea, select\")) return true;\n return (\n (el as HTMLElement).isContentEditable === true ||\n el.closest('[contenteditable=\"\"], [contenteditable=\"true\"]') !== null\n );\n}\n\n/** Table parts copied alone are wrapped back into a table, which Excel reads as cells. */\nfunction wrapTableParts(container: HTMLElement): string {\n const first = container.firstElementChild?.tagName;\n const html = container.innerHTML;\n if (first === \"TD\" || first === \"TH\") return `<table><tbody><tr>${html}</tr></tbody></table>`;\n if (first === \"TR\") return `<table><tbody>${html}</tbody></table>`;\n if (first === \"TBODY\" || first === \"THEAD\" || first === \"TFOOT\") return `<table>${html}</table>`;\n return html;\n}\n\nfunction onCopy(event: ClipboardEvent): void {\n if (event.defaultPrevented || !event.clipboardData) return;\n const doc = (event.target as Node | null)?.ownerDocument ?? document;\n if (isEditable(event.target as Node | null) || isEditable(doc.activeElement)) return;\n const selection = doc.getSelection();\n if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return;\n if (isEditable(selection.anchorNode) || isEditable(selection.focusNode)) return;\n const text = selection.toString();\n if (!hasMinusBeforeNumber(text)) return;\n\n const container = doc.createElement(\"div\");\n for (let i = 0; i < selection.rangeCount; i++) container.appendChild(selection.getRangeAt(i).cloneContents());\n const walker = doc.createTreeWalker(container, NodeFilter.SHOW_TEXT);\n for (let node = walker.nextNode(); node; node = walker.nextNode()) {\n node.nodeValue = asciiMinusInNumbers(node.nodeValue ?? \"\");\n }\n event.clipboardData.setData(\"text/plain\", asciiMinusInNumbers(text));\n event.clipboardData.setData(\"text/html\", wrapTableParts(container));\n event.preventDefault();\n}\n\nconst installs = new WeakMap<Document, number>();\n\n/**\n * Listen for copies on `doc` until the returned function is called. Counted: nested\n * providers install it once, and the last one to go removes it.\n */\nexport function installClipboardMinus(doc: Document = document): () => void {\n const count = installs.get(doc) ?? 0;\n if (count === 0) doc.addEventListener(\"copy\", onCopy);\n installs.set(doc, count + 1);\n let done = false;\n return () => {\n if (done) return;\n done = true;\n const left = (installs.get(doc) ?? 1) - 1;\n installs.set(doc, left);\n if (left === 0) doc.removeEventListener(\"copy\", onCopy);\n };\n}\n"],"mappings":";AAkBA,MAAM,sBAAsB;AAOrB,SAAS,oBAAoB,MAAsB;AACxD,SAAO,KAAK,QAAQ,qBAAqB,GAAG;AAC9C;AAEA,SAAS,qBAAqB,MAAuB;AACnD,sBAAoB,YAAY;AAChC,QAAM,QAAQ,oBAAoB,KAAK,IAAI;AAC3C,sBAAoB,YAAY;AAChC,SAAO;AACT;AAGA,SAAS,WAAW,MAAwC;AAC1D,QAAM,KAAK,gBAAgB,UAAU,OAAO,MAAM;AAClD,MAAI,CAAC,GAAI,QAAO;AAChB,MAAI,GAAG,QAAQ,yBAAyB,EAAG,QAAO;AAClD,SACG,GAAmB,sBAAsB,QAC1C,GAAG,QAAQ,gDAAgD,MAAM;AAErE;AAGA,SAAS,eAAe,WAAgC;AACtD,QAAM,QAAQ,UAAU,mBAAmB;AAC3C,QAAM,OAAO,UAAU;AACvB,MAAI,UAAU,QAAQ,UAAU,KAAM,QAAO,qBAAqB,IAAI;AACtE,MAAI,UAAU,KAAM,QAAO,iBAAiB,IAAI;AAChD,MAAI,UAAU,WAAW,UAAU,WAAW,UAAU,QAAS,QAAO,UAAU,IAAI;AACtF,SAAO;AACT;AAEA,SAAS,OAAO,OAA6B;AAC3C,MAAI,MAAM,oBAAoB,CAAC,MAAM,cAAe;AACpD,QAAM,MAAO,MAAM,QAAwB,iBAAiB;AAC5D,MAAI,WAAW,MAAM,MAAqB,KAAK,WAAW,IAAI,aAAa,EAAG;AAC9E,QAAM,YAAY,IAAI,aAAa;AACnC,MAAI,CAAC,aAAa,UAAU,eAAe,KAAK,UAAU,YAAa;AACvE,MAAI,WAAW,UAAU,UAAU,KAAK,WAAW,UAAU,SAAS,EAAG;AACzE,QAAM,OAAO,UAAU,SAAS;AAChC,MAAI,CAAC,qBAAqB,IAAI,EAAG;AAEjC,QAAM,YAAY,IAAI,cAAc,KAAK;AACzC,WAAS,IAAI,GAAG,IAAI,UAAU,YAAY,IAAK,WAAU,YAAY,UAAU,WAAW,CAAC,EAAE,cAAc,CAAC;AAC5G,QAAM,SAAS,IAAI,iBAAiB,WAAW,WAAW,SAAS;AACnE,WAAS,OAAO,OAAO,SAAS,GAAG,MAAM,OAAO,OAAO,SAAS,GAAG;AACjE,SAAK,YAAY,oBAAoB,KAAK,aAAa,EAAE;AAAA,EAC3D;AACA,QAAM,cAAc,QAAQ,cAAc,oBAAoB,IAAI,CAAC;AACnE,QAAM,cAAc,QAAQ,aAAa,eAAe,SAAS,CAAC;AAClE,QAAM,eAAe;AACvB;AAEA,MAAM,WAAW,oBAAI,QAA0B;AAMxC,SAAS,sBAAsB,MAAgB,UAAsB;AAC1E,QAAM,QAAQ,SAAS,IAAI,GAAG,KAAK;AACnC,MAAI,UAAU,EAAG,KAAI,iBAAiB,QAAQ,MAAM;AACpD,WAAS,IAAI,KAAK,QAAQ,CAAC;AAC3B,MAAI,OAAO;AACX,SAAO,MAAM;AACX,QAAI,KAAM;AACV,WAAO;AACP,UAAM,QAAQ,SAAS,IAAI,GAAG,KAAK,KAAK;AACxC,aAAS,IAAI,KAAK,IAAI;AACtB,QAAI,SAAS,EAAG,KAAI,oBAAoB,QAAQ,MAAM;AAAA,EACxD;AACF;","names":[]}
|
package/dist/lib/dates.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/lib/dates.ts"],"sourcesContent":["/**\n * ISO date helpers, collected from copies that were scattered across the pages.\n *\n * **Every \"what day is it\" helper here reads the LOCAL calendar** — one family, not\n * two. It used to be two: these all built a local Date and then read it back through\n * `toISOString()`, which is UTC, so east of Greenwich they all returned YESTERDAY\n * between local midnight and the UTC rollover. The old module note called that\n * deliberate and warned against changing it. It was not defensible, and dev#471 is\n * what it cost:\n *\n * > *\"Just entered a tx now from scheduled. But it still shows as upcoming. Is there\n * > a problem with timezones? It is currently 1 o clock middle european summer time.\"*\n *\n * There was: at 01:00 CEST `todayIso()` answered with the 14th, the row the scheduler\n * had just booked was dated the 15th, and `isUpcoming` compares the two strings — so a\n * transaction entered a second ago read as one still to come. The same slip moved\n * `startOfMonthIso()` onto the last day of the PREVIOUS month for an hour every first\n * of the month, which is a report range nobody would have questioned.\n *\n * A calendar day only means anything to the human reading it, and every caller here —\n * the register's today, the date-range presets, the budget cursor, a new\n * transaction's default date — is asking that question. There is no caller for whom\n * \"the UTC day\" is the right answer; the server compares plain dates with no zone at\n * all, so sending it the user's local day is also what it wants.\n */\n\n/** Zero-pad a number to two digits (\"3\" -> \"03\"). */\nexport function pad(n: number): string {\n return String(n).padStart(2, \"0\");\n}\n\n/** Today as \"YYYY-MM-DD\" on the LOCAL calendar (see the module note). */\nexport function todayIso(): string {\n return toLocalIso(new Date());\n}\n\n/** A Date as a local-time \"YYYY-MM-DD\" string. */\nexport function toLocalIso(d: Date): string {\n return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;\n}\n\n/** A Date as a local-time \"YYYY-MM\" month key. */\nexport function monthKey(d: Date): string {\n return `${d.getFullYear()}-${pad(d.getMonth() + 1)}`;\n}\n\n// ---------- Date-range presets + calendar helpers ----------\n// Local-calendar like todayIso (see the module note); used by the data-table date\n// filter and its MiniCalendar. Each one mutates a local Date and must therefore READ\n// it back locally — going out through toISOString() is what shifted every one of\n// these by a day near midnight (dev#471).\n\n/** Today shifted by `days`, as \"YYYY-MM-DD\". */\nexport function shiftIso(days: number): string {\n const d = new Date();\n d.setDate(d.getDate() + days);\n return toLocalIso(d);\n}\n\n/** First day (Monday) of the current week, as \"YYYY-MM-DD\". */\nexport function startOfWeekIso(): string {\n const d = new Date();\n const day = d.getDay();\n const diff = (day + 6) % 7; // monday = 0\n d.setDate(d.getDate() - diff);\n return toLocalIso(d);\n}\n\n/** First day of the month `offsetMonths` from now, as \"YYYY-MM-DD\". */\nexport function startOfMonthIso(offsetMonths = 0): string {\n const d = new Date();\n d.setDate(1);\n d.setMonth(d.getMonth() + offsetMonths);\n return toLocalIso(d);\n}\n\n/** Last day of the month `offsetMonths` from now, as \"YYYY-MM-DD\". */\nexport function endOfMonthIso(offsetMonths = 0): string {\n const d = new Date();\n d.setDate(1);\n d.setMonth(d.getMonth() + offsetMonths + 1);\n d.setDate(0);\n return toLocalIso(d);\n}\n\n/** First day of the year `offsetYears` from now, as \"YYYY-MM-DD\". */\nexport function startOfYearIso(offsetYears = 0): string {\n const d = new Date();\n d.setMonth(0);\n d.setDate(1);\n d.setFullYear(d.getFullYear() + offsetYears);\n return toLocalIso(d);\n}\n\n/** Last day of the year `offsetYears` from now, as \"YYYY-MM-DD\". */\nexport function endOfYearIso(offsetYears = 0): string {\n const d = new Date();\n d.setMonth(11);\n d.setDate(31);\n d.setFullYear(d.getFullYear() + offsetYears);\n return toLocalIso(d);\n}\n\n/** Parse a \"YYYY-MM-DD\" string to a local-midnight Date, or null if malformed —\n * where \"malformed\" includes a date that does not exist.\n *\n * The falsy check alone was not enough. `new Date(y, m - 1, d)` ROLLS OVER out-of-range\n * components rather than refusing them, so \"2026-13-45\" came back as 2027-02-14 and\n * \"2026-02-30\" as 2026-03-02: a valid-looking Date for a day that never happened. No\n * caller could tell — {@link formatIsoDate} blanks only on `null`, so the invented\n * date formatted and displayed exactly like a real one.\n *\n * The round-trip is the check: build the Date, then read the components back off it.\n * A value that rolled over cannot answer with the numbers it was given. That is the\n * same shape {@link sameYmd} already expresses, applied to a Date and its source. */\nexport function parseIsoDate(s: string): Date | null {\n if (!s) return null;\n const [y, m, d] = s.split(\"-\").map(Number);\n if (!y || !m || !d) return null;\n if (!Number.isInteger(y) || !Number.isInteger(m) || !Number.isInteger(d)) return null;\n const dt = new Date(y, m - 1, d);\n if (Number.isNaN(dt.getTime())) return null;\n if (dt.getFullYear() !== y || dt.getMonth() !== m - 1 || dt.getDate() !== d) return null;\n return dt;\n}\n\n/** Format a \"YYYY-MM-DD\" string for display in `locale` (empty string when\n * unparseable). Defaults to the locale's short numeric date; pass `options` to\n * override (e.g. `{ dateStyle: \"medium\" }`). `locale` may be `undefined` since 0.34 —\n * the runtime's default, as `Intl` reads it and as every other kit formatter takes it\n * (DUP-11: DatePicker re-wrapped this function only to pass none). */\nexport function formatIsoDate(\n iso: string,\n locale: string | undefined,\n options?: Intl.DateTimeFormatOptions,\n): string {\n const d = parseIsoDate(iso);\n return d ? d.toLocaleDateString(locale, options) : \"\";\n}\n\n/** A \"YYYY-MM-DD\" string shifted by `days` (negative shifts back), or the input\n * unchanged when it isn't a parseable date.\n *\n * Local-time throughout — {@link parseIsoDate} builds a local-midnight Date and\n * {@link toLocalIso} reads local components back, so this never slides a day the way\n * a `toISOString()` round-trip does west of UTC. */\nexport function addDaysIso(iso: string, days: number): string {\n const d = parseIsoDate(iso);\n if (!d) return iso;\n d.setDate(d.getDate() + days);\n return toLocalIso(d);\n}\n\n/**\n * `d` moved by whole months, keeping the day of the month where the target has one.\n * `setMonth` alone ROLLS OVER — 31 January + 1 month is 3 March — which would skip\n * February entirely for anyone paging through the year with PageDown. Returns a new\n * Date; `d` is not touched.\n */\nexport function addMonthsClamped(d: Date, months: number): Date {\n const day = d.getDate();\n const out = new Date(d.getFullYear(), d.getMonth() + months, 1);\n const lastOfTarget = new Date(out.getFullYear(), out.getMonth() + 1, 0).getDate();\n out.setDate(Math.min(day, lastOfTarget));\n return out;\n}\n\n/**\n * The first day of the week in `locale`, as a `Date#getDay` index (0 = Sunday).\n *\n * `Intl.Locale#getWeekInfo()` is the standard spelling; V8 shipped it first as the\n * `weekInfo` accessor, and Firefox has neither yet — hence both reads and a Monday\n * fallback, which is ISO 8601. Its `firstDay` counts 1 = Monday … 7 = Sunday, so `% 7`\n * maps it onto `getDay`. `undefined` asks for the runtime's default locale.\n */\nexport function localeWeekStart(locale: string | undefined): 0 | 1 | 2 | 3 | 4 | 5 | 6 {\n try {\n const tag = locale ?? new Intl.DateTimeFormat().resolvedOptions().locale;\n const loc = new Intl.Locale(tag) as Intl.Locale & {\n getWeekInfo?: () => { firstDay: number };\n weekInfo?: { firstDay: number };\n };\n const firstDay = (loc.getWeekInfo?.() ?? loc.weekInfo)?.firstDay;\n if (typeof firstDay === \"number\") return (firstDay % 7) as 0 | 1 | 2 | 3 | 4 | 5 | 6;\n } catch {\n // A malformed tag throws from `Intl.Locale`; a calendar still has to render.\n }\n return 1;\n}\n\n/** Whether two Dates fall on the same local calendar day. */\nexport function sameYmd(a: Date, b: Date): boolean {\n return (\n a.getFullYear() === b.getFullYear() &&\n a.getMonth() === b.getMonth() &&\n a.getDate() === b.getDate()\n );\n}\n\nexport interface DateRangePreset {\n key: string;\n from: string;\n to: string;\n}\n\n/**\n * The named ranges offered by the data-table date filter (local-calendar, like\n * {@link todayIso} — see the module note). `key` maps to the consumer's\n * `table.preset_*` labels.\n */\nexport function dateRangePresets(): DateRangePreset[] {\n const mondayOffset = (new Date().getDay() + 6) % 7; // monday = 0\n return [\n { key: \"today\", from: todayIso(), to: todayIso() },\n { key: \"yesterday\", from: shiftIso(-1), to: shiftIso(-1) },\n { key: \"this_week\", from: startOfWeekIso(), to: todayIso() },\n { key: \"last_week\", from: shiftIso(-7 - mondayOffset), to: shiftIso(-1 - mondayOffset) },\n { key: \"last_7_days\", from: shiftIso(-6), to: todayIso() },\n { key: \"last_30_days\", from: shiftIso(-29), to: todayIso() },\n { key: \"this_month\", from: startOfMonthIso(0), to: todayIso() },\n { key: \"last_month\", from: startOfMonthIso(-1), to: endOfMonthIso(-1) },\n { key: \"last_3_months\", from: shiftIso(-90), to: todayIso() },\n { key: \"ytd\", from: startOfYearIso(0), to: todayIso() },\n { key: \"last_year\", from: startOfYearIso(-1), to: endOfYearIso(-1) },\n ];\n}\n\n/** The last `n` WHOLE calendar months before the current one, as an inclusive\n * `{ from, to }` — on 15 May, `n = 3` is 1 February … 30 April. Local-calendar, like\n * everything here; `now` is for tests and for a host with its own clock. */\nexport function lastFullMonthsRange(n: number, now: Date = new Date()): { from: string; to: string } {\n const from = new Date(now.getFullYear(), now.getMonth() - n, 1);\n // Day 0 of the current month is the last day of the previous one.\n const to = new Date(now.getFullYear(), now.getMonth(), 0);\n return { from: toLocalIso(from), to: toLocalIso(to) };\n}\n\n/** The last `n` WHOLE calendar years before the current one — in 2026, `n = 2` is\n * 1 January 2024 … 31 December 2025. */\nexport function lastFullYearsRange(n: number, now: Date = new Date()): { from: string; to: string } {\n const year = now.getFullYear();\n return { from: `${year - n}-01-01`, to: `${year - 1}-12-31` };\n}\n\nexport interface CalendarMonthPresetOptions {\n /** How many whole months each month preset spans. Default `[3, 6]`. */\n months?: readonly number[];\n /** How many whole years each year preset spans. Default `[2]`. */\n years?: readonly number[];\n /** The day to count back from. Default: now, on the local calendar. */\n now?: Date;\n}\n\n/**\n * Month-ALIGNED presets, beside the rolling-day ones of {@link dateRangePresets}.\n *\n * kastlan's accounting reports are closed by the month: \"the last three months\" there\n * means February, March and April, never \"the 90 days up to this morning\", which\n * starts mid-month and ends with a day that is still being booked. `last_3_months`\n * above is the rolling kind and stays that way for the data table's filter, so these\n * are separate keys rather than a changed meaning under an old one.\n *\n * Keys are `last_<n>_full_months` and `last_<n>_full_years`; the host labels them (the\n * same arrangement as `dateRangePresets`' keys and its `table.preset_*` strings). With\n * `DateRangePicker`, pass the key as the preset's `id` so the choice keeps its\n * identity while the dates are recomputed on every render.\n */\nexport function calendarMonthPresets(options: CalendarMonthPresetOptions = {}): DateRangePreset[] {\n const { months = [3, 6], years = [2], now = new Date() } = options;\n return [\n ...months.map((n) => ({ key: `last_${n}_full_months`, ...lastFullMonthsRange(n, now) })),\n ...years.map((n) => ({ key: `last_${n}_full_years`, ...lastFullYearsRange(n, now) })),\n ];\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/lib/dates.ts"],"sourcesContent":["/**\n * ISO date helpers, collected from copies that were scattered across the pages.\n *\n * **Every \"what day is it\" helper here reads the LOCAL calendar** — one family, not\n * two. It used to be two: these all built a local Date and then read it back through\n * `toISOString()`, which is UTC, so east of Greenwich they all returned YESTERDAY\n * between local midnight and the UTC rollover. The old module note called that\n * deliberate and warned against changing it. It was not defensible, and dev#471 is\n * what it cost:\n *\n * > *\"Just entered a tx now from scheduled. But it still shows as upcoming. Is there\n * > a problem with timezones? It is currently 1 o clock middle european summer time.\"*\n *\n * There was: at 01:00 CEST `todayIso()` answered with the 14th, the row the scheduler\n * had just booked was dated the 15th, and `isUpcoming` compares the two strings — so a\n * transaction entered a second ago read as one still to come. The same slip moved\n * `startOfMonthIso()` onto the last day of the PREVIOUS month for an hour every first\n * of the month, which is a report range nobody would have questioned.\n *\n * A calendar day only means anything to the human reading it, and every caller here —\n * the register's today, the date-range presets, the budget cursor, a new\n * transaction's default date — is asking that question. There is no caller for whom\n * \"the UTC day\" is the right answer; the server compares plain dates with no zone at\n * all, so sending it the user's local day is also what it wants.\n */\n\n/** Zero-pad a number to two digits (\"3\" -> \"03\"). */\nexport function pad(n: number): string {\n return String(n).padStart(2, \"0\");\n}\n\n/** Today as \"YYYY-MM-DD\" on the LOCAL calendar (see the module note). */\nexport function todayIso(): string {\n return toLocalIso(new Date());\n}\n\n/** A Date as a local-time \"YYYY-MM-DD\" string. */\nexport function toLocalIso(d: Date): string {\n return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;\n}\n\n/** A Date as a local-time \"YYYY-MM\" month key. */\nexport function monthKey(d: Date): string {\n return `${d.getFullYear()}-${pad(d.getMonth() + 1)}`;\n}\n\n// ---------- Date-range presets + calendar helpers ----------\n// Local-calendar like todayIso (see the module note); used by the data-table date\n// filter and its MiniCalendar. Each one mutates a local Date and must therefore READ\n// it back locally — going out through toISOString() is what shifted every one of\n// these by a day near midnight (dev#471).\n\n/** Today shifted by `days`, as \"YYYY-MM-DD\". */\nexport function shiftIso(days: number): string {\n const d = new Date();\n d.setDate(d.getDate() + days);\n return toLocalIso(d);\n}\n\n/** First day (Monday) of the current week, as \"YYYY-MM-DD\". */\nexport function startOfWeekIso(): string {\n const d = new Date();\n const day = d.getDay();\n const diff = (day + 6) % 7; // monday = 0\n d.setDate(d.getDate() - diff);\n return toLocalIso(d);\n}\n\n/** First day of the month `offsetMonths` from now, as \"YYYY-MM-DD\". */\nexport function startOfMonthIso(offsetMonths = 0): string {\n const d = new Date();\n d.setDate(1);\n d.setMonth(d.getMonth() + offsetMonths);\n return toLocalIso(d);\n}\n\n/** Last day of the month `offsetMonths` from now, as \"YYYY-MM-DD\". */\nexport function endOfMonthIso(offsetMonths = 0): string {\n const d = new Date();\n d.setDate(1);\n d.setMonth(d.getMonth() + offsetMonths + 1);\n d.setDate(0);\n return toLocalIso(d);\n}\n\n/** First day of the year `offsetYears` from now, as \"YYYY-MM-DD\". */\nexport function startOfYearIso(offsetYears = 0): string {\n const d = new Date();\n d.setMonth(0);\n d.setDate(1);\n d.setFullYear(d.getFullYear() + offsetYears);\n return toLocalIso(d);\n}\n\n/** Last day of the year `offsetYears` from now, as \"YYYY-MM-DD\". */\nexport function endOfYearIso(offsetYears = 0): string {\n const d = new Date();\n d.setMonth(11);\n d.setDate(31);\n d.setFullYear(d.getFullYear() + offsetYears);\n return toLocalIso(d);\n}\n\n/** Parse a \"YYYY-MM-DD\" string to a local-midnight Date, or null if malformed —\n * where \"malformed\" includes a date that does not exist.\n *\n * The falsy check alone was not enough. `new Date(y, m - 1, d)` ROLLS OVER out-of-range\n * components rather than refusing them, so \"2026-13-45\" came back as 2027-02-14 and\n * \"2026-02-30\" as 2026-03-02: a valid-looking Date for a day that never happened. No\n * caller could tell — {@link formatIsoDate} blanks only on `null`, so the invented\n * date formatted and displayed exactly like a real one.\n *\n * The round-trip is the check: build the Date, then read the components back off it.\n * A value that rolled over cannot answer with the numbers it was given. That is the\n * same shape {@link sameYmd} already expresses, applied to a Date and its source. */\nexport function parseIsoDate(s: string): Date | null {\n if (!s) return null;\n const [y, m, d] = s.split(\"-\").map(Number);\n if (!y || !m || !d) return null;\n if (!Number.isInteger(y) || !Number.isInteger(m) || !Number.isInteger(d)) return null;\n const dt = new Date(y, m - 1, d);\n if (Number.isNaN(dt.getTime())) return null;\n if (dt.getFullYear() !== y || dt.getMonth() !== m - 1 || dt.getDate() !== d) return null;\n return dt;\n}\n\n/** Format a \"YYYY-MM-DD\" string for display in `locale` (empty string when\n * unparseable). Defaults to the locale's short numeric date; pass `options` to\n * override (e.g. `{ dateStyle: \"medium\" }`). `locale` may be `undefined` since 0.34 —\n * the runtime's default, as `Intl` reads it and as every other kit formatter takes it\n * (DUP-11: DatePicker re-wrapped this function only to pass none). */\nexport function formatIsoDate(iso: string, locale: string | undefined, options?: Intl.DateTimeFormatOptions): string {\n const d = parseIsoDate(iso);\n return d ? d.toLocaleDateString(locale, options) : \"\";\n}\n\n/** A \"YYYY-MM-DD\" string shifted by `days` (negative shifts back), or the input\n * unchanged when it isn't a parseable date.\n *\n * Local-time throughout — {@link parseIsoDate} builds a local-midnight Date and\n * {@link toLocalIso} reads local components back, so this never slides a day the way\n * a `toISOString()` round-trip does west of UTC. */\nexport function addDaysIso(iso: string, days: number): string {\n const d = parseIsoDate(iso);\n if (!d) return iso;\n d.setDate(d.getDate() + days);\n return toLocalIso(d);\n}\n\n/**\n * `d` moved by whole months, keeping the day of the month where the target has one.\n * `setMonth` alone ROLLS OVER — 31 January + 1 month is 3 March — which would skip\n * February entirely for anyone paging through the year with PageDown. Returns a new\n * Date; `d` is not touched.\n */\nexport function addMonthsClamped(d: Date, months: number): Date {\n const day = d.getDate();\n const out = new Date(d.getFullYear(), d.getMonth() + months, 1);\n const lastOfTarget = new Date(out.getFullYear(), out.getMonth() + 1, 0).getDate();\n out.setDate(Math.min(day, lastOfTarget));\n return out;\n}\n\n/**\n * The first day of the week in `locale`, as a `Date#getDay` index (0 = Sunday).\n *\n * `Intl.Locale#getWeekInfo()` is the standard spelling; V8 shipped it first as the\n * `weekInfo` accessor, and Firefox has neither yet — hence both reads and a Monday\n * fallback, which is ISO 8601. Its `firstDay` counts 1 = Monday … 7 = Sunday, so `% 7`\n * maps it onto `getDay`. `undefined` asks for the runtime's default locale.\n */\nexport function localeWeekStart(locale: string | undefined): 0 | 1 | 2 | 3 | 4 | 5 | 6 {\n try {\n const tag = locale ?? new Intl.DateTimeFormat().resolvedOptions().locale;\n const loc = new Intl.Locale(tag) as Intl.Locale & {\n getWeekInfo?: () => { firstDay: number };\n weekInfo?: { firstDay: number };\n };\n const firstDay = (loc.getWeekInfo?.() ?? loc.weekInfo)?.firstDay;\n if (typeof firstDay === \"number\") return (firstDay % 7) as 0 | 1 | 2 | 3 | 4 | 5 | 6;\n } catch {\n // A malformed tag throws from `Intl.Locale`; a calendar still has to render.\n }\n return 1;\n}\n\n/** Whether two Dates fall on the same local calendar day. */\nexport function sameYmd(a: Date, b: Date): boolean {\n return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();\n}\n\nexport interface DateRangePreset {\n key: string;\n from: string;\n to: string;\n}\n\n/**\n * The named ranges offered by the data-table date filter (local-calendar, like\n * {@link todayIso} — see the module note). `key` maps to the consumer's\n * `table.preset_*` labels.\n */\nexport function dateRangePresets(): DateRangePreset[] {\n const mondayOffset = (new Date().getDay() + 6) % 7; // monday = 0\n return [\n { key: \"today\", from: todayIso(), to: todayIso() },\n { key: \"yesterday\", from: shiftIso(-1), to: shiftIso(-1) },\n { key: \"this_week\", from: startOfWeekIso(), to: todayIso() },\n { key: \"last_week\", from: shiftIso(-7 - mondayOffset), to: shiftIso(-1 - mondayOffset) },\n { key: \"last_7_days\", from: shiftIso(-6), to: todayIso() },\n { key: \"last_30_days\", from: shiftIso(-29), to: todayIso() },\n { key: \"this_month\", from: startOfMonthIso(0), to: todayIso() },\n { key: \"last_month\", from: startOfMonthIso(-1), to: endOfMonthIso(-1) },\n { key: \"last_3_months\", from: shiftIso(-90), to: todayIso() },\n { key: \"ytd\", from: startOfYearIso(0), to: todayIso() },\n { key: \"last_year\", from: startOfYearIso(-1), to: endOfYearIso(-1) },\n ];\n}\n\n/** The last `n` WHOLE calendar months before the current one, as an inclusive\n * `{ from, to }` — on 15 May, `n = 3` is 1 February … 30 April. Local-calendar, like\n * everything here; `now` is for tests and for a host with its own clock. */\nexport function lastFullMonthsRange(n: number, now: Date = new Date()): { from: string; to: string } {\n const from = new Date(now.getFullYear(), now.getMonth() - n, 1);\n // Day 0 of the current month is the last day of the previous one.\n const to = new Date(now.getFullYear(), now.getMonth(), 0);\n return { from: toLocalIso(from), to: toLocalIso(to) };\n}\n\n/** The last `n` WHOLE calendar years before the current one — in 2026, `n = 2` is\n * 1 January 2024 … 31 December 2025. */\nexport function lastFullYearsRange(n: number, now: Date = new Date()): { from: string; to: string } {\n const year = now.getFullYear();\n return { from: `${year - n}-01-01`, to: `${year - 1}-12-31` };\n}\n\nexport interface CalendarMonthPresetOptions {\n /** How many whole months each month preset spans. Default `[3, 6]`. */\n months?: readonly number[];\n /** How many whole years each year preset spans. Default `[2]`. */\n years?: readonly number[];\n /** The day to count back from. Default: now, on the local calendar. */\n now?: Date;\n}\n\n/**\n * Month-ALIGNED presets, beside the rolling-day ones of {@link dateRangePresets}.\n *\n * kastlan's accounting reports are closed by the month: \"the last three months\" there\n * means February, March and April, never \"the 90 days up to this morning\", which\n * starts mid-month and ends with a day that is still being booked. `last_3_months`\n * above is the rolling kind and stays that way for the data table's filter, so these\n * are separate keys rather than a changed meaning under an old one.\n *\n * Keys are `last_<n>_full_months` and `last_<n>_full_years`; the host labels them (the\n * same arrangement as `dateRangePresets`' keys and its `table.preset_*` strings). With\n * `DateRangePicker`, pass the key as the preset's `id` so the choice keeps its\n * identity while the dates are recomputed on every render.\n */\nexport function calendarMonthPresets(options: CalendarMonthPresetOptions = {}): DateRangePreset[] {\n const { months = [3, 6], years = [2], now = new Date() } = options;\n return [\n ...months.map((n) => ({ key: `last_${n}_full_months`, ...lastFullMonthsRange(n, now) })),\n ...years.map((n) => ({ key: `last_${n}_full_years`, ...lastFullYearsRange(n, now) })),\n ];\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/lib/decimal-marks.ts"],"sourcesContent":["/**\n * The locale's decimal mark and the reading of typed grouping marks, shared by the\n * kit's numeric text fields: {@link AmountInput}, {@link NumberInput} and, through\n * NumberInput, {@link NumberField} and {@link MoneyField}.\n *\n * Package-private — not re-exported from the barrel. It lives here rather than in\n * `calc.ts` because that module is re-exported wholesale (`export * from\n * \"./lib/calc\"`), and these are the fields' plumbing, not API.\n *\n * WHY ONE MODULE. AmountInput learned the locale's mark (kastlan 40) and the grouping\n * parse (keksdose G1); NumberInput learned neither, so the same \"1.234,56\" a German\n * user typed into a money field read as 1234.56 and, typed into the budget-goal field\n * next to it, as 1.23456 (keksdose K5). Two private copies of the same rule are how\n * that happened, so the rule now has one home.\n */\nimport { sanitizeLive } from \"./calc\";\n\n/**\n * The locale's decimal mark, as far as a field can type it: \",\" or \".\" (kastlan 40).\n * A mark outside those two (Arabic's \"٫\") is not one {@link sanitizeLive} lets\n * through, so those locales keep the dot rather than get a field that eats the key it\n * shows. `undefined` is the runtime's own locale, which is what `Intl` reads it as.\n */\nexport function decimalMark(locale: string | undefined): \",\" | \".\" {\n try {\n const part = new Intl.NumberFormat(locale).formatToParts(1.5).find((p) => p.type === \"decimal\");\n return part?.value === \",\" ? \",\" : \".\";\n } catch {\n // An invalid tag throws a RangeError; a typo in a locale must not take the form down.\n return \".\";\n }\n}\n\n/**\n * Which typed mark is the decimal one (keksdose G1). {@link sanitizeLive} folds every\n * \",\" into \".\", so on its own it read the German \"1.234,56\" as 1.23456 — and a money\n * field's settle then made that 1.23, final. This decides per operand, from the\n * typist's own text, before that fold:\n *\n * - both marks present: the one that is not the locale's is grouping, and goes;\n * - in a \",\" locale, only \".\" present: grouping when it cannot be a decimal as a\n * German reader writes it — twice (\"1.234.567\"), or in thousands shape (\"1.234\",\n * \"12.500\") — otherwise a decimal (\"1.5\", typed by a dot-decimal habit). A first\n * group that starts with 0 is never thousands: \"0.500\" is a half, not five hundred\n * (BUG-6, refactor audit 2026-10);\n * - in a \".\" locale, only \",\" present: grouping when it appears twice (\"1,234,567\");\n * once it stays a decimal, because Swiss and German typists write \"1,5\" in a\n * de-CH form (kastlan 40) and a unit price \"1,789\" is a decimal.\n *\n * Only a keystroke's text comes through here. A calculator result is dot-decimal\n * already, and \"1.234\" from `100/81.03…` is never a thousand.\n */\nexport function normalizeTypedMarks(raw: string, mark: \",\" | \".\"): string {\n const other = mark === \",\" ? \".\" : \",\";\n return raw.replace(/[0-9.,]+/g, (operand) => {\n const hasMark = operand.includes(mark);\n const count = operand.split(other).length - 1;\n if (count === 0) return operand;\n const grouping = hasMark
|
|
1
|
+
{"version":3,"sources":["../../src/lib/decimal-marks.ts"],"sourcesContent":["/**\n * The locale's decimal mark and the reading of typed grouping marks, shared by the\n * kit's numeric text fields: {@link AmountInput}, {@link NumberInput} and, through\n * NumberInput, {@link NumberField} and {@link MoneyField}.\n *\n * Package-private — not re-exported from the barrel. It lives here rather than in\n * `calc.ts` because that module is re-exported wholesale (`export * from\n * \"./lib/calc\"`), and these are the fields' plumbing, not API.\n *\n * WHY ONE MODULE. AmountInput learned the locale's mark (kastlan 40) and the grouping\n * parse (keksdose G1); NumberInput learned neither, so the same \"1.234,56\" a German\n * user typed into a money field read as 1234.56 and, typed into the budget-goal field\n * next to it, as 1.23456 (keksdose K5). Two private copies of the same rule are how\n * that happened, so the rule now has one home.\n */\nimport { sanitizeLive } from \"./calc\";\n\n/**\n * The locale's decimal mark, as far as a field can type it: \",\" or \".\" (kastlan 40).\n * A mark outside those two (Arabic's \"٫\") is not one {@link sanitizeLive} lets\n * through, so those locales keep the dot rather than get a field that eats the key it\n * shows. `undefined` is the runtime's own locale, which is what `Intl` reads it as.\n */\nexport function decimalMark(locale: string | undefined): \",\" | \".\" {\n try {\n const part = new Intl.NumberFormat(locale).formatToParts(1.5).find((p) => p.type === \"decimal\");\n return part?.value === \",\" ? \",\" : \".\";\n } catch {\n // An invalid tag throws a RangeError; a typo in a locale must not take the form down.\n return \".\";\n }\n}\n\n/**\n * Which typed mark is the decimal one (keksdose G1). {@link sanitizeLive} folds every\n * \",\" into \".\", so on its own it read the German \"1.234,56\" as 1.23456 — and a money\n * field's settle then made that 1.23, final. This decides per operand, from the\n * typist's own text, before that fold:\n *\n * - both marks present: the one that is not the locale's is grouping, and goes;\n * - in a \",\" locale, only \".\" present: grouping when it cannot be a decimal as a\n * German reader writes it — twice (\"1.234.567\"), or in thousands shape (\"1.234\",\n * \"12.500\") — otherwise a decimal (\"1.5\", typed by a dot-decimal habit). A first\n * group that starts with 0 is never thousands: \"0.500\" is a half, not five hundred\n * (BUG-6, refactor audit 2026-10);\n * - in a \".\" locale, only \",\" present: grouping when it appears twice (\"1,234,567\");\n * once it stays a decimal, because Swiss and German typists write \"1,5\" in a\n * de-CH form (kastlan 40) and a unit price \"1,789\" is a decimal.\n *\n * Only a keystroke's text comes through here. A calculator result is dot-decimal\n * already, and \"1.234\" from `100/81.03…` is never a thousand.\n */\nexport function normalizeTypedMarks(raw: string, mark: \",\" | \".\"): string {\n const other = mark === \",\" ? \".\" : \",\";\n return raw.replace(/[0-9.,]+/g, (operand) => {\n const hasMark = operand.includes(mark);\n const count = operand.split(other).length - 1;\n if (count === 0) return operand;\n const grouping = hasMark ? true : count > 1 || (mark === \",\" && /^[1-9]\\d{0,2}(\\.\\d{3})+$/.test(operand));\n return grouping ? operand.split(other).join(\"\") : operand;\n });\n}\n\n/** What a keystroke's raw text reports: grouping read off, then the field's live\n * sanitising (comma → dot, stray characters out, one decimal point per operand). */\nexport function readTyped(raw: string, mark: \",\" | \".\"): string {\n return sanitizeLive(normalizeTypedMarks(raw, mark));\n}\n\n// Spacing a typist uses to group digits — a space, the no-break and narrow no-break\n// spaces `Intl` itself writes for fr-CH (\"1 234,5\" with U+202F), and the Swiss\n// apostrophes (\"1'234.50\"). The live sanitising drops them, so they never reach the\n// value; the draft may still show them.\nconst GROUP_SPACING = /[\\s'’\\u00a0\\u202f]/g;\n\n/**\n * Whether the field may keep SHOWING `raw` — the typist's own text — while it holds\n * `canonical` (dot-decimal, as `onChange` reported it).\n *\n * Showing the typed text rather than re-spelling the value is what keeps the caret\n * where the typist put it, and what keeps a grouping \".\" from being turned into a \",\"\n * under their fingers — after which no rule could tell the two apart (keksdose G1).\n * But only text that says the same as the value, and says it with nothing the field\n * would have DROPPED: a letter, or a second decimal point in one operand (\"1.2.3\"),\n * must not stay on screen while the value says \"1.23\" (Keksdose live #201 is the bug\n * that showing it would bring back). Grouping marks and grouping spaces may stay.\n */\nexport function showsTyped(raw: string, mark: \",\" | \".\", canonical: string): boolean {\n const read = normalizeTypedMarks(raw, mark).replace(GROUP_SPACING, \"\");\n const sanitized = sanitizeLive(read);\n // Nothing dropped: the read text is the sanitised one, but for which mark it uses.\n return sanitized === read.replace(/,/g, \".\") && sanitized === sanitizeLive(canonical);\n}\n\n/** A dot-decimal text spelt in `mark` — display only, one character for one, so a\n * re-spelling never moves a caret. */\nexport function localizeMark(text: string, mark: \",\" | \".\"): string {\n return mark === \",\" ? text.replace(/\\./g, \",\") : text;\n}\n"],"mappings":";AAeA,SAAS,oBAAoB;AAQtB,SAAS,YAAY,QAAuC;AACjE,MAAI;AACF,UAAM,OAAO,IAAI,KAAK,aAAa,MAAM,EAAE,cAAc,GAAG,EAAE,KAAK,CAAC,MAAM,EAAE,SAAS,SAAS;AAC9F,WAAO,MAAM,UAAU,MAAM,MAAM;AAAA,EACrC,QAAQ;AAEN,WAAO;AAAA,EACT;AACF;AAqBO,SAAS,oBAAoB,KAAa,MAAyB;AACxE,QAAM,QAAQ,SAAS,MAAM,MAAM;AACnC,SAAO,IAAI,QAAQ,aAAa,CAAC,YAAY;AAC3C,UAAM,UAAU,QAAQ,SAAS,IAAI;AACrC,UAAM,QAAQ,QAAQ,MAAM,KAAK,EAAE,SAAS;AAC5C,QAAI,UAAU,EAAG,QAAO;AACxB,UAAM,WAAW,UAAU,OAAO,QAAQ,KAAM,SAAS,OAAO,2BAA2B,KAAK,OAAO;AACvG,WAAO,WAAW,QAAQ,MAAM,KAAK,EAAE,KAAK,EAAE,IAAI;AAAA,EACpD,CAAC;AACH;AAIO,SAAS,UAAU,KAAa,MAAyB;AAC9D,SAAO,aAAa,oBAAoB,KAAK,IAAI,CAAC;AACpD;AAMA,MAAM,gBAAgB;AAcf,SAAS,WAAW,KAAa,MAAiB,WAA4B;AACnF,QAAM,OAAO,oBAAoB,KAAK,IAAI,EAAE,QAAQ,eAAe,EAAE;AACrE,QAAM,YAAY,aAAa,IAAI;AAEnC,SAAO,cAAc,KAAK,QAAQ,MAAM,GAAG,KAAK,cAAc,aAAa,SAAS;AACtF;AAIO,SAAS,aAAa,MAAc,MAAyB;AAClE,SAAO,SAAS,MAAM,KAAK,QAAQ,OAAO,GAAG,IAAI;AACnD;","names":[]}
|
|
@@ -49,7 +49,8 @@ function resetDeviceSettings() {
|
|
|
49
49
|
entry.reset();
|
|
50
50
|
keys.push(...covered);
|
|
51
51
|
} catch (error) {
|
|
52
|
-
if (devBuild())
|
|
52
|
+
if (devBuild())
|
|
53
|
+
console.error(`resetDeviceSettings: the reset of "${entry.key}" threw; its key is removed instead.`, error);
|
|
53
54
|
remove.push(entry);
|
|
54
55
|
}
|
|
55
56
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/lib/device-settings.ts"],"sourcesContent":["import { readSessionStored, readStored, removeStored, storedKeys, writeSessionStored } from \"./safe-storage\";\n\n/**\n * What this device keeps of how it shows the app, and the one action that puts it all\n * back: \"Reset this device's settings\" (docs/settings-harmonization.md §11).\n *\n * A registry, because no one part knows every key. The kit's own stores and components\n * join it themselves — the theme, palette, text-size and contrast stores when they are\n * created, `UiKitProvider` (the prefix its tables keep their views under) and `AppShell`\n * (the sidebar's folded state) while they are mounted — and an app adds its own keys\n * beside the store or accessor that owns each (keksdose's date format, kastlan's\n * language, Kurvenschmiede's curve channels). {@link resetDeviceSettings} then clears\n * exactly what joined, and nothing else: the sign-in, the session, what waits to be sent\n * and the account never join (§11.2 decision 2), so a key nobody registered survives.\n *\n * No React and no router: a store joins at module scope, when it is created. Here in\n * `src/lib` beside `safe-storage.ts`, which the stores and the shell already import, so\n * `src/theme` and `src/shell` do not import `src/settings`. One module instance, one\n * module-level list.\n */\n\n/** What a reset of this device's settings clears (§11.2 decision 1): how the app looks\n * here, and how its tables and panels were left. An app's own list may add a third kind\n * of its own for what never goes (Kurvenschmiede's \"never\"). */\nexport type DeviceSettingKind = \"appearance\" | \"view\";\n\nexport interface DeviceSetting {\n /** The storage key; with `prefix`, every key that starts with it. */\n key: string;\n /** `true`: `key` is a prefix (a table's views, keksdose's display currency per budget).\n * It matches by `startsWith`: `budget.displayCurrency.` does not take the legacy\n * `budget.displayCurrency`. */\n prefix?: boolean;\n /** Default `\"local\"`. */\n storage?: \"local\" | \"session\";\n kind: DeviceSettingKind;\n /**\n * Puts the live value back to its default through the store's own setter, the one its\n * control calls, so whatever that setter also does still happens — never\n * `setState(getInitialState())`, which skips it. REQUIRED for anything held in memory:\n * every persisted zustand store (removing its key alone, it writes its value back on its\n * next change), a mounted component's state. With `reset` the key is left to it (a\n * persisted store writes its default itself); without, the key, or every key under the\n * prefix, is removed — for what is read where it is next used (a table at its mount, a\n * plain accessor).\n */\n reset?: () => void;\n}\n\n/** Whoever joined one key, in join order; the last one's entry is the key's entry. */\ntype Holders = { entry: DeviceSetting }[];\n\n/** One slot per key and storage area, in the order each was first joined. */\nconst slots = new Map<string, Holders>();\n\nconst areaOf = (entry: DeviceSetting): \"local\" | \"session\" => (entry.storage === \"session\" ? \"session\" : \"local\");\nconst slotOf = (entry: DeviceSetting) => `${areaOf(entry)}:${entry.key}`;\n\n/**\n * Join the registry; answers the leave function. A store joins once, at creation, and\n * never leaves; a component joins while mounted and leaves in its effect's cleanup.\n *\n * **One entry per key** (and storage area): joining a key again replaces its entry and\n * adds a holder, and a leave drops its holder — the entry goes with the last one, and\n * until then the latest remaining holder's entry is the key's. So a module evaluated twice\n * (a test, a hot reload) does not reset twice, and two nested providers with one prefix\n * keep their entry until the outer one unmounts.\n */\nexport function registerDeviceSetting(entry: DeviceSetting): () => void {\n const slot = slotOf(entry);\n const holder = { entry: { ...entry } };\n const holders = slots.get(slot);\n if (holders) holders.push(holder);\n else slots.set(slot, [holder]);\n let left = false;\n return () => {\n if (left) return;\n left = true;\n // A holder that has not left is in its slot: only its own leave takes it out, and a\n // slot goes only once it is empty.\n const current = slots.get(slot) as Holders;\n current.splice(current.indexOf(holder), 1);\n if (current.length === 0) slots.delete(slot);\n };\n}\n\n/** Every entry, in join order: for an app's own test (Kurvenschmiede holds it to its\n * `STORAGE` list) or its privacy page. */\nexport function deviceSettings(): readonly DeviceSetting[] {\n return [...slots.values()].map((holders) => holders[holders.length - 1].entry);\n}\n\n/** Is this a development build? `import.meta.env` is a Vite injection, absent under Node. */\nfunction devBuild(): boolean {\n return Boolean(import.meta.env?.DEV);\n}\n\n/** The keys an entry covers now: a prefix's keys found under it, else its own key. */\nfunction keysOf(entry: DeviceSetting): string[] {\n return entry.prefix ? storedKeys(entry.key, areaOf(entry)) : [entry.key];\n}\n\n/** Remove what `entry` covers; answers the keys that were there. */\nfunction removeEntry(entry: DeviceSetting): string[] {\n const session = areaOf(entry) === \"session\";\n const present = keysOf(entry).filter((key) => (session ? readSessionStored(key) : readStored(key)) !== null);\n for (const key of present) {\n if (session) writeSessionStored(key, null);\n else removeStored(key);\n }\n return present;\n}\n\n/**\n * Reset every entry, at once; answers the keys it reset or removed, prefixes expanded.\n *\n * Synchronous, in two passes: first every `reset`, in join order, so each live store puts\n * its value back (and writes its default); then the keys of the entries without one. A\n * `reset` that throws is caught (`console.error` in development), its key is removed\n * instead, and the rest still run. It never reloads, navigates or sends a request, and\n * never touches a key nobody registered. Where the browser refuses storage, the live\n * values still reset and nothing throws.\n *\n * The answer: an entry with a `reset` lists its key (a prefix, the keys found under it);\n * an entry without lists the keys it removed, those that were there.\n */\nexport function resetDeviceSettings(): string[] {\n const keys: string[] = [];\n const remove: DeviceSetting[] = [];\n for (const entry of deviceSettings()) {\n if (!entry.reset) {\n remove.push(entry);\n continue;\n }\n const covered = keysOf(entry);\n try {\n entry.reset();\n keys.push(...covered);\n } catch (error) {\n if (devBuild())
|
|
1
|
+
{"version":3,"sources":["../../src/lib/device-settings.ts"],"sourcesContent":["import { readSessionStored, readStored, removeStored, storedKeys, writeSessionStored } from \"./safe-storage\";\n\n/**\n * What this device keeps of how it shows the app, and the one action that puts it all\n * back: \"Reset this device's settings\" (docs/settings-harmonization.md §11).\n *\n * A registry, because no one part knows every key. The kit's own stores and components\n * join it themselves — the theme, palette, text-size and contrast stores when they are\n * created, `UiKitProvider` (the prefix its tables keep their views under) and `AppShell`\n * (the sidebar's folded state) while they are mounted — and an app adds its own keys\n * beside the store or accessor that owns each (keksdose's date format, kastlan's\n * language, Kurvenschmiede's curve channels). {@link resetDeviceSettings} then clears\n * exactly what joined, and nothing else: the sign-in, the session, what waits to be sent\n * and the account never join (§11.2 decision 2), so a key nobody registered survives.\n *\n * No React and no router: a store joins at module scope, when it is created. Here in\n * `src/lib` beside `safe-storage.ts`, which the stores and the shell already import, so\n * `src/theme` and `src/shell` do not import `src/settings`. One module instance, one\n * module-level list.\n */\n\n/** What a reset of this device's settings clears (§11.2 decision 1): how the app looks\n * here, and how its tables and panels were left. An app's own list may add a third kind\n * of its own for what never goes (Kurvenschmiede's \"never\"). */\nexport type DeviceSettingKind = \"appearance\" | \"view\";\n\nexport interface DeviceSetting {\n /** The storage key; with `prefix`, every key that starts with it. */\n key: string;\n /** `true`: `key` is a prefix (a table's views, keksdose's display currency per budget).\n * It matches by `startsWith`: `budget.displayCurrency.` does not take the legacy\n * `budget.displayCurrency`. */\n prefix?: boolean;\n /** Default `\"local\"`. */\n storage?: \"local\" | \"session\";\n kind: DeviceSettingKind;\n /**\n * Puts the live value back to its default through the store's own setter, the one its\n * control calls, so whatever that setter also does still happens — never\n * `setState(getInitialState())`, which skips it. REQUIRED for anything held in memory:\n * every persisted zustand store (removing its key alone, it writes its value back on its\n * next change), a mounted component's state. With `reset` the key is left to it (a\n * persisted store writes its default itself); without, the key, or every key under the\n * prefix, is removed — for what is read where it is next used (a table at its mount, a\n * plain accessor).\n */\n reset?: () => void;\n}\n\n/** Whoever joined one key, in join order; the last one's entry is the key's entry. */\ntype Holders = { entry: DeviceSetting }[];\n\n/** One slot per key and storage area, in the order each was first joined. */\nconst slots = new Map<string, Holders>();\n\nconst areaOf = (entry: DeviceSetting): \"local\" | \"session\" => (entry.storage === \"session\" ? \"session\" : \"local\");\nconst slotOf = (entry: DeviceSetting) => `${areaOf(entry)}:${entry.key}`;\n\n/**\n * Join the registry; answers the leave function. A store joins once, at creation, and\n * never leaves; a component joins while mounted and leaves in its effect's cleanup.\n *\n * **One entry per key** (and storage area): joining a key again replaces its entry and\n * adds a holder, and a leave drops its holder — the entry goes with the last one, and\n * until then the latest remaining holder's entry is the key's. So a module evaluated twice\n * (a test, a hot reload) does not reset twice, and two nested providers with one prefix\n * keep their entry until the outer one unmounts.\n */\nexport function registerDeviceSetting(entry: DeviceSetting): () => void {\n const slot = slotOf(entry);\n const holder = { entry: { ...entry } };\n const holders = slots.get(slot);\n if (holders) holders.push(holder);\n else slots.set(slot, [holder]);\n let left = false;\n return () => {\n if (left) return;\n left = true;\n // A holder that has not left is in its slot: only its own leave takes it out, and a\n // slot goes only once it is empty.\n const current = slots.get(slot) as Holders;\n current.splice(current.indexOf(holder), 1);\n if (current.length === 0) slots.delete(slot);\n };\n}\n\n/** Every entry, in join order: for an app's own test (Kurvenschmiede holds it to its\n * `STORAGE` list) or its privacy page. */\nexport function deviceSettings(): readonly DeviceSetting[] {\n return [...slots.values()].map((holders) => holders[holders.length - 1].entry);\n}\n\n/** Is this a development build? `import.meta.env` is a Vite injection, absent under Node. */\nfunction devBuild(): boolean {\n return Boolean(import.meta.env?.DEV);\n}\n\n/** The keys an entry covers now: a prefix's keys found under it, else its own key. */\nfunction keysOf(entry: DeviceSetting): string[] {\n return entry.prefix ? storedKeys(entry.key, areaOf(entry)) : [entry.key];\n}\n\n/** Remove what `entry` covers; answers the keys that were there. */\nfunction removeEntry(entry: DeviceSetting): string[] {\n const session = areaOf(entry) === \"session\";\n const present = keysOf(entry).filter((key) => (session ? readSessionStored(key) : readStored(key)) !== null);\n for (const key of present) {\n if (session) writeSessionStored(key, null);\n else removeStored(key);\n }\n return present;\n}\n\n/**\n * Reset every entry, at once; answers the keys it reset or removed, prefixes expanded.\n *\n * Synchronous, in two passes: first every `reset`, in join order, so each live store puts\n * its value back (and writes its default); then the keys of the entries without one. A\n * `reset` that throws is caught (`console.error` in development), its key is removed\n * instead, and the rest still run. It never reloads, navigates or sends a request, and\n * never touches a key nobody registered. Where the browser refuses storage, the live\n * values still reset and nothing throws.\n *\n * The answer: an entry with a `reset` lists its key (a prefix, the keys found under it);\n * an entry without lists the keys it removed, those that were there.\n */\nexport function resetDeviceSettings(): string[] {\n const keys: string[] = [];\n const remove: DeviceSetting[] = [];\n for (const entry of deviceSettings()) {\n if (!entry.reset) {\n remove.push(entry);\n continue;\n }\n const covered = keysOf(entry);\n try {\n entry.reset();\n keys.push(...covered);\n } catch (error) {\n if (devBuild())\n console.error(`resetDeviceSettings: the reset of \"${entry.key}\" threw; its key is removed instead.`, error);\n remove.push(entry);\n }\n }\n for (const entry of remove) keys.push(...removeEntry(entry));\n return [...new Set(keys)];\n}\n"],"mappings":";AAAA,SAAS,mBAAmB,YAAY,cAAc,YAAY,0BAA0B;AAqD5F,MAAM,QAAQ,oBAAI,IAAqB;AAEvC,MAAM,SAAS,CAAC,UAA+C,MAAM,YAAY,YAAY,YAAY;AACzG,MAAM,SAAS,CAAC,UAAyB,GAAG,OAAO,KAAK,CAAC,IAAI,MAAM,GAAG;AAY/D,SAAS,sBAAsB,OAAkC;AACtE,QAAM,OAAO,OAAO,KAAK;AACzB,QAAM,SAAS,EAAE,OAAO,EAAE,GAAG,MAAM,EAAE;AACrC,QAAM,UAAU,MAAM,IAAI,IAAI;AAC9B,MAAI,QAAS,SAAQ,KAAK,MAAM;AAAA,MAC3B,OAAM,IAAI,MAAM,CAAC,MAAM,CAAC;AAC7B,MAAI,OAAO;AACX,SAAO,MAAM;AACX,QAAI,KAAM;AACV,WAAO;AAGP,UAAM,UAAU,MAAM,IAAI,IAAI;AAC9B,YAAQ,OAAO,QAAQ,QAAQ,MAAM,GAAG,CAAC;AACzC,QAAI,QAAQ,WAAW,EAAG,OAAM,OAAO,IAAI;AAAA,EAC7C;AACF;AAIO,SAAS,iBAA2C;AACzD,SAAO,CAAC,GAAG,MAAM,OAAO,CAAC,EAAE,IAAI,CAAC,YAAY,QAAQ,QAAQ,SAAS,CAAC,EAAE,KAAK;AAC/E;AAGA,SAAS,WAAoB;AAC3B,SAAO,QAAQ,YAAY,KAAK,GAAG;AACrC;AAGA,SAAS,OAAO,OAAgC;AAC9C,SAAO,MAAM,SAAS,WAAW,MAAM,KAAK,OAAO,KAAK,CAAC,IAAI,CAAC,MAAM,GAAG;AACzE;AAGA,SAAS,YAAY,OAAgC;AACnD,QAAM,UAAU,OAAO,KAAK,MAAM;AAClC,QAAM,UAAU,OAAO,KAAK,EAAE,OAAO,CAAC,SAAS,UAAU,kBAAkB,GAAG,IAAI,WAAW,GAAG,OAAO,IAAI;AAC3G,aAAW,OAAO,SAAS;AACzB,QAAI,QAAS,oBAAmB,KAAK,IAAI;AAAA,QACpC,cAAa,GAAG;AAAA,EACvB;AACA,SAAO;AACT;AAeO,SAAS,sBAAgC;AAC9C,QAAM,OAAiB,CAAC;AACxB,QAAM,SAA0B,CAAC;AACjC,aAAW,SAAS,eAAe,GAAG;AACpC,QAAI,CAAC,MAAM,OAAO;AAChB,aAAO,KAAK,KAAK;AACjB;AAAA,IACF;AACA,UAAM,UAAU,OAAO,KAAK;AAC5B,QAAI;AACF,YAAM,MAAM;AACZ,WAAK,KAAK,GAAG,OAAO;AAAA,IACtB,SAAS,OAAO;AACd,UAAI,SAAS;AACX,gBAAQ,MAAM,sCAAsC,MAAM,GAAG,wCAAwC,KAAK;AAC5G,aAAO,KAAK,KAAK;AAAA,IACnB;AAAA,EACF;AACA,aAAW,SAAS,OAAQ,MAAK,KAAK,GAAG,YAAY,KAAK,CAAC;AAC3D,SAAO,CAAC,GAAG,IAAI,IAAI,IAAI,CAAC;AAC1B;","names":[]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/lib/direction.ts"],"sourcesContent":["/**\n * Reading direction, as the DOM has it — internal, not exported from the barrel.\n *\n * The kit lays itself out with logical classes (`ps-*`, `end-*`, `text-start`), which\n * need no JavaScript. These helpers are for the two things CSS cannot do:\n *\n * - **Arrow keys.** ←/→ mean previous/next in LTR and the reverse in RTL. Resolve the\n * key through {@link horizontalStep} instead of comparing to \"ArrowRight\".\n * - **Portals.** A panel portalled to `document.body` leaves the subtree whose `dir`\n * it inherited. Read the anchor's direction with {@link dirOf} and put it on the\n * portalled root, and position it from the anchor's START edge, not `rect.left`.\n */\n\nexport type Direction = \"ltr\" | \"rtl\";\n\n/** The effective direction at `el`: its nearest `[dir]` ancestor (or itself), else the\n * document's, else LTR. Attribute-based on purpose — jsdom computes no `direction`. */\nexport function dirOf(el: Element | null | undefined): Direction {\n const source
|
|
1
|
+
{"version":3,"sources":["../../src/lib/direction.ts"],"sourcesContent":["/**\n * Reading direction, as the DOM has it — internal, not exported from the barrel.\n *\n * The kit lays itself out with logical classes (`ps-*`, `end-*`, `text-start`), which\n * need no JavaScript. These helpers are for the two things CSS cannot do:\n *\n * - **Arrow keys.** ←/→ mean previous/next in LTR and the reverse in RTL. Resolve the\n * key through {@link horizontalStep} instead of comparing to \"ArrowRight\".\n * - **Portals.** A panel portalled to `document.body` leaves the subtree whose `dir`\n * it inherited. Read the anchor's direction with {@link dirOf} and put it on the\n * portalled root, and position it from the anchor's START edge, not `rect.left`.\n */\n\nexport type Direction = \"ltr\" | \"rtl\";\n\n/** The effective direction at `el`: its nearest `[dir]` ancestor (or itself), else the\n * document's, else LTR. Attribute-based on purpose — jsdom computes no `direction`. */\nexport function dirOf(el: Element | null | undefined): Direction {\n const source = el?.closest(\"[dir]\") ?? (typeof document !== \"undefined\" ? document.documentElement : null);\n return source?.getAttribute(\"dir\") === \"rtl\" ? \"rtl\" : \"ltr\";\n}\n\nexport function isRtl(el: Element | null | undefined): boolean {\n return dirOf(el) === \"rtl\";\n}\n\n/**\n * +1 for a key that moves FORWARD along the reading direction, -1 for backward, 0 for\n * any other key. ArrowRight is forward in LTR and backward in RTL.\n */\nexport function horizontalStep(key: string, el: Element | null | undefined): 1 | -1 | 0 {\n if (key !== \"ArrowRight\" && key !== \"ArrowLeft\") return 0;\n const forward = (key === \"ArrowRight\") !== isRtl(el);\n return forward ? 1 : -1;\n}\n"],"mappings":";AAiBO,SAAS,MAAM,IAA2C;AAC/D,QAAM,SAAS,IAAI,QAAQ,OAAO,MAAM,OAAO,aAAa,cAAc,SAAS,kBAAkB;AACrG,SAAO,QAAQ,aAAa,KAAK,MAAM,QAAQ,QAAQ;AACzD;AAEO,SAAS,MAAM,IAAyC;AAC7D,SAAO,MAAM,EAAE,MAAM;AACvB;AAMO,SAAS,eAAe,KAAa,IAA4C;AACtF,MAAI,QAAQ,gBAAgB,QAAQ,YAAa,QAAO;AACxD,QAAM,UAAW,QAAQ,iBAAkB,MAAM,EAAE;AACnD,SAAO,UAAU,IAAI;AACvB;","names":[]}
|
package/dist/lib/iban.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/lib/iban.ts"],"sourcesContent":["/**\n * IBAN helpers — ISO 13616 — for kastlan (supplier and customer bank details, the\n * Swiss QR-bill) and keksdose (the accounts behind a household's transfers).\n *\n * Both apps validated an IBAN the same partial way: a regex for the shape, sometimes a\n * mod-97, never the per-country length — so a Swiss IBAN with a digit missing passed\n * as long as the shorter string happened to sum right, and \"CH\" followed by 22\n * characters (a German length) was accepted as Swiss. And kastlan's QR-bill needs a\n * second distinction neither had: a **QR-IBAN** (the institution id at positions 5–9 is\n * 30000–31999) is the only account a QR-bill with a QR reference can be paid to, and it\n * cannot receive an ordinary transfer — so a regular IBAN typed where a QR-IBAN is\n * needed, or the other way round, is a payment that bounces weeks later.\n *\n * Everything here works on the **electronic format** (no spaces, upper case), which is\n * what to store; {@link formatIban} is the paper format, which is what to show.\n *\n * Validation is the registry's two rules, nothing more: the length for the country and\n * the mod-97 check digits. It does not check each country's BBAN layout (which places\n * hold letters), nor whether the bank exists — a checksum catches a typo, not a closed\n * account.\n */\n\n/**\n * The IBAN length for each country in the SWIFT IBAN Registry (2025 edition) — the\n * whole string, country code and check digits included.\n *\n * A country that is not in the table does not issue IBANs, so {@link isValidIban}\n * refuses it (\"XX\" is a typo, not an exotic bank). Territories that use another\n * country's code (Åland under FI, Guernsey under GB, the French overseas departments\n * under FR) are covered by that code, as the registry lists them.\n */\nexport const IBAN_LENGTHS: Readonly<Record<string, number>> = Object.freeze({\n AD: 24, AE: 23, AL: 28, AT: 20, AZ: 28, BA: 20, BE: 16, BG: 22, BH: 22, BI: 27,\n BR: 29, BY: 28, CH: 21, CR: 22, CY: 28, CZ: 24, DE: 22, DJ: 27, DK: 18, DO: 28,\n EE: 20, EG: 29, ES: 24, FI: 18, FK: 18, FO: 18, FR: 27, GB: 22, GE: 22, GI: 23,\n GL: 18, GR: 27, GT: 28, HN: 28, HR: 21, HU: 28, IE: 22, IL: 23, IQ: 23, IS: 26,\n IT: 27, JO: 30, KW: 30, KZ: 20, LB: 28, LC: 32, LI: 21, LT: 20, LU: 20, LV: 21,\n LY: 25, MC: 27, MD: 24, ME: 22, MK: 19, MN: 20, MR: 27, MT: 31, MU: 30, NI: 28,\n NL: 18, NO: 15, OM: 23, PK: 24, PL: 28, PS: 29, PT: 25, QA: 29, RO: 24, RS: 22,\n RU: 33, SA: 24, SC: 31, SD: 18, SE: 24, SI: 19, SK: 24, SM: 27, SO: 23, ST: 25,\n SV: 28, TL: 23, TN: 24, TR: 26, UA: 29, VA: 22, VG: 24, XK: 20, YE: 30,\n});\n\n/**\n * Which kind of IBAN a field accepts. `\"any\"`: every valid IBAN. `\"qr\"`: only a\n * QR-IBAN (a QR-bill with a QR reference — kastlan's payment slips). `\"plain\"`: every\n * valid IBAN EXCEPT a QR-IBAN (an account for ordinary transfers, which a QR-IBAN\n * cannot receive).\n */\nexport type IbanKind = \"qr\" | \"plain\" | \"any\";\n\n/**\n * Why a value is not the IBAN a field wants, most fundamental first:\n *\n * - `\"format\"` — it does not start with a two-letter country code and two digits;\n * - `\"country\"` — the first two letters are not a country in {@link IBAN_LENGTHS};\n * - `\"length\"` — the wrong number of characters for that country;\n * - `\"checksum\"` — the mod-97 check digits do not match (a typo, a swapped pair);\n * - `\"qrRequired\"` — a valid IBAN, but not a QR-IBAN where `kind=\"qr\"` needs one;\n * - `\"qrNotAllowed\"` — a valid QR-IBAN where `kind=\"plain\"` refuses one.\n */\nexport type IbanProblem = \"format\" | \"country\" | \"length\" | \"checksum\" | \"qrRequired\" | \"qrNotAllowed\";\n\n/**\n * The electronic format: every character that is not a letter or a digit dropped, the\n * letters in upper case. Spaces, non-breaking spaces, hyphens and dots — what a paste\n * from an invoice, a PDF or an e-banking screen carries — all go.\n *\n * A leading `\"IBAN\"` (the paper format's label: \"IBAN CH56 …\") is dropped too, but only\n * in front of a country code and check digits, so typing the word does not make it\n * vanish under the caret.\n */\nexport function compactIban(value: string): string {\n const compact = value.replace(/[^0-9A-Za-z]/g, \"\").toUpperCase();\n return /^IBAN[A-Z]{2}\\d{2}/.test(compact) ? compact.slice(4) : compact;\n}\n\n/**\n * The paper format: {@link compactIban}, then groups of four separated by a single\n * space (\"CH09 0000 0000 0000 0000 1\"). The last group is whatever is left over.\n */\nexport function formatIban(value: string): string {\n return compactIban(value).replace(/(.{4})(?=.)/g, \"$1 \");\n}\n\n/** The remainder of a long digit string modulo 97, a few digits at a time so it never\n * leaves the safe-integer range — ISO 7064 MOD 97-10 without a BigInt. */\nfunction mod97(digits: string): number {\n let rest = 0;\n for (let i = 0; i < digits.length; i += 7) {\n rest = Number(`${rest}${digits.slice(i, i + 7)}`) % 97;\n }\n return rest;\n}\n\n/** Letters to their two-digit numbers (A = 10 … Z = 35), digits as they are. */\nfunction expandLetters(value: string): string {\n return value.replace(/[A-Z]/g, (c) => String(c.charCodeAt(0) - 55));\n}\n\n/**\n * The two check digits for a country code and a BBAN (the account part after them):\n * `98 − ((BBAN + country + \"00\") mod 97)`, letters expanded A = 10 … Z = 35.\n *\n * What an app needs to BUILD an IBAN — a test fixture, a demo, a migration that\n * assembles one from a clearing number and an account number — rather than to check\n * one. Both arguments are compacted first.\n */\nexport function ibanCheckDigits(country: string, bban: string): string {\n const rearranged = `${compactIban(bban)}${compactIban(country)}00`;\n const check = 98 - mod97(expandLetters(rearranged));\n return String(check).padStart(2, \"0\");\n}\n\n/**\n * The first thing wrong with `value` as an IBAN of `kind`, or `null` when it is one.\n * The value is compacted first, so the paper format is accepted. An empty value is\n * `\"format\"`: whether a field may be left empty is the form's rule, not the IBAN's.\n */\nexport function ibanProblem(value: string, kind: IbanKind = \"any\"): IbanProblem | null {\n const iban = compactIban(value);\n if (!/^[A-Z]{2}\\d{2}/.test(iban)) return \"format\";\n const expected = IBAN_LENGTHS[iban.slice(0, 2)];\n if (expected === undefined) return \"country\";\n if (iban.length !== expected) return \"length\";\n // 00, 01 and 99 are never computed (98 − r lies in 2…98), but 01 ≡ 98 mod 97 and\n // would otherwise pass the remainder test below — ISO 7064 rules them out.\n const check = iban.slice(2, 4);\n if (check === \"00\" || check === \"01\" || check === \"99\") return \"checksum\";\n if (mod97(expandLetters(`${iban.slice(4)}${iban.slice(0, 4)}`)) !== 1) return \"checksum\";\n if (kind === \"any\") return null;\n const qr = qrIid(iban);\n if (kind === \"qr\" && !qr) return \"qrRequired\";\n if (kind === \"plain\" && qr) return \"qrNotAllowed\";\n return null;\n}\n\n/**\n * Whether `value` is a valid IBAN: a registry country, that country's length, and\n * matching mod-97 check digits. Spaces and lower case are fine (see\n * {@link compactIban}); what to store is the compact form.\n */\nexport function isValidIban(value: string): boolean {\n return ibanProblem(value) === null;\n}\n\n/** A CH/LI IBAN whose institution id (positions 5–9) is in the QR range. Structure\n * only — the caller has checked or will check the rest. */\nfunction qrIid(iban: string): boolean {\n const country = iban.slice(0, 2);\n if (country !== \"CH\" && country !== \"LI\") return false;\n const iid = iban.slice(4, 9);\n if (!/^\\d{5}$/.test(iid)) return false;\n const n = Number(iid);\n return n >= 30000 && n <= 31999;\n}\n\n/**\n * Whether `value` is a valid **QR-IBAN**: a valid Swiss or Liechtenstein IBAN whose\n * institution id — positions 5 to 9, the QR-IID — lies in 30000–31999 (SIX, Swiss\n * Implementation Guidelines for the QR-bill).\n *\n * The distinction is not cosmetic (kastlan): a QR-bill with a QR reference can only be\n * paid to a QR-IBAN, and a QR-IBAN can receive nothing else — so an app that prints\n * payment slips needs one, and an app that stores an account for ordinary transfers\n * must not take one.\n */\nexport function isQrIban(value: string): boolean {\n const iban = compactIban(value);\n return ibanProblem(iban) === null && qrIid(iban);\n}\n"],"mappings":";AA+BO,MAAM,eAAiD,OAAO,OAAO;AAAA,EAC1E,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAC5E,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAC5E,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAC5E,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAC5E,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAC5E,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAC5E,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAC5E,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAC5E,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AAAA,EAAI,IAAI;AACtE,CAAC;AA+BM,SAAS,YAAY,OAAuB;AACjD,QAAM,UAAU,MAAM,QAAQ,iBAAiB,EAAE,EAAE,YAAY;AAC/D,SAAO,qBAAqB,KAAK,OAAO,IAAI,QAAQ,MAAM,CAAC,IAAI;AACjE;AAMO,SAAS,WAAW,OAAuB;AAChD,SAAO,YAAY,KAAK,EAAE,QAAQ,gBAAgB,KAAK;AACzD;AAIA,SAAS,MAAM,QAAwB;AACrC,MAAI,OAAO;AACX,WAAS,IAAI,GAAG,IAAI,OAAO,QAAQ,KAAK,GAAG;AACzC,WAAO,OAAO,GAAG,IAAI,GAAG,OAAO,MAAM,GAAG,IAAI,CAAC,CAAC,EAAE,IAAI;AAAA,EACtD;AACA,SAAO;AACT;AAGA,SAAS,cAAc,OAAuB;AAC5C,SAAO,MAAM,QAAQ,UAAU,CAAC,MAAM,OAAO,EAAE,WAAW,CAAC,IAAI,EAAE,CAAC;AACpE;AAUO,SAAS,gBAAgB,SAAiB,MAAsB;AACrE,QAAM,aAAa,GAAG,YAAY,IAAI,CAAC,GAAG,YAAY,OAAO,CAAC;AAC9D,QAAM,QAAQ,KAAK,MAAM,cAAc,UAAU,CAAC;AAClD,SAAO,OAAO,KAAK,EAAE,SAAS,GAAG,GAAG;AACtC;AAOO,SAAS,YAAY,OAAe,OAAiB,OAA2B;AACrF,QAAM,OAAO,YAAY,KAAK;AAC9B,MAAI,CAAC,iBAAiB,KAAK,IAAI,EAAG,QAAO;AACzC,QAAM,WAAW,aAAa,KAAK,MAAM,GAAG,CAAC,CAAC;AAC9C,MAAI,aAAa,OAAW,QAAO;AACnC,MAAI,KAAK,WAAW,SAAU,QAAO;AAGrC,QAAM,QAAQ,KAAK,MAAM,GAAG,CAAC;AAC7B,MAAI,UAAU,QAAQ,UAAU,QAAQ,UAAU,KAAM,QAAO;AAC/D,MAAI,MAAM,cAAc,GAAG,KAAK,MAAM,CAAC,CAAC,GAAG,KAAK,MAAM,GAAG,CAAC,CAAC,EAAE,CAAC,MAAM,EAAG,QAAO;AAC9E,MAAI,SAAS,MAAO,QAAO;AAC3B,QAAM,KAAK,MAAM,IAAI;AACrB,MAAI,SAAS,QAAQ,CAAC,GAAI,QAAO;AACjC,MAAI,SAAS,WAAW,GAAI,QAAO;AACnC,SAAO;AACT;AAOO,SAAS,YAAY,OAAwB;AAClD,SAAO,YAAY,KAAK,MAAM;AAChC;AAIA,SAAS,MAAM,MAAuB;AACpC,QAAM,UAAU,KAAK,MAAM,GAAG,CAAC;AAC/B,MAAI,YAAY,QAAQ,YAAY,KAAM,QAAO;AACjD,QAAM,MAAM,KAAK,MAAM,GAAG,CAAC;AAC3B,MAAI,CAAC,UAAU,KAAK,GAAG,EAAG,QAAO;AACjC,QAAM,IAAI,OAAO,GAAG;AACpB,SAAO,KAAK,OAAS,KAAK;AAC5B;AAYO,SAAS,SAAS,OAAwB;AAC/C,QAAM,OAAO,YAAY,KAAK;AAC9B,SAAO,YAAY,IAAI,MAAM,QAAQ,MAAM,IAAI;AACjD;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/lib/iban.ts"],"sourcesContent":["/**\n * IBAN helpers — ISO 13616 — for kastlan (supplier and customer bank details, the\n * Swiss QR-bill) and keksdose (the accounts behind a household's transfers).\n *\n * Both apps validated an IBAN the same partial way: a regex for the shape, sometimes a\n * mod-97, never the per-country length — so a Swiss IBAN with a digit missing passed\n * as long as the shorter string happened to sum right, and \"CH\" followed by 22\n * characters (a German length) was accepted as Swiss. And kastlan's QR-bill needs a\n * second distinction neither had: a **QR-IBAN** (the institution id at positions 5–9 is\n * 30000–31999) is the only account a QR-bill with a QR reference can be paid to, and it\n * cannot receive an ordinary transfer — so a regular IBAN typed where a QR-IBAN is\n * needed, or the other way round, is a payment that bounces weeks later.\n *\n * Everything here works on the **electronic format** (no spaces, upper case), which is\n * what to store; {@link formatIban} is the paper format, which is what to show.\n *\n * Validation is the registry's two rules, nothing more: the length for the country and\n * the mod-97 check digits. It does not check each country's BBAN layout (which places\n * hold letters), nor whether the bank exists — a checksum catches a typo, not a closed\n * account.\n */\n\n/**\n * The IBAN length for each country in the SWIFT IBAN Registry (2025 edition) — the\n * whole string, country code and check digits included.\n *\n * A country that is not in the table does not issue IBANs, so {@link isValidIban}\n * refuses it (\"XX\" is a typo, not an exotic bank). Territories that use another\n * country's code (Åland under FI, Guernsey under GB, the French overseas departments\n * under FR) are covered by that code, as the registry lists them.\n */\nexport const IBAN_LENGTHS: Readonly<Record<string, number>> = Object.freeze({\n AD: 24,\n AE: 23,\n AL: 28,\n AT: 20,\n AZ: 28,\n BA: 20,\n BE: 16,\n BG: 22,\n BH: 22,\n BI: 27,\n BR: 29,\n BY: 28,\n CH: 21,\n CR: 22,\n CY: 28,\n CZ: 24,\n DE: 22,\n DJ: 27,\n DK: 18,\n DO: 28,\n EE: 20,\n EG: 29,\n ES: 24,\n FI: 18,\n FK: 18,\n FO: 18,\n FR: 27,\n GB: 22,\n GE: 22,\n GI: 23,\n GL: 18,\n GR: 27,\n GT: 28,\n HN: 28,\n HR: 21,\n HU: 28,\n IE: 22,\n IL: 23,\n IQ: 23,\n IS: 26,\n IT: 27,\n JO: 30,\n KW: 30,\n KZ: 20,\n LB: 28,\n LC: 32,\n LI: 21,\n LT: 20,\n LU: 20,\n LV: 21,\n LY: 25,\n MC: 27,\n MD: 24,\n ME: 22,\n MK: 19,\n MN: 20,\n MR: 27,\n MT: 31,\n MU: 30,\n NI: 28,\n NL: 18,\n NO: 15,\n OM: 23,\n PK: 24,\n PL: 28,\n PS: 29,\n PT: 25,\n QA: 29,\n RO: 24,\n RS: 22,\n RU: 33,\n SA: 24,\n SC: 31,\n SD: 18,\n SE: 24,\n SI: 19,\n SK: 24,\n SM: 27,\n SO: 23,\n ST: 25,\n SV: 28,\n TL: 23,\n TN: 24,\n TR: 26,\n UA: 29,\n VA: 22,\n VG: 24,\n XK: 20,\n YE: 30,\n});\n\n/**\n * Which kind of IBAN a field accepts. `\"any\"`: every valid IBAN. `\"qr\"`: only a\n * QR-IBAN (a QR-bill with a QR reference — kastlan's payment slips). `\"plain\"`: every\n * valid IBAN EXCEPT a QR-IBAN (an account for ordinary transfers, which a QR-IBAN\n * cannot receive).\n */\nexport type IbanKind = \"qr\" | \"plain\" | \"any\";\n\n/**\n * Why a value is not the IBAN a field wants, most fundamental first:\n *\n * - `\"format\"` — it does not start with a two-letter country code and two digits;\n * - `\"country\"` — the first two letters are not a country in {@link IBAN_LENGTHS};\n * - `\"length\"` — the wrong number of characters for that country;\n * - `\"checksum\"` — the mod-97 check digits do not match (a typo, a swapped pair);\n * - `\"qrRequired\"` — a valid IBAN, but not a QR-IBAN where `kind=\"qr\"` needs one;\n * - `\"qrNotAllowed\"` — a valid QR-IBAN where `kind=\"plain\"` refuses one.\n */\nexport type IbanProblem = \"format\" | \"country\" | \"length\" | \"checksum\" | \"qrRequired\" | \"qrNotAllowed\";\n\n/**\n * The electronic format: every character that is not a letter or a digit dropped, the\n * letters in upper case. Spaces, non-breaking spaces, hyphens and dots — what a paste\n * from an invoice, a PDF or an e-banking screen carries — all go.\n *\n * A leading `\"IBAN\"` (the paper format's label: \"IBAN CH56 …\") is dropped too, but only\n * in front of a country code and check digits, so typing the word does not make it\n * vanish under the caret.\n */\nexport function compactIban(value: string): string {\n const compact = value.replace(/[^0-9A-Za-z]/g, \"\").toUpperCase();\n return /^IBAN[A-Z]{2}\\d{2}/.test(compact) ? compact.slice(4) : compact;\n}\n\n/**\n * The paper format: {@link compactIban}, then groups of four separated by a single\n * space (\"CH09 0000 0000 0000 0000 1\"). The last group is whatever is left over.\n */\nexport function formatIban(value: string): string {\n return compactIban(value).replace(/(.{4})(?=.)/g, \"$1 \");\n}\n\n/** The remainder of a long digit string modulo 97, a few digits at a time so it never\n * leaves the safe-integer range — ISO 7064 MOD 97-10 without a BigInt. */\nfunction mod97(digits: string): number {\n let rest = 0;\n for (let i = 0; i < digits.length; i += 7) {\n rest = Number(`${rest}${digits.slice(i, i + 7)}`) % 97;\n }\n return rest;\n}\n\n/** Letters to their two-digit numbers (A = 10 … Z = 35), digits as they are. */\nfunction expandLetters(value: string): string {\n return value.replace(/[A-Z]/g, (c) => String(c.charCodeAt(0) - 55));\n}\n\n/**\n * The two check digits for a country code and a BBAN (the account part after them):\n * `98 − ((BBAN + country + \"00\") mod 97)`, letters expanded A = 10 … Z = 35.\n *\n * What an app needs to BUILD an IBAN — a test fixture, a demo, a migration that\n * assembles one from a clearing number and an account number — rather than to check\n * one. Both arguments are compacted first.\n */\nexport function ibanCheckDigits(country: string, bban: string): string {\n const rearranged = `${compactIban(bban)}${compactIban(country)}00`;\n const check = 98 - mod97(expandLetters(rearranged));\n return String(check).padStart(2, \"0\");\n}\n\n/**\n * The first thing wrong with `value` as an IBAN of `kind`, or `null` when it is one.\n * The value is compacted first, so the paper format is accepted. An empty value is\n * `\"format\"`: whether a field may be left empty is the form's rule, not the IBAN's.\n */\nexport function ibanProblem(value: string, kind: IbanKind = \"any\"): IbanProblem | null {\n const iban = compactIban(value);\n if (!/^[A-Z]{2}\\d{2}/.test(iban)) return \"format\";\n const expected = IBAN_LENGTHS[iban.slice(0, 2)];\n if (expected === undefined) return \"country\";\n if (iban.length !== expected) return \"length\";\n // 00, 01 and 99 are never computed (98 − r lies in 2…98), but 01 ≡ 98 mod 97 and\n // would otherwise pass the remainder test below — ISO 7064 rules them out.\n const check = iban.slice(2, 4);\n if (check === \"00\" || check === \"01\" || check === \"99\") return \"checksum\";\n if (mod97(expandLetters(`${iban.slice(4)}${iban.slice(0, 4)}`)) !== 1) return \"checksum\";\n if (kind === \"any\") return null;\n const qr = qrIid(iban);\n if (kind === \"qr\" && !qr) return \"qrRequired\";\n if (kind === \"plain\" && qr) return \"qrNotAllowed\";\n return null;\n}\n\n/**\n * Whether `value` is a valid IBAN: a registry country, that country's length, and\n * matching mod-97 check digits. Spaces and lower case are fine (see\n * {@link compactIban}); what to store is the compact form.\n */\nexport function isValidIban(value: string): boolean {\n return ibanProblem(value) === null;\n}\n\n/** A CH/LI IBAN whose institution id (positions 5–9) is in the QR range. Structure\n * only — the caller has checked or will check the rest. */\nfunction qrIid(iban: string): boolean {\n const country = iban.slice(0, 2);\n if (country !== \"CH\" && country !== \"LI\") return false;\n const iid = iban.slice(4, 9);\n if (!/^\\d{5}$/.test(iid)) return false;\n const n = Number(iid);\n return n >= 30000 && n <= 31999;\n}\n\n/**\n * Whether `value` is a valid **QR-IBAN**: a valid Swiss or Liechtenstein IBAN whose\n * institution id — positions 5 to 9, the QR-IID — lies in 30000–31999 (SIX, Swiss\n * Implementation Guidelines for the QR-bill).\n *\n * The distinction is not cosmetic (kastlan): a QR-bill with a QR reference can only be\n * paid to a QR-IBAN, and a QR-IBAN can receive nothing else — so an app that prints\n * payment slips needs one, and an app that stores an account for ordinary transfers\n * must not take one.\n */\nexport function isQrIban(value: string): boolean {\n const iban = compactIban(value);\n return ibanProblem(iban) === null && qrIid(iban);\n}\n"],"mappings":";AA+BO,MAAM,eAAiD,OAAO,OAAO;AAAA,EAC1E,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AACN,CAAC;AA+BM,SAAS,YAAY,OAAuB;AACjD,QAAM,UAAU,MAAM,QAAQ,iBAAiB,EAAE,EAAE,YAAY;AAC/D,SAAO,qBAAqB,KAAK,OAAO,IAAI,QAAQ,MAAM,CAAC,IAAI;AACjE;AAMO,SAAS,WAAW,OAAuB;AAChD,SAAO,YAAY,KAAK,EAAE,QAAQ,gBAAgB,KAAK;AACzD;AAIA,SAAS,MAAM,QAAwB;AACrC,MAAI,OAAO;AACX,WAAS,IAAI,GAAG,IAAI,OAAO,QAAQ,KAAK,GAAG;AACzC,WAAO,OAAO,GAAG,IAAI,GAAG,OAAO,MAAM,GAAG,IAAI,CAAC,CAAC,EAAE,IAAI;AAAA,EACtD;AACA,SAAO;AACT;AAGA,SAAS,cAAc,OAAuB;AAC5C,SAAO,MAAM,QAAQ,UAAU,CAAC,MAAM,OAAO,EAAE,WAAW,CAAC,IAAI,EAAE,CAAC;AACpE;AAUO,SAAS,gBAAgB,SAAiB,MAAsB;AACrE,QAAM,aAAa,GAAG,YAAY,IAAI,CAAC,GAAG,YAAY,OAAO,CAAC;AAC9D,QAAM,QAAQ,KAAK,MAAM,cAAc,UAAU,CAAC;AAClD,SAAO,OAAO,KAAK,EAAE,SAAS,GAAG,GAAG;AACtC;AAOO,SAAS,YAAY,OAAe,OAAiB,OAA2B;AACrF,QAAM,OAAO,YAAY,KAAK;AAC9B,MAAI,CAAC,iBAAiB,KAAK,IAAI,EAAG,QAAO;AACzC,QAAM,WAAW,aAAa,KAAK,MAAM,GAAG,CAAC,CAAC;AAC9C,MAAI,aAAa,OAAW,QAAO;AACnC,MAAI,KAAK,WAAW,SAAU,QAAO;AAGrC,QAAM,QAAQ,KAAK,MAAM,GAAG,CAAC;AAC7B,MAAI,UAAU,QAAQ,UAAU,QAAQ,UAAU,KAAM,QAAO;AAC/D,MAAI,MAAM,cAAc,GAAG,KAAK,MAAM,CAAC,CAAC,GAAG,KAAK,MAAM,GAAG,CAAC,CAAC,EAAE,CAAC,MAAM,EAAG,QAAO;AAC9E,MAAI,SAAS,MAAO,QAAO;AAC3B,QAAM,KAAK,MAAM,IAAI;AACrB,MAAI,SAAS,QAAQ,CAAC,GAAI,QAAO;AACjC,MAAI,SAAS,WAAW,GAAI,QAAO;AACnC,SAAO;AACT;AAOO,SAAS,YAAY,OAAwB;AAClD,SAAO,YAAY,KAAK,MAAM;AAChC;AAIA,SAAS,MAAM,MAAuB;AACpC,QAAM,UAAU,KAAK,MAAM,GAAG,CAAC;AAC/B,MAAI,YAAY,QAAQ,YAAY,KAAM,QAAO;AACjD,QAAM,MAAM,KAAK,MAAM,GAAG,CAAC;AAC3B,MAAI,CAAC,UAAU,KAAK,GAAG,EAAG,QAAO;AACjC,QAAM,IAAI,OAAO,GAAG;AACpB,SAAO,KAAK,OAAS,KAAK;AAC5B;AAYO,SAAS,SAAS,OAAwB;AAC/C,QAAM,OAAO,YAAY,KAAK;AAC9B,SAAO,YAAY,IAAI,MAAM,QAAQ,MAAM,IAAI;AACjD;","names":[]}
|
package/dist/lib/logger.js
CHANGED
|
@@ -14,11 +14,7 @@ const loggerImpl = (f, name) => (set, get, store) => {
|
|
|
14
14
|
const prev = get();
|
|
15
15
|
set(...args);
|
|
16
16
|
if (storeLog) {
|
|
17
|
-
console.debug(
|
|
18
|
-
`%c[store${name ? `:${name}` : ""}]`,
|
|
19
|
-
"color:#0d9488",
|
|
20
|
-
{ prev, next: get() }
|
|
21
|
-
);
|
|
17
|
+
console.debug(`%c[store${name ? `:${name}` : ""}]`, "color:#0d9488", { prev, next: get() });
|
|
22
18
|
}
|
|
23
19
|
});
|
|
24
20
|
return f(loggedSet, get, store);
|
package/dist/lib/logger.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/lib/logger.ts"],"sourcesContent":["import type { StateCreator, StoreMutatorIdentifier } from \"zustand\";\n\nimport { readStored } from \"./safe-storage\";\n\n/**\n * zustand logging middleware.\n *\n * Logs every state transition (a store \"action\") to the DevTools console as a\n * single grouped line showing the prev/next state. Enabled in dev (Vite\n * `import.meta.env.DEV`) by default; override at runtime with\n * `localStorage.setItem(\"store_log\", \"0\")` to silence or `\"1\"` to force-on in a\n * production build.\n */\ntype Logger = <\n T,\n Mps extends [StoreMutatorIdentifier, unknown][] = [],\n Mcs extends [StoreMutatorIdentifier, unknown][] = [],\n>(\n f: StateCreator<T, Mps, Mcs>,\n name?: string,\n) => StateCreator<T, Mps, Mcs>;\n\ntype LoggerImpl = <T>(f: StateCreator<T, [], []>, name?: string) => StateCreator<T, [], []>;\n\n/**\n * Read ONCE, at module scope, not on every `set`.\n *\n * This used to run inside the wrapped `set`, which means a synchronous\n * `localStorage.getItem` on every state transition of every logged store — in\n * production builds too, because the override has to stay readable there by design.\n * The stores this middleware wraps include ones that transition per pointer event.\n *\n * The override cannot change without someone opening devtools and reloading, so\n * once per session is the same behaviour at a fraction of the cost. {@link setStoreLog}\n * keeps the ability to flip it live from a console, which is the only thing the\n * per-call read was actually buying.\n */\nlet storeLog: boolean = (() => {\n const override = readStored(\"store_log\");\n if (override === \"0\") return false;\n if (override === \"1\") return true;\n // NOT under vitest. `DEV` distinguishes a dev build from a production one; a test\n // run is neither, and vitest sets `DEV=true` — so every transition of every logged\n // store printed a prev/next object into the suite output. Measured on the consumer's\n // suite: 822 blocks, several thousand lines, in a log whose whole job is to make one\n // real `console.error` findable. `store_log=1` still forces it on.\n // Optional-chained because `import.meta.env` is a Vite injection: it is\n // undefined under plain Node ESM, and this package is published publicly,\n // so a non-Vite consumer must be able to import it without throwing.\n return Boolean(import.meta.env?.DEV) && !import.meta.env?.VITEST;\n})();\n\n/** Turn store logging on or off for the rest of the session (devtools escape hatch). */\nexport function setStoreLog(enabled: boolean): void {\n storeLog = enabled;\n}\n\nconst loggerImpl: LoggerImpl = (f, name) => (set, get, store) => {\n const loggedSet = ((...args: unknown[]) => {\n const prev = get();\n (set as (...a: unknown[]) => void)(...args);\n if (storeLog) {\n console.debug(
|
|
1
|
+
{"version":3,"sources":["../../src/lib/logger.ts"],"sourcesContent":["import type { StateCreator, StoreMutatorIdentifier } from \"zustand\";\n\nimport { readStored } from \"./safe-storage\";\n\n/**\n * zustand logging middleware.\n *\n * Logs every state transition (a store \"action\") to the DevTools console as a\n * single grouped line showing the prev/next state. Enabled in dev (Vite\n * `import.meta.env.DEV`) by default; override at runtime with\n * `localStorage.setItem(\"store_log\", \"0\")` to silence or `\"1\"` to force-on in a\n * production build.\n */\ntype Logger = <\n T,\n Mps extends [StoreMutatorIdentifier, unknown][] = [],\n Mcs extends [StoreMutatorIdentifier, unknown][] = [],\n>(\n f: StateCreator<T, Mps, Mcs>,\n name?: string,\n) => StateCreator<T, Mps, Mcs>;\n\ntype LoggerImpl = <T>(f: StateCreator<T, [], []>, name?: string) => StateCreator<T, [], []>;\n\n/**\n * Read ONCE, at module scope, not on every `set`.\n *\n * This used to run inside the wrapped `set`, which means a synchronous\n * `localStorage.getItem` on every state transition of every logged store — in\n * production builds too, because the override has to stay readable there by design.\n * The stores this middleware wraps include ones that transition per pointer event.\n *\n * The override cannot change without someone opening devtools and reloading, so\n * once per session is the same behaviour at a fraction of the cost. {@link setStoreLog}\n * keeps the ability to flip it live from a console, which is the only thing the\n * per-call read was actually buying.\n */\nlet storeLog: boolean = (() => {\n const override = readStored(\"store_log\");\n if (override === \"0\") return false;\n if (override === \"1\") return true;\n // NOT under vitest. `DEV` distinguishes a dev build from a production one; a test\n // run is neither, and vitest sets `DEV=true` — so every transition of every logged\n // store printed a prev/next object into the suite output. Measured on the consumer's\n // suite: 822 blocks, several thousand lines, in a log whose whole job is to make one\n // real `console.error` findable. `store_log=1` still forces it on.\n // Optional-chained because `import.meta.env` is a Vite injection: it is\n // undefined under plain Node ESM, and this package is published publicly,\n // so a non-Vite consumer must be able to import it without throwing.\n return Boolean(import.meta.env?.DEV) && !import.meta.env?.VITEST;\n})();\n\n/** Turn store logging on or off for the rest of the session (devtools escape hatch). */\nexport function setStoreLog(enabled: boolean): void {\n storeLog = enabled;\n}\n\nconst loggerImpl: LoggerImpl = (f, name) => (set, get, store) => {\n const loggedSet = ((...args: unknown[]) => {\n const prev = get();\n (set as (...a: unknown[]) => void)(...args);\n if (storeLog) {\n console.debug(`%c[store${name ? `:${name}` : \"\"}]`, \"color:#0d9488\", { prev, next: get() });\n }\n }) as typeof set;\n return f(loggedSet, get, store);\n};\n\nexport const logger = loggerImpl as unknown as Logger;\n"],"mappings":";AAEA,SAAS,kBAAkB;AAmC3B,IAAI,YAAqB,MAAM;AAC7B,QAAM,WAAW,WAAW,WAAW;AACvC,MAAI,aAAa,IAAK,QAAO;AAC7B,MAAI,aAAa,IAAK,QAAO;AAS7B,SAAO,QAAQ,YAAY,KAAK,GAAG,KAAK,CAAC,YAAY,KAAK;AAC5D,GAAG;AAGI,SAAS,YAAY,SAAwB;AAClD,aAAW;AACb;AAEA,MAAM,aAAyB,CAAC,GAAG,SAAS,CAAC,KAAK,KAAK,UAAU;AAC/D,QAAM,aAAa,IAAI,SAAoB;AACzC,UAAM,OAAO,IAAI;AACjB,IAAC,IAAkC,GAAG,IAAI;AAC1C,QAAI,UAAU;AACZ,cAAQ,MAAM,WAAW,OAAO,IAAI,IAAI,KAAK,EAAE,KAAK,iBAAiB,EAAE,MAAM,MAAM,IAAI,EAAE,CAAC;AAAA,IAC5F;AAAA,EACF;AACA,SAAO,EAAE,WAAW,KAAK,KAAK;AAChC;AAEO,MAAM,SAAS;","names":[]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/lib/person-name.ts"],"sourcesContent":["/**\n * A person's name as the reader's language writes it — the one rule of\n * docs/auth-harmonization.md §3.2 for every place a name is shown: the top bar, the\n * account menu, user lists, avatars, feedback, the greeting in a mail.\n *\n * WHY A FUNCTION AND NOT `${first} ${last}`. The three apps move from one free-text\n * `display_name` to `first_name` + `last_name` (§2.3), and the moment a name has two\n * parts, their ORDER is a language rule, not a data rule:\n *\n * - \"First Last\" in de-CH, en, fr, it and es — \"Ada Example\";\n * - **\"Last First\" in hu** — Hungarian puts the family name first: \"Example Ada\";\n * - **\"LastFirst\" with no space in zh** — a Chinese name is written family name first\n * and run together: 李 + 小龙 → \"李小龙\". Only a name written in CJK characters,\n * though: BOTH parts must hold Han (or kana, or hangul) characters. A Latin name read\n * in Chinese stays as it is written — \"Ada Lovelace\", not \"LovelaceAda\" (the\n * coordinator's refinement of §3.2, 2026-10-06).\n *\n * WHOSE language (§3.2, §10.6): on screen the READER's, so this takes the locale of the\n * UI, never the locale stored with the person. A mail uses the recipient's and a frozen\n * stamp the author's — that is server-kit's `full_name(first, last, locale)`, which\n * follows exactly these rules so a name reads the same in the app and in its mails.\n *\n * NOTHING IS GUESSED. Both parts are trimmed, and an empty part simply drops out: a\n * migrated keksdose or Kurvenschmiede user keeps the whole old display name as the\n * first name and an empty last name until they complete it (§3.3), and \"Ada Example\"\n * with no last name must still read \"Ada Example\" — not \"Ada Example \" and not a split\n * at the space. Inner spaces are the person's own and stay as typed.\n *\n * Only the language subtag counts (\"de-CH\" → \"de\", \"zh-Hant-TW\" → \"zh\"), and a language\n * this does not know reads \"First Last\", the order of the kit's five other languages.\n */\n\n/** The two parts of a person's name. Either may be missing or blank: a migrated row\n * has an empty `last` until it is completed (§3.3), and APIs send `null` for \"none\". */\nexport interface PersonName {\n /** The given name — `first_name`. */\n first?: string | null;\n /** The family name — `last_name`. */\n last?: string | null;\n}\n\ntype NameOrder = \"given-first\" | \"family-first\" | \"family-first-joined\";\n\n/** A part written in CJK characters: Han, and kana or hangul where they occur (a\n * Japanese or Korean name in a Chinese UI). One such character is enough. */\nconst CJK = /\\p{Script=Han}|\\p{Script=Hiragana}|\\p{Script=Katakana}|\\p{Script=Hangul}/u;\n\nfunction language(locale: string | undefined): string {\n return (locale ?? \"\").split(/[-_]/)[0]!.toLowerCase();\n}\n\n/**\n * The order the reader's language writes THIS name in. See the module note: hu puts the\n * family name first for every name; zh runs a CJK name together family-first and leaves\n * any other name as it is written; the rest write the given name first. A part that is\n * empty passes the CJK test, so a one-part name orders by the part it has. Called with\n * at least one part filled in.\n */\nfunction nameOrder(locale: string | undefined, first: string, last: string): NameOrder {\n const lang = language(locale);\n if (lang === \"hu\") return \"family-first\";\n if (lang === \"zh\" && (!first || CJK.test(first)) && (!last || CJK.test(last))) {\n return \"family-first-joined\";\n }\n return \"given-first\";\n}\n\nfunction clean(part: string | null | undefined): string {\n return (part ?? \"\").trim();\n}\n\n/**\n * The full name in the reader's order: \"Ada Example\" (en, de, fr, it, es), \"Example Ada\"\n * (hu), \"李小龙\" (zh, for a name in CJK characters — \"Ada Lovelace\" stays so). Trimmed;\n * with one part empty, the other alone; with both empty, `\"\"` — so a caller can fall\n * back with `||` (`formatPersonName(p, l) || email`).\n *\n * `locale` is the READER's — the UI language, `useKitLocale()` in a component. Left out,\n * the given-name-first order.\n */\nexport function formatPersonName(name: PersonName, locale?: string): string {\n const first = clean(name.first);\n const last = clean(name.last);\n if (!last) return first;\n if (!first) return last;\n switch (nameOrder(locale, first, last)) {\n case \"family-first\":\n return `${last} ${first}`;\n case \"family-first-joined\":\n return `${last}${first}`;\n default:\n return `${first} ${last}`;\n }\n}\n\n/** One segmenter for every avatar on the page, made on first use. `null`: the runtime\n * has none, and code points have to do. */\nlet graphemes: Intl.Segmenter | null | undefined;\n\n/** The first user-perceived character — \"É\" whether it was typed as one code point or\n * as E + a combining accent, a whole emoji rather than half a surrogate pair. */\nfunction firstGrapheme(text: string): string {\n graphemes ??=\n typeof Intl !== \"undefined\" && \"Segmenter\" in Intl\n ? new Intl.Segmenter(undefined, { granularity: \"grapheme\" })\n : null;\n if (graphemes) {\n const segment = graphemes.segment(text)[Symbol.iterator]().next();\n if (!segment.done) return segment.value.segment;\n }\n return [...text][0] ?? \"\";\n}\n\n/** Upper case under the reader's rules (Turkish \"i\" → \"İ\"), and plain upper case for a\n * locale tag the runtime refuses rather than a thrown RangeError in a render. */\nfunction upper(text: string, locale: string | undefined): string {\n try {\n return text.toLocaleUpperCase(locale);\n } catch {\n return text.toUpperCase();\n }\n}\n\n/**\n * The initials for an avatar, in the same order as {@link formatPersonName}:\n *\n * - \"AE\" for Ada Example in en, de, fr, it and es;\n * - \"EA\" in hu, family name first;\n * - **one character in zh for a name in CJK characters, the family name's first** —\n * \"李\" for 李小龙. Two Chinese characters are not initials, they are most of a name.\n * A Latin name in zh is \"AE\", as it is written.\n *\n * With only one part (a migrated user's whole old display name in `first`, §3.3), the\n * initials are what {@link avatarInitials} has always made of a display name: the first\n * letters of its first two words (\"Ada Example\" → \"AE\"), or the first two letters of a\n * single word (\"Ada\" → \"AD\") — so nobody's avatar changes when their account migrates.\n * In zh, a part in CJK characters gives its first character.\n *\n * `\"\"` when both parts are blank; {@link UserAvatar} then falls back to the email.\n */\nexport function personInitials(name: PersonName, locale?: string): string {\n const first = clean(name.first);\n const last = clean(name.last);\n if (!first && !last) return \"\";\n const order = nameOrder(locale, first, last);\n if (order === \"family-first-joined\") return upper(firstGrapheme(last || first), locale);\n if (!first || !last) {\n const words = (first || last).split(/\\s+/);\n const initials =\n words.length >= 2
|
|
1
|
+
{"version":3,"sources":["../../src/lib/person-name.ts"],"sourcesContent":["/**\n * A person's name as the reader's language writes it — the one rule of\n * docs/auth-harmonization.md §3.2 for every place a name is shown: the top bar, the\n * account menu, user lists, avatars, feedback, the greeting in a mail.\n *\n * WHY A FUNCTION AND NOT `${first} ${last}`. The three apps move from one free-text\n * `display_name` to `first_name` + `last_name` (§2.3), and the moment a name has two\n * parts, their ORDER is a language rule, not a data rule:\n *\n * - \"First Last\" in de-CH, en, fr, it and es — \"Ada Example\";\n * - **\"Last First\" in hu** — Hungarian puts the family name first: \"Example Ada\";\n * - **\"LastFirst\" with no space in zh** — a Chinese name is written family name first\n * and run together: 李 + 小龙 → \"李小龙\". Only a name written in CJK characters,\n * though: BOTH parts must hold Han (or kana, or hangul) characters. A Latin name read\n * in Chinese stays as it is written — \"Ada Lovelace\", not \"LovelaceAda\" (the\n * coordinator's refinement of §3.2, 2026-10-06).\n *\n * WHOSE language (§3.2, §10.6): on screen the READER's, so this takes the locale of the\n * UI, never the locale stored with the person. A mail uses the recipient's and a frozen\n * stamp the author's — that is server-kit's `full_name(first, last, locale)`, which\n * follows exactly these rules so a name reads the same in the app and in its mails.\n *\n * NOTHING IS GUESSED. Both parts are trimmed, and an empty part simply drops out: a\n * migrated keksdose or Kurvenschmiede user keeps the whole old display name as the\n * first name and an empty last name until they complete it (§3.3), and \"Ada Example\"\n * with no last name must still read \"Ada Example\" — not \"Ada Example \" and not a split\n * at the space. Inner spaces are the person's own and stay as typed.\n *\n * Only the language subtag counts (\"de-CH\" → \"de\", \"zh-Hant-TW\" → \"zh\"), and a language\n * this does not know reads \"First Last\", the order of the kit's five other languages.\n */\n\n/** The two parts of a person's name. Either may be missing or blank: a migrated row\n * has an empty `last` until it is completed (§3.3), and APIs send `null` for \"none\". */\nexport interface PersonName {\n /** The given name — `first_name`. */\n first?: string | null;\n /** The family name — `last_name`. */\n last?: string | null;\n}\n\ntype NameOrder = \"given-first\" | \"family-first\" | \"family-first-joined\";\n\n/** A part written in CJK characters: Han, and kana or hangul where they occur (a\n * Japanese or Korean name in a Chinese UI). One such character is enough. */\nconst CJK = /\\p{Script=Han}|\\p{Script=Hiragana}|\\p{Script=Katakana}|\\p{Script=Hangul}/u;\n\nfunction language(locale: string | undefined): string {\n return (locale ?? \"\").split(/[-_]/)[0]!.toLowerCase();\n}\n\n/**\n * The order the reader's language writes THIS name in. See the module note: hu puts the\n * family name first for every name; zh runs a CJK name together family-first and leaves\n * any other name as it is written; the rest write the given name first. A part that is\n * empty passes the CJK test, so a one-part name orders by the part it has. Called with\n * at least one part filled in.\n */\nfunction nameOrder(locale: string | undefined, first: string, last: string): NameOrder {\n const lang = language(locale);\n if (lang === \"hu\") return \"family-first\";\n if (lang === \"zh\" && (!first || CJK.test(first)) && (!last || CJK.test(last))) {\n return \"family-first-joined\";\n }\n return \"given-first\";\n}\n\nfunction clean(part: string | null | undefined): string {\n return (part ?? \"\").trim();\n}\n\n/**\n * The full name in the reader's order: \"Ada Example\" (en, de, fr, it, es), \"Example Ada\"\n * (hu), \"李小龙\" (zh, for a name in CJK characters — \"Ada Lovelace\" stays so). Trimmed;\n * with one part empty, the other alone; with both empty, `\"\"` — so a caller can fall\n * back with `||` (`formatPersonName(p, l) || email`).\n *\n * `locale` is the READER's — the UI language, `useKitLocale()` in a component. Left out,\n * the given-name-first order.\n */\nexport function formatPersonName(name: PersonName, locale?: string): string {\n const first = clean(name.first);\n const last = clean(name.last);\n if (!last) return first;\n if (!first) return last;\n switch (nameOrder(locale, first, last)) {\n case \"family-first\":\n return `${last} ${first}`;\n case \"family-first-joined\":\n return `${last}${first}`;\n default:\n return `${first} ${last}`;\n }\n}\n\n/** One segmenter for every avatar on the page, made on first use. `null`: the runtime\n * has none, and code points have to do. */\nlet graphemes: Intl.Segmenter | null | undefined;\n\n/** The first user-perceived character — \"É\" whether it was typed as one code point or\n * as E + a combining accent, a whole emoji rather than half a surrogate pair. */\nfunction firstGrapheme(text: string): string {\n graphemes ??=\n typeof Intl !== \"undefined\" && \"Segmenter\" in Intl\n ? new Intl.Segmenter(undefined, { granularity: \"grapheme\" })\n : null;\n if (graphemes) {\n const segment = graphemes.segment(text)[Symbol.iterator]().next();\n if (!segment.done) return segment.value.segment;\n }\n return [...text][0] ?? \"\";\n}\n\n/** Upper case under the reader's rules (Turkish \"i\" → \"İ\"), and plain upper case for a\n * locale tag the runtime refuses rather than a thrown RangeError in a render. */\nfunction upper(text: string, locale: string | undefined): string {\n try {\n return text.toLocaleUpperCase(locale);\n } catch {\n return text.toUpperCase();\n }\n}\n\n/**\n * The initials for an avatar, in the same order as {@link formatPersonName}:\n *\n * - \"AE\" for Ada Example in en, de, fr, it and es;\n * - \"EA\" in hu, family name first;\n * - **one character in zh for a name in CJK characters, the family name's first** —\n * \"李\" for 李小龙. Two Chinese characters are not initials, they are most of a name.\n * A Latin name in zh is \"AE\", as it is written.\n *\n * With only one part (a migrated user's whole old display name in `first`, §3.3), the\n * initials are what {@link avatarInitials} has always made of a display name: the first\n * letters of its first two words (\"Ada Example\" → \"AE\"), or the first two letters of a\n * single word (\"Ada\" → \"AD\") — so nobody's avatar changes when their account migrates.\n * In zh, a part in CJK characters gives its first character.\n *\n * `\"\"` when both parts are blank; {@link UserAvatar} then falls back to the email.\n */\nexport function personInitials(name: PersonName, locale?: string): string {\n const first = clean(name.first);\n const last = clean(name.last);\n if (!first && !last) return \"\";\n const order = nameOrder(locale, first, last);\n if (order === \"family-first-joined\") return upper(firstGrapheme(last || first), locale);\n if (!first || !last) {\n const words = (first || last).split(/\\s+/);\n const initials =\n words.length >= 2 ? firstGrapheme(words[0]!) + firstGrapheme(words[1]!) : [...words[0]!].slice(0, 2).join(\"\");\n return upper(initials, locale);\n }\n const pair = order === \"family-first\" ? [last, first] : [first, last];\n return upper(firstGrapheme(pair[0]!) + firstGrapheme(pair[1]!), locale);\n}\n"],"mappings":";AA6CA,MAAM,MAAM;AAEZ,SAAS,SAAS,QAAoC;AACpD,UAAQ,UAAU,IAAI,MAAM,MAAM,EAAE,CAAC,EAAG,YAAY;AACtD;AASA,SAAS,UAAU,QAA4B,OAAe,MAAyB;AACrF,QAAM,OAAO,SAAS,MAAM;AAC5B,MAAI,SAAS,KAAM,QAAO;AAC1B,MAAI,SAAS,SAAS,CAAC,SAAS,IAAI,KAAK,KAAK,OAAO,CAAC,QAAQ,IAAI,KAAK,IAAI,IAAI;AAC7E,WAAO;AAAA,EACT;AACA,SAAO;AACT;AAEA,SAAS,MAAM,MAAyC;AACtD,UAAQ,QAAQ,IAAI,KAAK;AAC3B;AAWO,SAAS,iBAAiB,MAAkB,QAAyB;AAC1E,QAAM,QAAQ,MAAM,KAAK,KAAK;AAC9B,QAAM,OAAO,MAAM,KAAK,IAAI;AAC5B,MAAI,CAAC,KAAM,QAAO;AAClB,MAAI,CAAC,MAAO,QAAO;AACnB,UAAQ,UAAU,QAAQ,OAAO,IAAI,GAAG;AAAA,IACtC,KAAK;AACH,aAAO,GAAG,IAAI,IAAI,KAAK;AAAA,IACzB,KAAK;AACH,aAAO,GAAG,IAAI,GAAG,KAAK;AAAA,IACxB;AACE,aAAO,GAAG,KAAK,IAAI,IAAI;AAAA,EAC3B;AACF;AAIA,IAAI;AAIJ,SAAS,cAAc,MAAsB;AAC3C,gBACE,OAAO,SAAS,eAAe,eAAe,OAC1C,IAAI,KAAK,UAAU,QAAW,EAAE,aAAa,WAAW,CAAC,IACzD;AACN,MAAI,WAAW;AACb,UAAM,UAAU,UAAU,QAAQ,IAAI,EAAE,OAAO,QAAQ,EAAE,EAAE,KAAK;AAChE,QAAI,CAAC,QAAQ,KAAM,QAAO,QAAQ,MAAM;AAAA,EAC1C;AACA,SAAO,CAAC,GAAG,IAAI,EAAE,CAAC,KAAK;AACzB;AAIA,SAAS,MAAM,MAAc,QAAoC;AAC/D,MAAI;AACF,WAAO,KAAK,kBAAkB,MAAM;AAAA,EACtC,QAAQ;AACN,WAAO,KAAK,YAAY;AAAA,EAC1B;AACF;AAmBO,SAAS,eAAe,MAAkB,QAAyB;AACxE,QAAM,QAAQ,MAAM,KAAK,KAAK;AAC9B,QAAM,OAAO,MAAM,KAAK,IAAI;AAC5B,MAAI,CAAC,SAAS,CAAC,KAAM,QAAO;AAC5B,QAAM,QAAQ,UAAU,QAAQ,OAAO,IAAI;AAC3C,MAAI,UAAU,sBAAuB,QAAO,MAAM,cAAc,QAAQ,KAAK,GAAG,MAAM;AACtF,MAAI,CAAC,SAAS,CAAC,MAAM;AACnB,UAAM,SAAS,SAAS,MAAM,MAAM,KAAK;AACzC,UAAM,WACJ,MAAM,UAAU,IAAI,cAAc,MAAM,CAAC,CAAE,IAAI,cAAc,MAAM,CAAC,CAAE,IAAI,CAAC,GAAG,MAAM,CAAC,CAAE,EAAE,MAAM,GAAG,CAAC,EAAE,KAAK,EAAE;AAC9G,WAAO,MAAM,UAAU,MAAM;AAAA,EAC/B;AACA,QAAM,OAAO,UAAU,iBAAiB,CAAC,MAAM,KAAK,IAAI,CAAC,OAAO,IAAI;AACpE,SAAO,MAAM,cAAc,KAAK,CAAC,CAAE,IAAI,cAAc,KAAK,CAAC,CAAE,GAAG,MAAM;AACxE;","names":[]}
|