@vit-foundation/ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +129 -0
  3. package/dist/components/jobs/JobList.svelte +88 -0
  4. package/dist/components/jobs/JobList.svelte.d.ts +18 -0
  5. package/dist/components/jobs/index.d.ts +2 -0
  6. package/dist/components/jobs/index.js +1 -0
  7. package/dist/components/layout/Footer.svelte +79 -0
  8. package/dist/components/layout/Footer.svelte.d.ts +12 -0
  9. package/dist/components/layout/Nav.svelte +229 -0
  10. package/dist/components/layout/Nav.svelte.d.ts +18 -0
  11. package/dist/components/layout/PageShell.svelte +107 -0
  12. package/dist/components/layout/PageShell.svelte.d.ts +42 -0
  13. package/dist/components/layout/index.d.ts +3 -0
  14. package/dist/components/layout/index.js +3 -0
  15. package/dist/components/projects/ProjectCard.svelte +107 -0
  16. package/dist/components/projects/ProjectCard.svelte.d.ts +17 -0
  17. package/dist/components/projects/index.d.ts +2 -0
  18. package/dist/components/projects/index.js +1 -0
  19. package/dist/components/team/CollaboratorList.svelte +37 -0
  20. package/dist/components/team/CollaboratorList.svelte.d.ts +7 -0
  21. package/dist/components/team/TeamMemberCard.svelte +78 -0
  22. package/dist/components/team/TeamMemberCard.svelte.d.ts +21 -0
  23. package/dist/components/team/index.d.ts +3 -0
  24. package/dist/components/team/index.js +2 -0
  25. package/dist/components/timeline/Timeline.svelte +116 -0
  26. package/dist/components/timeline/Timeline.svelte.d.ts +12 -0
  27. package/dist/components/timeline/TimelineMilestone.svelte +127 -0
  28. package/dist/components/timeline/TimelineMilestone.svelte.d.ts +15 -0
  29. package/dist/components/timeline/index.d.ts +3 -0
  30. package/dist/components/timeline/index.js +2 -0
  31. package/dist/components/ui/Button.svelte +53 -0
  32. package/dist/components/ui/Button.svelte.d.ts +21 -0
  33. package/dist/components/ui/CardMedia.svelte +23 -0
  34. package/dist/components/ui/CardMedia.svelte.d.ts +11 -0
  35. package/dist/components/ui/CardTitle.svelte +28 -0
  36. package/dist/components/ui/CardTitle.svelte.d.ts +8 -0
  37. package/dist/components/ui/CopyIntro.svelte +40 -0
  38. package/dist/components/ui/CopyIntro.svelte.d.ts +24 -0
  39. package/dist/components/ui/DateText.svelte +43 -0
  40. package/dist/components/ui/DateText.svelte.d.ts +28 -0
  41. package/dist/components/ui/DecorShapes.svelte +36 -0
  42. package/dist/components/ui/DecorShapes.svelte.d.ts +7 -0
  43. package/dist/components/ui/Field.svelte +89 -0
  44. package/dist/components/ui/Field.svelte.d.ts +55 -0
  45. package/dist/components/ui/FilterChips.svelte +43 -0
  46. package/dist/components/ui/FilterChips.svelte.d.ts +15 -0
  47. package/dist/components/ui/FormErrorFeedback.svelte +55 -0
  48. package/dist/components/ui/FormErrorFeedback.svelte.d.ts +33 -0
  49. package/dist/components/ui/FormFeedback.svelte +65 -0
  50. package/dist/components/ui/FormFeedback.svelte.d.ts +30 -0
  51. package/dist/components/ui/FormResultSlot.svelte +33 -0
  52. package/dist/components/ui/FormResultSlot.svelte.d.ts +26 -0
  53. package/dist/components/ui/GhostButton.svelte +46 -0
  54. package/dist/components/ui/GhostButton.svelte.d.ts +10 -0
  55. package/dist/components/ui/Link.svelte +30 -0
  56. package/dist/components/ui/Link.svelte.d.ts +21 -0
  57. package/dist/components/ui/Pagination.svelte +77 -0
  58. package/dist/components/ui/Pagination.svelte.d.ts +18 -0
  59. package/dist/components/ui/RichText.svelte +160 -0
  60. package/dist/components/ui/RichText.svelte.d.ts +18 -0
  61. package/dist/components/ui/SearchInput.svelte +90 -0
  62. package/dist/components/ui/SearchInput.svelte.d.ts +13 -0
  63. package/dist/components/ui/ShareRow.svelte +95 -0
  64. package/dist/components/ui/ShareRow.svelte.d.ts +19 -0
  65. package/dist/components/ui/index.d.ts +17 -0
  66. package/dist/components/ui/index.js +17 -0
  67. package/dist/components/weeklies/SortSelect.svelte +42 -0
  68. package/dist/components/weeklies/SortSelect.svelte.d.ts +8 -0
  69. package/dist/components/weeklies/WeeklieCard.svelte +82 -0
  70. package/dist/components/weeklies/WeeklieCard.svelte.d.ts +15 -0
  71. package/dist/components/weeklies/index.d.ts +3 -0
  72. package/dist/components/weeklies/index.js +2 -0
  73. package/dist/config/UiProvider.svelte +21 -0
  74. package/dist/config/UiProvider.svelte.d.ts +14 -0
  75. package/dist/config/context.d.ts +17 -0
  76. package/dist/config/context.js +47 -0
  77. package/dist/config/index.d.ts +5 -0
  78. package/dist/config/index.js +4 -0
  79. package/dist/config/messages.d.ts +7 -0
  80. package/dist/config/messages.js +38 -0
  81. package/dist/config/types.d.ts +99 -0
  82. package/dist/config/types.js +9 -0
  83. package/dist/content/index.d.ts +3 -0
  84. package/dist/content/index.js +1 -0
  85. package/dist/content/richtext.d.ts +10 -0
  86. package/dist/content/richtext.js +14 -0
  87. package/dist/content/types.d.ts +71 -0
  88. package/dist/content/types.js +6 -0
  89. package/dist/edit/Editable.svelte +197 -0
  90. package/dist/edit/Editable.svelte.d.ts +52 -0
  91. package/dist/edit/context.d.ts +10 -0
  92. package/dist/edit/context.js +15 -0
  93. package/dist/edit/helpers.d.ts +18 -0
  94. package/dist/edit/helpers.js +14 -0
  95. package/dist/edit/index.d.ts +5 -0
  96. package/dist/edit/index.js +4 -0
  97. package/dist/edit/types.d.ts +64 -0
  98. package/dist/edit/types.js +4 -0
  99. package/dist/index.d.ts +13 -0
  100. package/dist/index.js +17 -0
  101. package/dist/styles/base.css +67 -0
  102. package/dist/styles/tokens.css +57 -0
  103. package/dist/utils/dates.d.ts +14 -0
  104. package/dist/utils/dates.js +34 -0
  105. package/dist/utils/milestones.d.ts +11 -0
  106. package/dist/utils/milestones.js +28 -0
  107. package/dist/utils/paths.d.ts +2 -0
  108. package/dist/utils/paths.js +4 -0
  109. package/package.json +139 -0
@@ -0,0 +1,89 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { FieldConstraint } from '../../content/types.js';
4
+
5
+ /** Attributes the control must spread (`{...attrs}`) to stay wired. */
6
+ interface ControlAttrs {
7
+ id: string;
8
+ 'aria-describedby': string | undefined;
9
+ maxlength: number | undefined;
10
+ minlength: number | undefined;
11
+ }
12
+
13
+ /**
14
+ * The message comes from the validation that failed, not from the caller.
15
+ * Every call site used to hand Field the sentence as a prop while issues()
16
+ * was read as a boolean, so the constraint and the sentence describing it
17
+ * lived in different files and could never disagree loudly. The host app's schemas own
18
+ * both now (the foundation site: `boundedText` in its schema layer).
19
+ *
20
+ * The numbers travel the same way for the same reason. They used to be
21
+ * imported by each form and spelled out as a `maxlength` beside the input,
22
+ * which is how the password and email bounds came to be enforced on the
23
+ * server and stated nowhere in the markup: forgetting an attribute breaks
24
+ * nothing visible and no test. A field that declares its constraint here
25
+ * cannot render a control without it.
26
+ *
27
+ * `constraint` is required and explicitly nullable rather than optional,
28
+ * because optional is what let the hole reopen. The round that moved the
29
+ * numbers here counted eleven bounded fields and shipped the twelfth — the
30
+ * magic-link email — with no bound stated in the browser, and nothing could
31
+ * fail: an omitted prop and a control that genuinely has no length were the
32
+ * same value. They are different values now. A control with no length says
33
+ * `null` and says so on purpose; a bounded field that says nothing does not
34
+ * compile.
35
+ */
36
+ interface Props {
37
+ /** Control id; the error paragraph gets `${id}-error`. */
38
+ id: string;
39
+ label: string;
40
+ /** The remote form field; its first issue is what gets rendered. */
41
+ field: { issues(): Array<{ message: string }> | undefined };
42
+ /**
43
+ * The schema's bounds for this field, become the control's length
44
+ * attributes. `null` for controls with no length: checkboxes, selects.
45
+ */
46
+ constraint: FieldConstraint | null;
47
+ /** Render the label visually hidden (surrounding context already labels it). */
48
+ hideLabel?: boolean;
49
+ /** The input/textarea/select, spreading the given attrs. */
50
+ children: Snippet<[ControlAttrs]>;
51
+ }
52
+
53
+ let { id, label, field, constraint, hideLabel = false, children }: Props = $props();
54
+
55
+ const errorMessage = $derived(field.issues()?.[0]?.message);
56
+ const showError = $derived(errorMessage !== undefined);
57
+ </script>
58
+
59
+ <div class="field">
60
+ <label class:visually-hidden={hideLabel} for={id}>{label}</label>
61
+ {@render children({
62
+ id,
63
+ 'aria-describedby': showError ? `${id}-error` : undefined,
64
+ maxlength: constraint?.max,
65
+ minlength: constraint?.min
66
+ })}
67
+ {#if showError}
68
+ <p class="field-error" id="{id}-error">{errorMessage}</p>
69
+ {/if}
70
+ </div>
71
+
72
+ <style>
73
+ .field {
74
+ display: flex;
75
+ flex-direction: column;
76
+ gap: var(--space-1);
77
+ }
78
+
79
+ label {
80
+ font-weight: 600;
81
+ font-size: var(--text-sm);
82
+ }
83
+
84
+ .field-error {
85
+ color: var(--series-8);
86
+ font-size: var(--text-sm);
87
+ margin: 0;
88
+ }
89
+ </style>
@@ -0,0 +1,55 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { FieldConstraint } from '../../content/types.js';
3
+ /** Attributes the control must spread (`{...attrs}`) to stay wired. */
4
+ interface ControlAttrs {
5
+ id: string;
6
+ 'aria-describedby': string | undefined;
7
+ maxlength: number | undefined;
8
+ minlength: number | undefined;
9
+ }
10
+ /**
11
+ * The message comes from the validation that failed, not from the caller.
12
+ * Every call site used to hand Field the sentence as a prop while issues()
13
+ * was read as a boolean, so the constraint and the sentence describing it
14
+ * lived in different files and could never disagree loudly. The host app's schemas own
15
+ * both now (the foundation site: `boundedText` in its schema layer).
16
+ *
17
+ * The numbers travel the same way for the same reason. They used to be
18
+ * imported by each form and spelled out as a `maxlength` beside the input,
19
+ * which is how the password and email bounds came to be enforced on the
20
+ * server and stated nowhere in the markup: forgetting an attribute breaks
21
+ * nothing visible and no test. A field that declares its constraint here
22
+ * cannot render a control without it.
23
+ *
24
+ * `constraint` is required and explicitly nullable rather than optional,
25
+ * because optional is what let the hole reopen. The round that moved the
26
+ * numbers here counted eleven bounded fields and shipped the twelfth — the
27
+ * magic-link email — with no bound stated in the browser, and nothing could
28
+ * fail: an omitted prop and a control that genuinely has no length were the
29
+ * same value. They are different values now. A control with no length says
30
+ * `null` and says so on purpose; a bounded field that says nothing does not
31
+ * compile.
32
+ */
33
+ interface Props {
34
+ /** Control id; the error paragraph gets `${id}-error`. */
35
+ id: string;
36
+ label: string;
37
+ /** The remote form field; its first issue is what gets rendered. */
38
+ field: {
39
+ issues(): Array<{
40
+ message: string;
41
+ }> | undefined;
42
+ };
43
+ /**
44
+ * The schema's bounds for this field, become the control's length
45
+ * attributes. `null` for controls with no length: checkboxes, selects.
46
+ */
47
+ constraint: FieldConstraint | null;
48
+ /** Render the label visually hidden (surrounding context already labels it). */
49
+ hideLabel?: boolean;
50
+ /** The input/textarea/select, spreading the given attrs. */
51
+ children: Snippet<[ControlAttrs]>;
52
+ }
53
+ declare const Field: import("svelte").Component<Props, {}, "">;
54
+ type Field = ReturnType<typeof Field>;
55
+ export default Field;
@@ -0,0 +1,43 @@
1
+ <script lang="ts">
2
+ import GhostButton from './GhostButton.svelte';
3
+
4
+ interface Chip {
5
+ value: string;
6
+ label: string;
7
+ }
8
+
9
+ interface Props {
10
+ chips: Chip[];
11
+ /** Currently selected chip value; null = none selected. */
12
+ selected: string | null;
13
+ label: string;
14
+ /** Called with the toggled value, or null when the active chip is unselected. */
15
+ onchange: (value: string | null) => void;
16
+ }
17
+
18
+ let { chips, selected, label, onchange }: Props = $props();
19
+
20
+ function toggle(value: string): void {
21
+ onchange(selected === value ? null : value);
22
+ }
23
+ </script>
24
+
25
+ <div class="chips" role="group" aria-label={label}>
26
+ {#each chips as chip (chip.value)}
27
+ <GhostButton
28
+ variant="chip"
29
+ aria-pressed={selected === chip.value}
30
+ onclick={() => toggle(chip.value)}
31
+ >
32
+ {chip.label}
33
+ </GhostButton>
34
+ {/each}
35
+ </div>
36
+
37
+ <style>
38
+ .chips {
39
+ display: flex;
40
+ flex-wrap: wrap;
41
+ gap: var(--space-2);
42
+ }
43
+ </style>
@@ -0,0 +1,15 @@
1
+ interface Chip {
2
+ value: string;
3
+ label: string;
4
+ }
5
+ interface Props {
6
+ chips: Chip[];
7
+ /** Currently selected chip value; null = none selected. */
8
+ selected: string | null;
9
+ label: string;
10
+ /** Called with the toggled value, or null when the active chip is unselected. */
11
+ onchange: (value: string | null) => void;
12
+ }
13
+ declare const FilterChips: import("svelte").Component<Props, {}, "">;
14
+ type FilterChips = ReturnType<typeof FilterChips>;
15
+ export default FilterChips;
@@ -0,0 +1,55 @@
1
+ <script lang="ts" generics="ExtraReason extends string = never">
2
+ import { getUiConfig } from '../../config/context.js';
3
+ import type { FormFailReason } from '../../content/types.js';
4
+ import FormFeedback from './FormFeedback.svelte';
5
+
6
+ interface Props {
7
+ /** The remote form's result; renders nothing unless it's a failure. */
8
+ result: { ok: boolean; reason?: FormFailReason | ExtraReason } | undefined | null;
9
+ /** Per-reason overrides; unlisted reasons use the shared form_error_* copy. */
10
+ messages?: Partial<Record<FormFailReason | ExtraReason, string>>;
11
+ }
12
+
13
+ let { result, messages = {} }: Props = $props();
14
+
15
+ const config = getUiConfig();
16
+
17
+ /**
18
+ * Exhaustive over the shared reasons, minus 'error' which is the fallback
19
+ * itself. Adding a member to FormFailReason fails the build here instead of
20
+ * silently rendering the generic message.
21
+ */
22
+ const GENERIC = $derived<Record<Exclude<FormFailReason, 'error'>, () => string>>({
23
+ rateLimited: config.messages.form_error_rateLimited,
24
+ unauthenticated: config.messages.form_error_unauthenticated,
25
+ forbidden: config.messages.form_error_forbidden,
26
+ unavailable: config.messages.form_error_unavailable
27
+ });
28
+
29
+ /**
30
+ * Own-property lookups on both records, which is what makes the rule this
31
+ * module documents — an unlisted reason gets the shared copy — actually
32
+ * hold. `messages[reason]` returns a truthy *inherited* member for
33
+ * 'constructor', 'toString' and '__proto__', and `reason in GENERIC` is true
34
+ * for all three, so each rendered a prototype member where a sentence
35
+ * belongs: "function Object() { [native code] }" in place of an error.
36
+ *
37
+ * Not reachable today — every reason the server produces is a constant from
38
+ * a closed set — so this is not a guard against an attacker. It is the
39
+ * difference between the fallback being documented and it being true.
40
+ */
41
+ function messageFor(reason: FormFailReason | ExtraReason | undefined): string {
42
+ if (reason) {
43
+ const override = Object.hasOwn(messages, reason) ? messages[reason] : undefined;
44
+ if (override) return override;
45
+ if (Object.hasOwn(GENERIC, reason)) {
46
+ return GENERIC[reason as Exclude<FormFailReason, 'error'>]();
47
+ }
48
+ }
49
+ return config.messages.form_error_generic();
50
+ }
51
+ </script>
52
+
53
+ {#if result && !result.ok}
54
+ <FormFeedback kind="error">{messageFor(result.reason)}</FormFeedback>
55
+ {/if}
@@ -0,0 +1,33 @@
1
+ import type { FormFailReason } from '../../content/types.js';
2
+ declare function $$render<ExtraReason extends string = never>(): {
3
+ props: {
4
+ /** The remote form's result; renders nothing unless it's a failure. */
5
+ result: {
6
+ ok: boolean;
7
+ reason?: FormFailReason | ExtraReason;
8
+ } | undefined | null;
9
+ /** Per-reason overrides; unlisted reasons use the shared form_error_* copy. */
10
+ messages?: Partial<Record<FormFailReason | ExtraReason, string>>;
11
+ };
12
+ exports: {};
13
+ bindings: "";
14
+ slots: {};
15
+ events: {};
16
+ };
17
+ declare class __sveltets_Render<ExtraReason extends string = never> {
18
+ props(): ReturnType<typeof $$render<ExtraReason>>['props'];
19
+ events(): ReturnType<typeof $$render<ExtraReason>>['events'];
20
+ slots(): ReturnType<typeof $$render<ExtraReason>>['slots'];
21
+ bindings(): "";
22
+ exports(): {};
23
+ }
24
+ interface $$IsomorphicComponent {
25
+ new <ExtraReason extends string = never>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<ExtraReason>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<ExtraReason>['props']>, ReturnType<__sveltets_Render<ExtraReason>['events']>, ReturnType<__sveltets_Render<ExtraReason>['slots']>> & {
26
+ $$bindings?: ReturnType<__sveltets_Render<ExtraReason>['bindings']>;
27
+ } & ReturnType<__sveltets_Render<ExtraReason>['exports']>;
28
+ <ExtraReason extends string = never>(internal: unknown, props: ReturnType<__sveltets_Render<ExtraReason>['props']> & {}): ReturnType<__sveltets_Render<ExtraReason>['exports']>;
29
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
30
+ }
31
+ declare const FormErrorFeedback: $$IsomorphicComponent;
32
+ type FormErrorFeedback<ExtraReason extends string = never> = InstanceType<typeof FormErrorFeedback<ExtraReason>>;
33
+ export default FormErrorFeedback;
@@ -0,0 +1,65 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+
4
+ /**
5
+ * The outcome of a submission, and the one owner of where focus goes when
6
+ * that outcome replaces the form.
7
+ *
8
+ * Every form that succeeds swaps its whole `<form>` out for this, which
9
+ * destroys the submit button the reader had just activated: focus falls back
10
+ * to `<body>`, so the next Tab restarts at the top of the document with no
11
+ * sign the submission worked. And because this element is *inserted* with
12
+ * its text already in place rather than being present and then updated, a
13
+ * polite `role="status"` is announced unreliably — the two together can
14
+ * leave a screen-reader user with no confirmation at all.
15
+ *
16
+ * Moving focus here settles both: the message is read on focus, and the
17
+ * reader continues from the confirmation instead of from nowhere.
18
+ *
19
+ * Only on success. An error leaves the form standing — and ContactForm
20
+ * renders one straight from live preflight issues — so focusing an error
21
+ * would take the cursor out of the field the reader is still typing in.
22
+ * `role="alert"` is assertive and is announced on insertion anyway; it is
23
+ * the polite `role="status"` that needs the focus move to be heard.
24
+ */
25
+ interface Props {
26
+ /** success renders role="status"; error renders role="alert". */
27
+ kind: 'success' | 'error';
28
+ children: Snippet;
29
+ }
30
+
31
+ let { kind, children }: Props = $props();
32
+
33
+ const isSuccess = $derived(kind === 'success');
34
+
35
+ let node = $state<HTMLParagraphElement | null>(null);
36
+
37
+ $effect(() => {
38
+ if (isSuccess) node?.focus();
39
+ });
40
+ </script>
41
+
42
+ <p bind:this={node} class="feedback {kind}" role={isSuccess ? 'status' : 'alert'} tabindex="-1">
43
+ {@render children()}
44
+ </p>
45
+
46
+ <style>
47
+ .feedback {
48
+ font-weight: 600;
49
+ margin: 0;
50
+ }
51
+
52
+ /* Focused programmatically, never by tabbing, so the ring would only ever
53
+ confuse: it is a message, not a control. */
54
+ .feedback:focus {
55
+ outline: none;
56
+ }
57
+
58
+ .success {
59
+ color: var(--color-brand);
60
+ }
61
+
62
+ .error {
63
+ color: var(--series-8);
64
+ }
65
+ </style>
@@ -0,0 +1,30 @@
1
+ import type { Snippet } from 'svelte';
2
+ /**
3
+ * The outcome of a submission, and the one owner of where focus goes when
4
+ * that outcome replaces the form.
5
+ *
6
+ * Every form that succeeds swaps its whole `<form>` out for this, which
7
+ * destroys the submit button the reader had just activated: focus falls back
8
+ * to `<body>`, so the next Tab restarts at the top of the document with no
9
+ * sign the submission worked. And because this element is *inserted* with
10
+ * its text already in place rather than being present and then updated, a
11
+ * polite `role="status"` is announced unreliably — the two together can
12
+ * leave a screen-reader user with no confirmation at all.
13
+ *
14
+ * Moving focus here settles both: the message is read on focus, and the
15
+ * reader continues from the confirmation instead of from nowhere.
16
+ *
17
+ * Only on success. An error leaves the form standing — and ContactForm
18
+ * renders one straight from live preflight issues — so focusing an error
19
+ * would take the cursor out of the field the reader is still typing in.
20
+ * `role="alert"` is assertive and is announced on insertion anyway; it is
21
+ * the polite `role="status"` that needs the focus move to be heard.
22
+ */
23
+ interface Props {
24
+ /** success renders role="status"; error renders role="alert". */
25
+ kind: 'success' | 'error';
26
+ children: Snippet;
27
+ }
28
+ declare const FormFeedback: import("svelte").Component<Props, {}, "">;
29
+ type FormFeedback = ReturnType<typeof FormFeedback>;
30
+ export default FormFeedback;
@@ -0,0 +1,33 @@
1
+ <script lang="ts">
2
+ import FormErrorFeedback from './FormErrorFeedback.svelte';
3
+ import FormFeedback from './FormFeedback.svelte';
4
+
5
+ /**
6
+ * A form's outcome in one slot: the success message announced with
7
+ * role="status", any failure rendered by FormErrorFeedback with role="alert".
8
+ * Use it wherever a form stays on screen after submitting; forms that
9
+ * replace themselves on success (contact, signup, magic link) branch in
10
+ * their own markup, because what changes there is the whole form, not the
11
+ * message.
12
+ *
13
+ * No per-reason copy passes through here. It used to take a `messages`
14
+ * record and hand it on unread; all three render sites omitted it, and every
15
+ * `messages` in the app goes to FormErrorFeedback directly — which is where
16
+ * a form needing per-reason copy still reaches, and what the two forms that
17
+ * need it already do.
18
+ */
19
+ interface Props {
20
+ /** The remote form's result; nothing renders until there is one. */
21
+ result: { ok: boolean; reason?: string } | undefined | null;
22
+ /** Announced on success. */
23
+ successMessage: string;
24
+ }
25
+
26
+ let { result, successMessage }: Props = $props();
27
+ </script>
28
+
29
+ {#if result?.ok}
30
+ <FormFeedback kind="success">{successMessage}</FormFeedback>
31
+ {:else}
32
+ <FormErrorFeedback {result} />
33
+ {/if}
@@ -0,0 +1,26 @@
1
+ /**
2
+ * A form's outcome in one slot: the success message announced with
3
+ * role="status", any failure rendered by FormErrorFeedback with role="alert".
4
+ * Use it wherever a form stays on screen after submitting; forms that
5
+ * replace themselves on success (contact, signup, magic link) branch in
6
+ * their own markup, because what changes there is the whole form, not the
7
+ * message.
8
+ *
9
+ * No per-reason copy passes through here. It used to take a `messages`
10
+ * record and hand it on unread; all three render sites omitted it, and every
11
+ * `messages` in the app goes to FormErrorFeedback directly — which is where
12
+ * a form needing per-reason copy still reaches, and what the two forms that
13
+ * need it already do.
14
+ */
15
+ interface Props {
16
+ /** The remote form's result; nothing renders until there is one. */
17
+ result: {
18
+ ok: boolean;
19
+ reason?: string;
20
+ } | undefined | null;
21
+ /** Announced on success. */
22
+ successMessage: string;
23
+ }
24
+ declare const FormResultSlot: import("svelte").Component<Props, {}, "">;
25
+ type FormResultSlot = ReturnType<typeof FormResultSlot>;
26
+ export default FormResultSlot;
@@ -0,0 +1,46 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLButtonAttributes } from 'svelte/elements';
4
+
5
+ interface Props extends HTMLButtonAttributes {
6
+ /** 'ghost' = transparent with the app radius; 'chip' = grey pill with an aria-pressed state. */
7
+ variant?: 'ghost' | 'chip';
8
+ children: Snippet;
9
+ }
10
+
11
+ let { variant = 'ghost', children, type = 'button', class: className, ...rest }: Props = $props();
12
+ </script>
13
+
14
+ <button {type} class="{variant} {className ?? ''}" {...rest}>{@render children()}</button>
15
+
16
+ <style>
17
+ button {
18
+ border: 1px solid var(--color-hairline);
19
+ cursor: pointer;
20
+ transition:
21
+ background var(--transition-fast),
22
+ color var(--transition-fast);
23
+ }
24
+
25
+ button:hover {
26
+ border-color: var(--color-brand);
27
+ }
28
+
29
+ .ghost {
30
+ background: none;
31
+ border-radius: var(--radius);
32
+ padding: var(--space-1) var(--space-2);
33
+ }
34
+
35
+ .chip {
36
+ background: var(--color-band-grey);
37
+ border-radius: 999px;
38
+ padding: var(--space-1) var(--space-3);
39
+ }
40
+
41
+ .chip[aria-pressed='true'] {
42
+ background: var(--color-brand);
43
+ border-color: var(--color-brand);
44
+ color: var(--color-surface);
45
+ }
46
+ </style>
@@ -0,0 +1,10 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLButtonAttributes } from 'svelte/elements';
3
+ interface Props extends HTMLButtonAttributes {
4
+ /** 'ghost' = transparent with the app radius; 'chip' = grey pill with an aria-pressed state. */
5
+ variant?: 'ghost' | 'chip';
6
+ children: Snippet;
7
+ }
8
+ declare const GhostButton: import("svelte").Component<Props, {}, "">;
9
+ type GhostButton = ReturnType<typeof GhostButton>;
10
+ export default GhostButton;
@@ -0,0 +1,30 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLAnchorAttributes } from 'svelte/elements';
4
+ import type { Locale } from '../../config/types.js';
5
+ import { getUiConfig } from '../../config/context.js';
6
+
7
+ interface Props extends HTMLAnchorAttributes {
8
+ /**
9
+ * Canonical (unlocalized) app path, e.g. "/weeklies" — internal only.
10
+ *
11
+ * An absolute or protocol-relative URL does not pass through: the
12
+ * app's href resolver may splice a locale segment into it, so a foreign
13
+ * path can come back mangled, and no `rel` is emitted. A content column that may hold
14
+ * either (a milestone's `link_url`) branches at the call site and
15
+ * renders a bare anchor for the external case.
16
+ */
17
+ href: string;
18
+ /** Target locale; defaults to the current request/page locale. */
19
+ locale?: Locale;
20
+ children: Snippet;
21
+ }
22
+
23
+ let { href, locale, children, ...rest }: Props = $props();
24
+
25
+ const config = getUiConfig();
26
+
27
+ const resolvedHref = $derived(config.href(href, { locale }));
28
+ </script>
29
+
30
+ <a href={resolvedHref} {...rest}>{@render children()}</a>
@@ -0,0 +1,21 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLAnchorAttributes } from 'svelte/elements';
3
+ import type { Locale } from '../../config/types.js';
4
+ interface Props extends HTMLAnchorAttributes {
5
+ /**
6
+ * Canonical (unlocalized) app path, e.g. "/weeklies" — internal only.
7
+ *
8
+ * An absolute or protocol-relative URL does not pass through: the
9
+ * app's href resolver may splice a locale segment into it, so a foreign
10
+ * path can come back mangled, and no `rel` is emitted. A content column that may hold
11
+ * either (a milestone's `link_url`) branches at the call site and
12
+ * renders a bare anchor for the external case.
13
+ */
14
+ href: string;
15
+ /** Target locale; defaults to the current request/page locale. */
16
+ locale?: Locale;
17
+ children: Snippet;
18
+ }
19
+ declare const Link: import("svelte").Component<Props, {}, "">;
20
+ type Link = ReturnType<typeof Link>;
21
+ export default Link;
@@ -0,0 +1,77 @@
1
+ <script lang="ts">
2
+ import { getUiConfig } from '../../config/context.js';
3
+ import Link from './Link.svelte';
4
+
5
+ /**
6
+ * Previous/next paging over a result set. Real links, not buttons: paging
7
+ * is a navigation, so it works without JavaScript, is crawlable, and each
8
+ * page keeps its own URL. The caller builds the hrefs because it owns the
9
+ * rest of the query string (the active filters).
10
+ */
11
+ interface Props {
12
+ /** 1-based current page. */
13
+ page: number;
14
+ /** Total items across all pages, not just the ones on screen. */
15
+ total: number;
16
+ pageSize: number;
17
+ /** Href for a given 1-based page number. */
18
+ href: (page: number) => string;
19
+ }
20
+
21
+ let { page, total, pageSize, href }: Props = $props();
22
+
23
+ const config = getUiConfig();
24
+ const msg = $derived(config.messages);
25
+
26
+ const pages = $derived(Math.max(1, Math.ceil(total / pageSize)));
27
+ const hasPrevious = $derived(page > 1);
28
+ const hasNext = $derived(page < pages);
29
+
30
+ // A page past the end is reachable through a stale or shared link. It shows
31
+ // the empty state, so it must still show the way back — otherwise the only
32
+ // recovery is editing the URL by hand. "Previous" goes to the last page that
33
+ // has rows, not to page - 1, which would be just as empty.
34
+ const isOutOfRange = $derived(page > pages);
35
+ const previousPage = $derived(Math.min(page - 1, pages));
36
+ </script>
37
+
38
+ {#if pages > 1 || isOutOfRange}
39
+ <nav class="pagination" aria-label={msg.pagination_label()}>
40
+ {#if hasPrevious}
41
+ <Link href={href(previousPage)} rel="prev">{msg.pagination_previous()}</Link>
42
+ {:else}
43
+ <span class="disabled">{msg.pagination_previous()}</span>
44
+ {/if}
45
+
46
+ <p class="status" aria-live="polite">
47
+ {msg.pagination_status({ page, pages })}
48
+ </p>
49
+
50
+ {#if hasNext}
51
+ <Link href={href(page + 1)} rel="next">{msg.pagination_next()}</Link>
52
+ {:else}
53
+ <span class="disabled">{msg.pagination_next()}</span>
54
+ {/if}
55
+ </nav>
56
+ {/if}
57
+
58
+ <style>
59
+ .pagination {
60
+ display: flex;
61
+ align-items: center;
62
+ justify-content: center;
63
+ gap: var(--space-4);
64
+ margin-top: var(--space-5);
65
+ }
66
+
67
+ .status {
68
+ margin: 0;
69
+ color: var(--color-ink-secondary);
70
+ font-size: var(--text-sm);
71
+ }
72
+
73
+ .disabled {
74
+ color: var(--color-ink-muted);
75
+ font-size: var(--text-sm);
76
+ }
77
+ </style>
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Previous/next paging over a result set. Real links, not buttons: paging
3
+ * is a navigation, so it works without JavaScript, is crawlable, and each
4
+ * page keeps its own URL. The caller builds the hrefs because it owns the
5
+ * rest of the query string (the active filters).
6
+ */
7
+ interface Props {
8
+ /** 1-based current page. */
9
+ page: number;
10
+ /** Total items across all pages, not just the ones on screen. */
11
+ total: number;
12
+ pageSize: number;
13
+ /** Href for a given 1-based page number. */
14
+ href: (page: number) => string;
15
+ }
16
+ declare const Pagination: import("svelte").Component<Props, {}, "">;
17
+ type Pagination = ReturnType<typeof Pagination>;
18
+ export default Pagination;