@mystravil/hardpoint 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +42 -38
  2. package/LICENSE +25 -25
  3. package/README.md +67 -64
  4. package/dist/base.css +625 -625
  5. package/dist/components/AppShell.svelte +698 -698
  6. package/dist/components/Button.svelte +174 -174
  7. package/dist/components/Checkbox.svelte +235 -235
  8. package/dist/components/CheckboxGroup.svelte +207 -207
  9. package/dist/components/Combobox.svelte +901 -901
  10. package/dist/components/Dialog.svelte +359 -359
  11. package/dist/components/Disclosure.svelte +201 -201
  12. package/dist/components/EmptyState.svelte +88 -88
  13. package/dist/components/ErrorState.svelte +135 -135
  14. package/dist/components/ImageViewer.svelte +764 -764
  15. package/dist/components/KeyValueList.svelte +114 -114
  16. package/dist/components/ListDetail.svelte +218 -218
  17. package/dist/components/LoadRegion.svelte +219 -219
  18. package/dist/components/MediaFrame.svelte +110 -110
  19. package/dist/components/MediaImage.svelte +256 -256
  20. package/dist/components/Menu.svelte +258 -258
  21. package/dist/components/MenuCheckboxItem.svelte +119 -119
  22. package/dist/components/MenuGroup.svelte +50 -50
  23. package/dist/components/MenuItem.svelte +94 -94
  24. package/dist/components/MenuRadioGroup.svelte +62 -62
  25. package/dist/components/MenuRadioItem.svelte +110 -110
  26. package/dist/components/MenuSeparator.svelte +28 -28
  27. package/dist/components/Meter.svelte +283 -283
  28. package/dist/components/NativeSelect.svelte +135 -135
  29. package/dist/components/Notice.svelte +262 -262
  30. package/dist/components/Popover.svelte +222 -222
  31. package/dist/components/Progress.svelte +157 -157
  32. package/dist/components/RadioGroup.svelte +440 -440
  33. package/dist/components/Select.svelte +471 -471
  34. package/dist/components/Skeleton.svelte +149 -149
  35. package/dist/components/Spinner.svelte +118 -118
  36. package/dist/components/Stat.svelte +253 -253
  37. package/dist/components/Steps.svelte +189 -189
  38. package/dist/components/Switch.svelte +222 -222
  39. package/dist/components/Table.svelte +504 -504
  40. package/dist/components/Tabs.svelte +246 -246
  41. package/dist/components/Tag.svelte +40 -40
  42. package/dist/components/TextArea.svelte +272 -272
  43. package/dist/components/TextField.svelte +256 -256
  44. package/dist/components/Timeline.svelte +156 -156
  45. package/dist/components/Toaster.svelte +411 -411
  46. package/dist/components/Tooltip.svelte +200 -200
  47. package/dist/components/TooltipGroup.svelte +46 -46
  48. package/dist/components/internal/Scrim.svelte +52 -52
  49. package/dist/components/internal/Sheet.svelte +263 -263
  50. package/dist/fonts.css +71 -71
  51. package/dist/tokens.css +283 -283
  52. package/package.json +81 -76
@@ -1,219 +1,219 @@
1
- <script lang="ts" module>
2
- import type { Snippet } from 'svelte';
3
- import type { HTMLAttributes } from 'svelte/elements';
4
-
5
- export type LoadRegionProps = Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'placeholder'> & {
6
- /** True while the content loads: the region shows `placeholder` and is `aria-busy`. */
7
- loading: boolean;
8
- /**
9
- * The text of the status line, such as `Loading tasks`, then `5 tasks`.
10
- * The line is visually hidden, stays mounted outside the region and is
11
- * a `role="status"` region, so each change is announced. Leave it empty
12
- * while an error state announces itself.
13
- */
14
- status: string;
15
- /**
16
- * The skeleton layout shown while loading, rendered inside an
17
- * `aria-hidden`, `inert` wrapper. Give it the content's own layout
18
- * container: during a reveal it lies under the content in a box of its
19
- * own, so a grid or gap set on the region would not reach it.
20
- */
21
- placeholder: Snippet;
22
- /** The content, and also any empty or error state. */
23
- children: Snippet;
24
- /** The region element. A page moves focus here when the content replaces the focused control. */
25
- ref?: HTMLDivElement | null;
26
- };
27
- </script>
28
-
29
- <script lang="ts">
30
- import { untrack } from 'svelte';
31
- import { duration } from '../motion/transitions.js';
32
-
33
- let {
34
- loading,
35
- status,
36
- placeholder,
37
- children,
38
- ref = $bindable(null),
39
- class: className,
40
- ...rest
41
- }: LoadRegionProps = $props();
42
-
43
- // ---- Reveal (BRIEF, motion rule 2) -------------------------------------
44
-
45
- // When a load ends after its skeletons became visible and --dur-reveal is
46
- // more than 0ms, the content crossfades in over the skeletons for
47
- // --dur-reveal on --settle. The placeholder stays mounted under the
48
- // content, unchanged. The content lies on top on an opaque backdrop (the
49
- // colour behind the region) and fades in from 0, so every pixel moves in
50
- // a straight line from the placeholder's to the content's: a bar turns
51
- // into its text, and a part drawn the same in both layers (a tag, a
52
- // label, a table's band) does not change at all. Then the placeholder
53
- // goes. At 0ms, for a load that ends within --skeleton-delay, on the
54
- // first render and for any change that is not a load ending, the content
55
- // replaces the placeholder in one frame. Opacity only: the skeletons
56
- // mirror the layout, so nothing moves. Quiet motion, so it runs in both
57
- // tiers; it is only a fade, so reduced motion keeps it.
58
-
59
- type Phase = 'loading' | 'reveal' | 'content';
60
- let phase = $state<Phase>(untrack(() => (loading ? 'loading' : 'content')));
61
- // Counts loads. A new load remounts the placeholder, so its skeletons
62
- // wait out --skeleton-delay again, also when it cuts a reveal short.
63
- let load = $state(0);
64
-
65
- /** When the running load began, or null between loads. */
66
- let began: number | null = untrack(() => (loading ? performance.now() : null));
67
- /** --skeleton-delay when the load began, as the skeletons read it; null until read. */
68
- let delay: number | null = null;
69
- /** The running reveal's timing, read from the region when the load ended. */
70
- let timing: KeyframeAnimationOptions = {};
71
-
72
- // Before the DOM update, so a reveal keeps the placeholder mounted (its
73
- // skeletons stay visible) while the content mounts on top. Only `loading`
74
- // is tracked.
75
- $effect.pre(() => {
76
- const busy = loading;
77
- untrack(() => {
78
- const node = ref;
79
- if (busy) {
80
- if (phase !== 'loading') {
81
- began = performance.now();
82
- delay = node ? duration(node, '--skeleton-delay') : null;
83
- load += 1;
84
- phase = 'loading';
85
- }
86
- return;
87
- }
88
- if (phase !== 'loading') return;
89
- const elapsed = began === null ? -1 : performance.now() - began;
90
- began = null;
91
- // Read now, so a token retuned during the load applies.
92
- const ms = node ? duration(node, '--dur-reveal') : 0;
93
- const wait = delay ?? (node ? duration(node, '--skeleton-delay') : 0);
94
- if (!node || ms <= 0 || elapsed < wait) {
95
- phase = 'content';
96
- return;
97
- }
98
- const settle = getComputedStyle(node).getPropertyValue('--settle').trim();
99
- timing = {
100
- duration: ms,
101
- easing: CSS.supports('transition-timing-function', settle) ? settle : 'ease-out'
102
- };
103
- phase = 'reveal';
104
- });
105
- });
106
-
107
- /** The solid colour behind `node`: the first background colour up the tree that is not transparent. */
108
- function backdrop(node: Element): string {
109
- for (let el: Element | null = node; el; el = el.parentElement) {
110
- const colour = getComputedStyle(el).backgroundColor;
111
- // Transparent is `rgba(…, 0)`, or `… / 0)` in the newer colour syntaxes.
112
- if (colour !== 'transparent' && !/^rgba\(.*,\s*0\)$|\/\s*0\)$/.test(colour)) return colour;
113
- }
114
- // Nothing paints: the browser's canvas shows through.
115
- return 'Canvas';
116
- }
117
-
118
- // After the DOM update, in the same flush, so the content's first frame
119
- // is already at opacity 0. The cleanup cancels the fade when a new load
120
- // starts or the region goes away.
121
- $effect(() => {
122
- if (phase !== 'reveal') return;
123
- return untrack(() => {
124
- const node = ref;
125
- const content = node?.querySelector<HTMLElement>(':scope > .hp-load-region-content');
126
- if (!node || !content) {
127
- phase = 'content';
128
- return;
129
- }
130
- content.style.backgroundColor = backdrop(node);
131
- const fade = content.animate([{ opacity: 0 }, { opacity: 1 }], timing);
132
- fade.onfinish = () => (phase = 'content');
133
- return () => {
134
- fade.cancel();
135
- content.style.removeProperty('background-color');
136
- };
137
- });
138
- });
139
- </script>
140
-
141
- <!--
142
- A region that loads (BRIEF, motion rule 2): skeleton blocks while the
143
- content loads, then the content. It owns the loading pattern of the
144
- states docs: a visually hidden `role="status"` line before the region
145
- that stays mounted, `aria-busy` on the region while `loading`, and the
146
- `placeholder` in an `aria-hidden`, `inert` wrapper. When a load that
147
- showed skeletons ends and `--dur-reveal` is more than 0ms, the content
148
- crossfades in over the skeletons; see the script. Both layers are
149
- `display: contents` at rest, so they add no box. During a reveal the
150
- region is `position: relative`, the placeholder lies under the content
151
- (`position: absolute; inset: 0`) and the content is a block on the
152
- colour behind the region, so keep the region's own layout inside the
153
- snippets and give their roots no outer margin. Focus is the page's job:
154
- when the content replaces the focused control, the page moves focus to
155
- `ref` or to the control needed next. Extra attributes and `class` go to
156
- the region, `div.hp-load-region` (`tabindex="-1"`), which carries
157
- `data-revealing` during a reveal.
158
- -->
159
- <p class="hp-visually-hidden" role="status">{status}</p>
160
- <div
161
- {...rest}
162
- bind:this={ref}
163
- class={['hp-load-region', className]}
164
- tabindex="-1"
165
- aria-busy={loading}
166
- data-revealing={phase === 'reveal' ? '' : undefined}
167
- >
168
- {#key load}
169
- {#if phase !== 'content'}
170
- <div class="hp-load-region-placeholder" aria-hidden="true" inert>
171
- {@render placeholder()}
172
- </div>
173
- {/if}
174
- {/key}
175
- {#if !loading}
176
- <div class="hp-load-region-content">
177
- {@render children()}
178
- </div>
179
- {/if}
180
- </div>
181
-
182
- <style>
183
- /* Focus lands here only from the page's own code, when the content
184
- replaces the focused control, as <main> takes it from the skip link:
185
- no contour, since the region is not a control. */
186
- .hp-load-region:focus {
187
- outline: none;
188
- }
189
- /* At rest neither layer adds a box. */
190
- .hp-load-region-placeholder,
191
- .hp-load-region-content {
192
- display: contents;
193
- }
194
-
195
- /* ---- Reveal: the content over the placeholder ------------------------ */
196
-
197
- .hp-load-region[data-revealing] {
198
- position: relative;
199
- }
200
- /* Under the content, over the content's area. Its own stacking context
201
- keeps a sticky table band (z-index) under the content too. Taller
202
- placeholder rows are cut at the region's bottom edge; a band's bleed
203
- into the gutter stays. */
204
- .hp-load-region[data-revealing] > .hp-load-region-placeholder {
205
- display: block;
206
- position: absolute;
207
- inset: 0;
208
- isolation: isolate;
209
- overflow-x: visible;
210
- overflow-y: clip;
211
- }
212
- /* A box for the fade, painted after the placeholder. The script gives it
213
- the colour behind the region, so it covers the placeholder as it fades
214
- in. */
215
- .hp-load-region[data-revealing] > .hp-load-region-content {
216
- display: block;
217
- position: relative;
218
- }
219
- </style>
1
+ <script lang="ts" module>
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLAttributes } from 'svelte/elements';
4
+
5
+ export type LoadRegionProps = Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'placeholder'> & {
6
+ /** True while the content loads: the region shows `placeholder` and is `aria-busy`. */
7
+ loading: boolean;
8
+ /**
9
+ * The text of the status line, such as `Loading tasks`, then `5 tasks`.
10
+ * The line is visually hidden, stays mounted outside the region and is
11
+ * a `role="status"` region, so each change is announced. Leave it empty
12
+ * while an error state announces itself.
13
+ */
14
+ status: string;
15
+ /**
16
+ * The skeleton layout shown while loading, rendered inside an
17
+ * `aria-hidden`, `inert` wrapper. Give it the content's own layout
18
+ * container: during a reveal it lies under the content in a box of its
19
+ * own, so a grid or gap set on the region would not reach it.
20
+ */
21
+ placeholder: Snippet;
22
+ /** The content, and also any empty or error state. */
23
+ children: Snippet;
24
+ /** The region element. A page moves focus here when the content replaces the focused control. */
25
+ ref?: HTMLDivElement | null;
26
+ };
27
+ </script>
28
+
29
+ <script lang="ts">
30
+ import { untrack } from 'svelte';
31
+ import { duration } from '../motion/transitions.js';
32
+
33
+ let {
34
+ loading,
35
+ status,
36
+ placeholder,
37
+ children,
38
+ ref = $bindable(null),
39
+ class: className,
40
+ ...rest
41
+ }: LoadRegionProps = $props();
42
+
43
+ // ---- Reveal (BRIEF, motion rule 2) -------------------------------------
44
+
45
+ // When a load ends after its skeletons became visible and --dur-reveal is
46
+ // more than 0ms, the content crossfades in over the skeletons for
47
+ // --dur-reveal on --settle. The placeholder stays mounted under the
48
+ // content, unchanged. The content lies on top on an opaque backdrop (the
49
+ // colour behind the region) and fades in from 0, so every pixel moves in
50
+ // a straight line from the placeholder's to the content's: a bar turns
51
+ // into its text, and a part drawn the same in both layers (a tag, a
52
+ // label, a table's band) does not change at all. Then the placeholder
53
+ // goes. At 0ms, for a load that ends within --skeleton-delay, on the
54
+ // first render and for any change that is not a load ending, the content
55
+ // replaces the placeholder in one frame. Opacity only: the skeletons
56
+ // mirror the layout, so nothing moves. Quiet motion, so it runs in both
57
+ // tiers; it is only a fade, so reduced motion keeps it.
58
+
59
+ type Phase = 'loading' | 'reveal' | 'content';
60
+ let phase = $state<Phase>(untrack(() => (loading ? 'loading' : 'content')));
61
+ // Counts loads. A new load remounts the placeholder, so its skeletons
62
+ // wait out --skeleton-delay again, also when it cuts a reveal short.
63
+ let load = $state(0);
64
+
65
+ /** When the running load began, or null between loads. */
66
+ let began: number | null = untrack(() => (loading ? performance.now() : null));
67
+ /** --skeleton-delay when the load began, as the skeletons read it; null until read. */
68
+ let delay: number | null = null;
69
+ /** The running reveal's timing, read from the region when the load ended. */
70
+ let timing: KeyframeAnimationOptions = {};
71
+
72
+ // Before the DOM update, so a reveal keeps the placeholder mounted (its
73
+ // skeletons stay visible) while the content mounts on top. Only `loading`
74
+ // is tracked.
75
+ $effect.pre(() => {
76
+ const busy = loading;
77
+ untrack(() => {
78
+ const node = ref;
79
+ if (busy) {
80
+ if (phase !== 'loading') {
81
+ began = performance.now();
82
+ delay = node ? duration(node, '--skeleton-delay') : null;
83
+ load += 1;
84
+ phase = 'loading';
85
+ }
86
+ return;
87
+ }
88
+ if (phase !== 'loading') return;
89
+ const elapsed = began === null ? -1 : performance.now() - began;
90
+ began = null;
91
+ // Read now, so a token retuned during the load applies.
92
+ const ms = node ? duration(node, '--dur-reveal') : 0;
93
+ const wait = delay ?? (node ? duration(node, '--skeleton-delay') : 0);
94
+ if (!node || ms <= 0 || elapsed < wait) {
95
+ phase = 'content';
96
+ return;
97
+ }
98
+ const settle = getComputedStyle(node).getPropertyValue('--settle').trim();
99
+ timing = {
100
+ duration: ms,
101
+ easing: CSS.supports('transition-timing-function', settle) ? settle : 'ease-out'
102
+ };
103
+ phase = 'reveal';
104
+ });
105
+ });
106
+
107
+ /** The solid colour behind `node`: the first background colour up the tree that is not transparent. */
108
+ function backdrop(node: Element): string {
109
+ for (let el: Element | null = node; el; el = el.parentElement) {
110
+ const colour = getComputedStyle(el).backgroundColor;
111
+ // Transparent is `rgba(…, 0)`, or `… / 0)` in the newer colour syntaxes.
112
+ if (colour !== 'transparent' && !/^rgba\(.*,\s*0\)$|\/\s*0\)$/.test(colour)) return colour;
113
+ }
114
+ // Nothing paints: the browser's canvas shows through.
115
+ return 'Canvas';
116
+ }
117
+
118
+ // After the DOM update, in the same flush, so the content's first frame
119
+ // is already at opacity 0. The cleanup cancels the fade when a new load
120
+ // starts or the region goes away.
121
+ $effect(() => {
122
+ if (phase !== 'reveal') return;
123
+ return untrack(() => {
124
+ const node = ref;
125
+ const content = node?.querySelector<HTMLElement>(':scope > .hp-load-region-content');
126
+ if (!node || !content) {
127
+ phase = 'content';
128
+ return;
129
+ }
130
+ content.style.backgroundColor = backdrop(node);
131
+ const fade = content.animate([{ opacity: 0 }, { opacity: 1 }], timing);
132
+ fade.onfinish = () => (phase = 'content');
133
+ return () => {
134
+ fade.cancel();
135
+ content.style.removeProperty('background-color');
136
+ };
137
+ });
138
+ });
139
+ </script>
140
+
141
+ <!--
142
+ A region that loads (BRIEF, motion rule 2): skeleton blocks while the
143
+ content loads, then the content. It owns the loading pattern of the
144
+ states docs: a visually hidden `role="status"` line before the region
145
+ that stays mounted, `aria-busy` on the region while `loading`, and the
146
+ `placeholder` in an `aria-hidden`, `inert` wrapper. When a load that
147
+ showed skeletons ends and `--dur-reveal` is more than 0ms, the content
148
+ crossfades in over the skeletons; see the script. Both layers are
149
+ `display: contents` at rest, so they add no box. During a reveal the
150
+ region is `position: relative`, the placeholder lies under the content
151
+ (`position: absolute; inset: 0`) and the content is a block on the
152
+ colour behind the region, so keep the region's own layout inside the
153
+ snippets and give their roots no outer margin. Focus is the page's job:
154
+ when the content replaces the focused control, the page moves focus to
155
+ `ref` or to the control needed next. Extra attributes and `class` go to
156
+ the region, `div.hp-load-region` (`tabindex="-1"`), which carries
157
+ `data-revealing` during a reveal.
158
+ -->
159
+ <p class="hp-visually-hidden" role="status">{status}</p>
160
+ <div
161
+ {...rest}
162
+ bind:this={ref}
163
+ class={['hp-load-region', className]}
164
+ tabindex="-1"
165
+ aria-busy={loading}
166
+ data-revealing={phase === 'reveal' ? '' : undefined}
167
+ >
168
+ {#key load}
169
+ {#if phase !== 'content'}
170
+ <div class="hp-load-region-placeholder" aria-hidden="true" inert>
171
+ {@render placeholder()}
172
+ </div>
173
+ {/if}
174
+ {/key}
175
+ {#if !loading}
176
+ <div class="hp-load-region-content">
177
+ {@render children()}
178
+ </div>
179
+ {/if}
180
+ </div>
181
+
182
+ <style>
183
+ /* Focus lands here only from the page's own code, when the content
184
+ replaces the focused control, as <main> takes it from the skip link:
185
+ no contour, since the region is not a control. */
186
+ .hp-load-region:focus {
187
+ outline: none;
188
+ }
189
+ /* At rest neither layer adds a box. */
190
+ .hp-load-region-placeholder,
191
+ .hp-load-region-content {
192
+ display: contents;
193
+ }
194
+
195
+ /* ---- Reveal: the content over the placeholder ------------------------ */
196
+
197
+ .hp-load-region[data-revealing] {
198
+ position: relative;
199
+ }
200
+ /* Under the content, over the content's area. Its own stacking context
201
+ keeps a sticky table band (z-index) under the content too. Taller
202
+ placeholder rows are cut at the region's bottom edge; a band's bleed
203
+ into the gutter stays. */
204
+ .hp-load-region[data-revealing] > .hp-load-region-placeholder {
205
+ display: block;
206
+ position: absolute;
207
+ inset: 0;
208
+ isolation: isolate;
209
+ overflow-x: visible;
210
+ overflow-y: clip;
211
+ }
212
+ /* A box for the fade, painted after the placeholder. The script gives it
213
+ the colour behind the region, so it covers the placeholder as it fades
214
+ in. */
215
+ .hp-load-region[data-revealing] > .hp-load-region-content {
216
+ display: block;
217
+ position: relative;
218
+ }
219
+ </style>
@@ -1,110 +1,110 @@
1
- <script lang="ts" module>
2
- import type { Snippet } from 'svelte';
3
- import type { HTMLAttributes } from 'svelte/elements';
4
-
5
- export type MediaFrameProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
6
- /**
7
- * The frame's aspect ratio, any CSS `aspect-ratio` value such as
8
- * `16 / 9`. Without it the frame takes its content's height, unless an
9
- * ancestor sets `--media-frame-ratio` (a page sets that custom property
10
- * in its media queries to change the ratio by breakpoint).
11
- */
12
- ratio?: string;
13
- /** The inner width in px, inside the 1px frame. Bindable, read-only. */
14
- width?: number;
15
- /** The inner height in px, inside the 1px frame. Bindable, read-only. */
16
- height?: number;
17
- /** The image, diagram or chart. */
18
- children?: Snippet;
19
- };
20
- </script>
21
-
22
- <script lang="ts">
23
- let {
24
- ratio,
25
- width = $bindable(),
26
- height = $bindable(),
27
- class: className,
28
- children,
29
- ...rest
30
- }: MediaFrameProps = $props();
31
- </script>
32
-
33
- <!--
34
- A 1px hairline frame with small + registration marks over its corners and
35
- top centre (BRIEF, visual rule 8; da3-55, da3-61), for images and
36
- diagrams, never text blocks. The marks paint over the content. Content
37
- that fills the frame positions itself (absolute, inset 0) and reads
38
- bind:width and bind:height to draw at the frame's real size.
39
- -->
40
- <div
41
- {...rest}
42
- class={['hp-media-frame', className]}
43
- style:--media-frame-ratio={ratio}
44
- bind:clientWidth={width}
45
- bind:clientHeight={height}
46
- >
47
- {@render children?.()}
48
- <span class="reg top left" aria-hidden="true"></span>
49
- <span class="reg top mid" aria-hidden="true"></span>
50
- <span class="reg top right" aria-hidden="true"></span>
51
- <span class="reg bottom left" aria-hidden="true"></span>
52
- <span class="reg bottom right" aria-hidden="true"></span>
53
- </div>
54
-
55
- <style>
56
- .hp-media-frame {
57
- position: relative;
58
- aspect-ratio: var(--media-frame-ratio, auto);
59
- border: 1px solid var(--border);
60
- }
61
-
62
- /* A 13px + whose 1px strokes cross at its centre pixel, 12px in from
63
- the frame's inner edge. */
64
- .reg {
65
- position: absolute;
66
- /* matches the approved specimen */
67
- width: 13px;
68
- /* matches the approved specimen */
69
- height: 13px;
70
- pointer-events: none;
71
- }
72
- .top {
73
- top: 12px;
74
- }
75
- .bottom {
76
- bottom: 12px;
77
- }
78
- .left {
79
- left: 12px;
80
- }
81
- .right {
82
- right: 12px;
83
- }
84
- .mid {
85
- /* matches the approved specimen */
86
- left: calc(50% - 6px);
87
- }
88
- .reg::before,
89
- .reg::after {
90
- content: '';
91
- position: absolute;
92
- background: var(--text);
93
- }
94
- .reg::before {
95
- /* matches the approved specimen */
96
- top: 6px;
97
- left: 0;
98
- /* matches the approved specimen */
99
- width: 13px;
100
- height: 1px;
101
- }
102
- .reg::after {
103
- top: 0;
104
- /* matches the approved specimen */
105
- left: 6px;
106
- width: 1px;
107
- /* matches the approved specimen */
108
- height: 13px;
109
- }
110
- </style>
1
+ <script lang="ts" module>
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLAttributes } from 'svelte/elements';
4
+
5
+ export type MediaFrameProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
6
+ /**
7
+ * The frame's aspect ratio, any CSS `aspect-ratio` value such as
8
+ * `16 / 9`. Without it the frame takes its content's height, unless an
9
+ * ancestor sets `--media-frame-ratio` (a page sets that custom property
10
+ * in its media queries to change the ratio by breakpoint).
11
+ */
12
+ ratio?: string;
13
+ /** The inner width in px, inside the 1px frame. Bindable, read-only. */
14
+ width?: number;
15
+ /** The inner height in px, inside the 1px frame. Bindable, read-only. */
16
+ height?: number;
17
+ /** The image, diagram or chart. */
18
+ children?: Snippet;
19
+ };
20
+ </script>
21
+
22
+ <script lang="ts">
23
+ let {
24
+ ratio,
25
+ width = $bindable(),
26
+ height = $bindable(),
27
+ class: className,
28
+ children,
29
+ ...rest
30
+ }: MediaFrameProps = $props();
31
+ </script>
32
+
33
+ <!--
34
+ A 1px hairline frame with small + registration marks over its corners and
35
+ top centre (BRIEF, visual rule 8; da3-55, da3-61), for images and
36
+ diagrams, never text blocks. The marks paint over the content. Content
37
+ that fills the frame positions itself (absolute, inset 0) and reads
38
+ bind:width and bind:height to draw at the frame's real size.
39
+ -->
40
+ <div
41
+ {...rest}
42
+ class={['hp-media-frame', className]}
43
+ style:--media-frame-ratio={ratio}
44
+ bind:clientWidth={width}
45
+ bind:clientHeight={height}
46
+ >
47
+ {@render children?.()}
48
+ <span class="reg top left" aria-hidden="true"></span>
49
+ <span class="reg top mid" aria-hidden="true"></span>
50
+ <span class="reg top right" aria-hidden="true"></span>
51
+ <span class="reg bottom left" aria-hidden="true"></span>
52
+ <span class="reg bottom right" aria-hidden="true"></span>
53
+ </div>
54
+
55
+ <style>
56
+ .hp-media-frame {
57
+ position: relative;
58
+ aspect-ratio: var(--media-frame-ratio, auto);
59
+ border: 1px solid var(--border);
60
+ }
61
+
62
+ /* A 13px + whose 1px strokes cross at its centre pixel, 12px in from
63
+ the frame's inner edge. */
64
+ .reg {
65
+ position: absolute;
66
+ /* matches the approved specimen */
67
+ width: 13px;
68
+ /* matches the approved specimen */
69
+ height: 13px;
70
+ pointer-events: none;
71
+ }
72
+ .top {
73
+ top: 12px;
74
+ }
75
+ .bottom {
76
+ bottom: 12px;
77
+ }
78
+ .left {
79
+ left: 12px;
80
+ }
81
+ .right {
82
+ right: 12px;
83
+ }
84
+ .mid {
85
+ /* matches the approved specimen */
86
+ left: calc(50% - 6px);
87
+ }
88
+ .reg::before,
89
+ .reg::after {
90
+ content: '';
91
+ position: absolute;
92
+ background: var(--text);
93
+ }
94
+ .reg::before {
95
+ /* matches the approved specimen */
96
+ top: 6px;
97
+ left: 0;
98
+ /* matches the approved specimen */
99
+ width: 13px;
100
+ height: 1px;
101
+ }
102
+ .reg::after {
103
+ top: 0;
104
+ /* matches the approved specimen */
105
+ left: 6px;
106
+ width: 1px;
107
+ /* matches the approved specimen */
108
+ height: 13px;
109
+ }
110
+ </style>