@mystravil/hardpoint 0.2.0 → 0.3.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 (54) hide show
  1. package/CHANGELOG.md +51 -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 +221 -174
  7. package/dist/components/Button.svelte.d.ts +18 -2
  8. package/dist/components/Checkbox.svelte +235 -235
  9. package/dist/components/CheckboxGroup.svelte +207 -207
  10. package/dist/components/Combobox.svelte +901 -901
  11. package/dist/components/Dialog.svelte +359 -359
  12. package/dist/components/Disclosure.svelte +201 -201
  13. package/dist/components/EmptyState.svelte +88 -88
  14. package/dist/components/ErrorState.svelte +135 -135
  15. package/dist/components/ImageViewer.svelte +764 -764
  16. package/dist/components/KeyValueList.svelte +114 -114
  17. package/dist/components/ListDetail.svelte +218 -218
  18. package/dist/components/LoadRegion.svelte +219 -219
  19. package/dist/components/MediaFrame.svelte +110 -110
  20. package/dist/components/MediaImage.svelte +256 -256
  21. package/dist/components/Menu.svelte +275 -258
  22. package/dist/components/MenuCheckboxItem.svelte +119 -119
  23. package/dist/components/MenuGroup.svelte +50 -50
  24. package/dist/components/MenuItem.svelte +94 -94
  25. package/dist/components/MenuRadioGroup.svelte +62 -62
  26. package/dist/components/MenuRadioItem.svelte +110 -110
  27. package/dist/components/MenuSeparator.svelte +28 -28
  28. package/dist/components/Meter.svelte +283 -283
  29. package/dist/components/NativeSelect.svelte +135 -135
  30. package/dist/components/Notice.svelte +262 -262
  31. package/dist/components/Popover.svelte +222 -222
  32. package/dist/components/Progress.svelte +157 -157
  33. package/dist/components/RadioGroup.svelte +440 -440
  34. package/dist/components/Select.svelte +471 -471
  35. package/dist/components/Skeleton.svelte +149 -149
  36. package/dist/components/Spinner.svelte +118 -118
  37. package/dist/components/Stat.svelte +253 -253
  38. package/dist/components/Steps.svelte +189 -189
  39. package/dist/components/Switch.svelte +222 -222
  40. package/dist/components/Table.svelte +504 -504
  41. package/dist/components/Tabs.svelte +246 -246
  42. package/dist/components/Tag.svelte +40 -40
  43. package/dist/components/TextArea.svelte +272 -272
  44. package/dist/components/TextField.svelte +396 -256
  45. package/dist/components/TextField.svelte.d.ts +16 -1
  46. package/dist/components/Timeline.svelte +156 -156
  47. package/dist/components/Toaster.svelte +411 -411
  48. package/dist/components/Tooltip.svelte +200 -200
  49. package/dist/components/TooltipGroup.svelte +46 -46
  50. package/dist/components/internal/Scrim.svelte +52 -52
  51. package/dist/components/internal/Sheet.svelte +263 -263
  52. package/dist/fonts.css +71 -71
  53. package/dist/tokens.css +283 -283
  54. 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>