@jelinek/ui 0.4.1 → 0.5.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.
- package/README.md +43 -25
- package/dist/components/OptionTile.svelte +206 -0
- package/dist/components/OptionTile.svelte.d.ts +57 -0
- package/dist/components/PhotoTile.svelte +109 -0
- package/dist/components/PhotoTile.svelte.d.ts +31 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -464,24 +464,48 @@ cascade that `pnpm test`'s jsdom environment structurally cannot check
|
|
|
464
464
|
The `publish` job is separate and gated on a tag matching `ui-v*` — it
|
|
465
465
|
does not run on ordinary merges to `master`, only when someone pushes a
|
|
466
466
|
`ui-v*` tag, and it also depends on `verify` passing first. It publishes
|
|
467
|
-
to **npmjs.org with `--access public
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
467
|
+
to **npmjs.org with `--access public`**.
|
|
468
|
+
|
|
469
|
+
### Releasing
|
|
470
|
+
|
|
471
|
+
1. Bump `version` in `svelte/package.json`, commit, push `master`.
|
|
472
|
+
2. `git tag ui-v<version> && git push origin ui-v<version>`.
|
|
473
|
+
3. `verify` runs, then `publish` — nothing else to do.
|
|
474
|
+
|
|
475
|
+
The tag is what records which commit a version came from, so push it even
|
|
476
|
+
if a release has to be finished by hand.
|
|
477
|
+
|
|
478
|
+
### How the publish authenticates
|
|
479
|
+
|
|
480
|
+
**Trusted publishing (GitHub Actions OIDC), no secret.** The job asks
|
|
481
|
+
GitHub for an OIDC token and npm exchanges it for a short-lived publish
|
|
482
|
+
credential. Registered on npmjs under the package's *Trusted publisher*:
|
|
483
|
+
org `JELINEK-nabytek-a-matrace`, repo `brand-guide`, workflow filename
|
|
484
|
+
`ui.yml`, environment left empty. All three are checked on every publish
|
|
485
|
+
— **renaming `ui.yml`, or adding an `environment:` to the publish job,
|
|
486
|
+
breaks releases** until the publisher entry is edited to match.
|
|
487
|
+
|
|
488
|
+
Two consequences worth knowing:
|
|
489
|
+
|
|
490
|
+
- The job runs `npm publish`, not `pnpm publish`. The OIDC exchange lives
|
|
491
|
+
in the npm CLI (11.5.1+, which the job installs explicitly); pnpm would
|
|
492
|
+
look for an auth token, find none, and fail.
|
|
493
|
+
- Provenance stays off (`NPM_CONFIG_PROVENANCE=false`). npm can only
|
|
494
|
+
attest a build from a public repository and this one is private
|
|
495
|
+
(licensed fonts). Trusted publishing does not require provenance.
|
|
496
|
+
|
|
497
|
+
There is no `NPM_TOKEN` secret any more. It never worked: npm requires a
|
|
498
|
+
second factor for every publish, and a granular token only satisfies that
|
|
499
|
+
if "Bypass two-factor authentication (2FA)" was ticked *when the token was
|
|
500
|
+
created* — a setting that cannot be added afterwards, on a mechanism npm
|
|
501
|
+
is retiring outright (account changes Aug 2026, direct publishing Jan
|
|
502
|
+
2027). Without it the registry answered a `PUT` with a misleading `E404
|
|
503
|
+
Not Found` in CI. 0.3.0 through 0.5.0 were released by hand because of it.
|
|
504
|
+
|
|
505
|
+
### Fallback: publishing by hand
|
|
506
|
+
|
|
507
|
+
If the job fails, the release is not stuck — publish from a machine that
|
|
508
|
+
can reach a browser:
|
|
485
509
|
|
|
486
510
|
```
|
|
487
511
|
cd svelte && npm publish --access public --auth-type=web
|
|
@@ -490,13 +514,7 @@ cd svelte && npm publish --access public --auth-type=web
|
|
|
490
514
|
That prints a `npmjs.com/auth/cli/…` URL; approving it there with a
|
|
491
515
|
passkey (or an authenticator app) completes the publish. `--otp=<code>`
|
|
492
516
|
works too, but only with an authenticator app — a passkey produces no
|
|
493
|
-
code.
|
|
494
|
-
the tag is what records which commit a version came from.
|
|
495
|
-
|
|
496
|
-
Fixing CI means either a bypass-enabled token while npm still issues
|
|
497
|
-
them, or trusted publishing via GitHub Actions OIDC — the latter is
|
|
498
|
-
untested here and may be unavailable for the same reason provenance is,
|
|
499
|
-
namely that this repo is private.
|
|
517
|
+
code.
|
|
500
518
|
|
|
501
519
|
## Known design debt
|
|
502
520
|
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import type { HTMLButtonAttributes } from 'svelte/elements';
|
|
4
|
+
import { cn } from '../utils/cn.js';
|
|
5
|
+
|
|
6
|
+
type Color = 'brown' | 'magenta';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
label: string;
|
|
10
|
+
selected?: boolean;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
/** Short text shown over the disabled tile explaining WHY it cannot be
|
|
13
|
+
* picked ("Nelze kombinovat s výškou Komfort"). A disabled option with
|
|
14
|
+
* no reason reads as broken, not constrained. */
|
|
15
|
+
disabledNote?: string;
|
|
16
|
+
/** Accent of the selected border and the meter fill — same division
|
|
17
|
+
* pair as Tile. The utilities resolve through --JELINEK-BROWN /
|
|
18
|
+
* --JELINEK-MAGENTA, so an app remapping those per division section
|
|
19
|
+
* re-colours selected tiles without touching this prop. */
|
|
20
|
+
color?: Color;
|
|
21
|
+
/** Photo header (a fabric, a wood finish). Aspect 4:3, top corners
|
|
22
|
+
* follow the tile radius. */
|
|
23
|
+
image?: string;
|
|
24
|
+
/** Empty by default — the label below already names the option. */
|
|
25
|
+
imageAlt?: string;
|
|
26
|
+
/** CSS colour for a swatch-strip header (a lacquer tone, a weight
|
|
27
|
+
* category band) — the no-photograph alternative to `image`. */
|
|
28
|
+
swatch?: string;
|
|
29
|
+
/** Text centred on the swatch strip (matrace-fe's weight label). */
|
|
30
|
+
swatchLabel?: string;
|
|
31
|
+
/** PRE-FORMATTED price delta ("+1 670 Kč"). A string on purpose: money
|
|
32
|
+
* formatting is the app's business (locale, currency, sign), not this
|
|
33
|
+
* kit's — passing a number here would force one formatter on every
|
|
34
|
+
* consumer. Rendered in the accent colour beside the label; wraps to
|
|
35
|
+
* its own right-aligned line on narrow tiles instead of overflowing. */
|
|
36
|
+
price?: string;
|
|
37
|
+
/** Pre-formatted crossed-out price above `price` (a running discount). */
|
|
38
|
+
oldPrice?: string;
|
|
39
|
+
description?: string;
|
|
40
|
+
/** A small labelled level meter (matrace-fe's "Tuhost"): `value` of
|
|
41
|
+
* `max` bars filled with the accent colour. */
|
|
42
|
+
meter?: { label: string; value: number; max?: number };
|
|
43
|
+
/** A Tag ("Novinka", "Volitelné"). Over an image header it floats
|
|
44
|
+
* top-left on the photo; on a tile with no media it renders at the
|
|
45
|
+
* top of the body, above the label row. */
|
|
46
|
+
badge?: Snippet;
|
|
47
|
+
onselect?: () => void;
|
|
48
|
+
/** Extra content at the body's end. Anything interactive inside must
|
|
49
|
+
* carry `data-stop-card-select` so its clicks don't also select the
|
|
50
|
+
* tile. */
|
|
51
|
+
children?: Snippet;
|
|
52
|
+
class?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let {
|
|
56
|
+
label,
|
|
57
|
+
selected = false,
|
|
58
|
+
disabled = false,
|
|
59
|
+
disabledNote,
|
|
60
|
+
color = 'brown',
|
|
61
|
+
image,
|
|
62
|
+
imageAlt = '',
|
|
63
|
+
swatch,
|
|
64
|
+
swatchLabel,
|
|
65
|
+
price,
|
|
66
|
+
oldPrice,
|
|
67
|
+
description,
|
|
68
|
+
meter,
|
|
69
|
+
badge,
|
|
70
|
+
onselect,
|
|
71
|
+
children,
|
|
72
|
+
class: className,
|
|
73
|
+
...rest
|
|
74
|
+
}: Props & Omit<HTMLButtonAttributes, 'disabled'> = $props();
|
|
75
|
+
|
|
76
|
+
function handleClick(event: MouseEvent) {
|
|
77
|
+
if (disabled) return;
|
|
78
|
+
if ((event.target as HTMLElement).closest('[data-stop-card-select]')) return;
|
|
79
|
+
onselect?.();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// A selectable option in a configurator — Tile's form-control sibling,
|
|
83
|
+
// first built twice over (matrace-fe's OptionCard, then the e-shop's) and
|
|
84
|
+
// promoted here so the third app doesn't roll a fourth copy. The guide has
|
|
85
|
+
// no .option-tile rule; the geometry is Tile's (rounded-card, the
|
|
86
|
+
// border-subtle resting frame), the selected state is the division-accent
|
|
87
|
+
// border the e-shop shipped, and — unlike Tile — there is NO hover lift:
|
|
88
|
+
// this is a form control, and its feedback is the border, not motion.
|
|
89
|
+
//
|
|
90
|
+
// A real <button aria-pressed>, not a clickable <div>: keyboard and screen
|
|
91
|
+
// readers get the option for free (matrace-fe's div needed two a11y-lint
|
|
92
|
+
// waivers; this needs none).
|
|
93
|
+
const SELECTED: Record<Color, string> = {
|
|
94
|
+
brown: 'border-jelinek-brown shadow-resting',
|
|
95
|
+
magenta: 'border-jelinek-magenta shadow-resting'
|
|
96
|
+
};
|
|
97
|
+
const HOVER: Record<Color, string> = {
|
|
98
|
+
brown: 'hover:border-jelinek-brown-highlight',
|
|
99
|
+
magenta: 'hover:border-jelinek-magenta-highlight'
|
|
100
|
+
};
|
|
101
|
+
const METER_FILL: Record<Color, string> = {
|
|
102
|
+
brown: 'bg-jelinek-brown',
|
|
103
|
+
magenta: 'bg-jelinek-magenta'
|
|
104
|
+
};
|
|
105
|
+
const PRICE_TEXT: Record<Color, string> = {
|
|
106
|
+
brown: 'text-jelinek-brown',
|
|
107
|
+
magenta: 'text-jelinek-magenta'
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const BASE =
|
|
111
|
+
'w-full cursor-pointer rounded-card border-2 bg-surface text-left ' +
|
|
112
|
+
'font-secondary font-light text-fg ' +
|
|
113
|
+
'transition-[border-color,box-shadow] duration-[var(--dur-base)] ease-[var(--ease)]';
|
|
114
|
+
|
|
115
|
+
const classes = $derived(
|
|
116
|
+
cn(
|
|
117
|
+
BASE,
|
|
118
|
+
selected ? SELECTED[color] : cn('border-border-subtle', HOVER[color]),
|
|
119
|
+
disabled && 'pointer-events-none opacity-60',
|
|
120
|
+
className
|
|
121
|
+
)
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const meterMax = $derived(meter?.max ?? 5);
|
|
125
|
+
</script>
|
|
126
|
+
|
|
127
|
+
<div class="relative">
|
|
128
|
+
<button type="button" onclick={handleClick} aria-pressed={selected} {disabled} {...rest} class={classes}>
|
|
129
|
+
{#if image}
|
|
130
|
+
<div class="relative">
|
|
131
|
+
<img
|
|
132
|
+
src={image}
|
|
133
|
+
alt={imageAlt}
|
|
134
|
+
loading="lazy"
|
|
135
|
+
class="aspect-[4/3] w-full rounded-t-[calc(var(--radius-card)-2px)] object-cover"
|
|
136
|
+
/>
|
|
137
|
+
{#if badge}<div class="absolute top-2 left-2">{@render badge()}</div>{/if}
|
|
138
|
+
</div>
|
|
139
|
+
{:else if swatch}
|
|
140
|
+
<div
|
|
141
|
+
class="flex aspect-[4/1] w-full items-center justify-center rounded-t-[calc(var(--radius-card)-2px)]"
|
|
142
|
+
style="background:{swatch}"
|
|
143
|
+
>
|
|
144
|
+
{#if swatchLabel}
|
|
145
|
+
<!-- Literal white + a soft shadow: the swatch is an arbitrary
|
|
146
|
+
consumer colour, so no theme token can promise contrast.
|
|
147
|
+
`font-bold`, not `font-semibold`: GT Walsheim Pro ships
|
|
148
|
+
300/400/500/700/900 and no 600, so semibold silently
|
|
149
|
+
renders as Medium 500 (e2e/font-faces.spec.ts caught it).
|
|
150
|
+
`font-semibold` is only legal on the logo subtitle, which
|
|
151
|
+
switches to Avenir Next, whose Demi *is* 600. -->
|
|
152
|
+
<span class="font-bold text-white [text-shadow:0_1px_2px_rgba(0,0,0,0.35)]"
|
|
153
|
+
>{swatchLabel}</span
|
|
154
|
+
>
|
|
155
|
+
{/if}
|
|
156
|
+
</div>
|
|
157
|
+
{/if}
|
|
158
|
+
|
|
159
|
+
<div class="flex flex-col gap-1 p-4">
|
|
160
|
+
{#if badge && !image && !swatch}<div>{@render badge()}</div>{/if}
|
|
161
|
+
<!-- flex-wrap + ml-auto: the price is one unbreakable chunk (nbsp
|
|
162
|
+
grouping); on a narrow tile it drops to its own right-aligned
|
|
163
|
+
line instead of overflowing the border. -->
|
|
164
|
+
<div class="flex flex-wrap items-baseline justify-between gap-x-2">
|
|
165
|
+
<span class="font-bold">{label}</span>
|
|
166
|
+
{#if price || oldPrice}
|
|
167
|
+
<span class="ml-auto flex flex-col items-end">
|
|
168
|
+
{#if oldPrice}<span class="text-step-down2 text-fg-muted2 line-through"
|
|
169
|
+
>{oldPrice}</span
|
|
170
|
+
>{/if}
|
|
171
|
+
{#if price}<span
|
|
172
|
+
class={cn('text-step-down1 font-medium whitespace-nowrap', PRICE_TEXT[color])}
|
|
173
|
+
>{price}</span
|
|
174
|
+
>{/if}
|
|
175
|
+
</span>
|
|
176
|
+
{/if}
|
|
177
|
+
</div>
|
|
178
|
+
{#if description}<p class="text-step-down1 text-fg-muted1 m-0">{description}</p>{/if}
|
|
179
|
+
{#if meter}
|
|
180
|
+
<div class="mt-1 flex items-center gap-2">
|
|
181
|
+
<span class="text-step-down2 text-fg-muted2 font-medium tracking-wide uppercase"
|
|
182
|
+
>{meter.label}</span
|
|
183
|
+
>
|
|
184
|
+
<div class="flex gap-[3px]" role="img" aria-label={`${meter.label}: ${meter.value} z ${meterMax}`}>
|
|
185
|
+
{#each Array(meterMax) as _, i (i)}
|
|
186
|
+
<div
|
|
187
|
+
class={cn(
|
|
188
|
+
'h-1 w-3 rounded-[2px]',
|
|
189
|
+
i < meter.value ? METER_FILL[color] : 'bg-border-subtle'
|
|
190
|
+
)}
|
|
191
|
+
></div>
|
|
192
|
+
{/each}
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
{/if}
|
|
196
|
+
{#if children}<div class="mt-1">{@render children()}</div>{/if}
|
|
197
|
+
</div>
|
|
198
|
+
</button>
|
|
199
|
+
{#if disabled && disabledNote}
|
|
200
|
+
<div
|
|
201
|
+
class="rounded-card text-step-down1 absolute inset-0 grid place-items-center bg-[var(--glass-fill)] p-4 text-center font-medium backdrop-blur-sm"
|
|
202
|
+
>
|
|
203
|
+
{disabledNote}
|
|
204
|
+
</div>
|
|
205
|
+
{/if}
|
|
206
|
+
</div>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { HTMLButtonAttributes } from 'svelte/elements';
|
|
3
|
+
type Color = 'brown' | 'magenta';
|
|
4
|
+
interface Props {
|
|
5
|
+
label: string;
|
|
6
|
+
selected?: boolean;
|
|
7
|
+
disabled?: boolean;
|
|
8
|
+
/** Short text shown over the disabled tile explaining WHY it cannot be
|
|
9
|
+
* picked ("Nelze kombinovat s výškou Komfort"). A disabled option with
|
|
10
|
+
* no reason reads as broken, not constrained. */
|
|
11
|
+
disabledNote?: string;
|
|
12
|
+
/** Accent of the selected border and the meter fill — same division
|
|
13
|
+
* pair as Tile. The utilities resolve through --JELINEK-BROWN /
|
|
14
|
+
* --JELINEK-MAGENTA, so an app remapping those per division section
|
|
15
|
+
* re-colours selected tiles without touching this prop. */
|
|
16
|
+
color?: Color;
|
|
17
|
+
/** Photo header (a fabric, a wood finish). Aspect 4:3, top corners
|
|
18
|
+
* follow the tile radius. */
|
|
19
|
+
image?: string;
|
|
20
|
+
/** Empty by default — the label below already names the option. */
|
|
21
|
+
imageAlt?: string;
|
|
22
|
+
/** CSS colour for a swatch-strip header (a lacquer tone, a weight
|
|
23
|
+
* category band) — the no-photograph alternative to `image`. */
|
|
24
|
+
swatch?: string;
|
|
25
|
+
/** Text centred on the swatch strip (matrace-fe's weight label). */
|
|
26
|
+
swatchLabel?: string;
|
|
27
|
+
/** PRE-FORMATTED price delta ("+1 670 Kč"). A string on purpose: money
|
|
28
|
+
* formatting is the app's business (locale, currency, sign), not this
|
|
29
|
+
* kit's — passing a number here would force one formatter on every
|
|
30
|
+
* consumer. Rendered in the accent colour beside the label; wraps to
|
|
31
|
+
* its own right-aligned line on narrow tiles instead of overflowing. */
|
|
32
|
+
price?: string;
|
|
33
|
+
/** Pre-formatted crossed-out price above `price` (a running discount). */
|
|
34
|
+
oldPrice?: string;
|
|
35
|
+
description?: string;
|
|
36
|
+
/** A small labelled level meter (matrace-fe's "Tuhost"): `value` of
|
|
37
|
+
* `max` bars filled with the accent colour. */
|
|
38
|
+
meter?: {
|
|
39
|
+
label: string;
|
|
40
|
+
value: number;
|
|
41
|
+
max?: number;
|
|
42
|
+
};
|
|
43
|
+
/** A Tag ("Novinka", "Volitelné"). Over an image header it floats
|
|
44
|
+
* top-left on the photo; on a tile with no media it renders at the
|
|
45
|
+
* top of the body, above the label row. */
|
|
46
|
+
badge?: Snippet;
|
|
47
|
+
onselect?: () => void;
|
|
48
|
+
/** Extra content at the body's end. Anything interactive inside must
|
|
49
|
+
* carry `data-stop-card-select` so its clicks don't also select the
|
|
50
|
+
* tile. */
|
|
51
|
+
children?: Snippet;
|
|
52
|
+
class?: string;
|
|
53
|
+
}
|
|
54
|
+
type $$ComponentProps = Props & Omit<HTMLButtonAttributes, 'disabled'>;
|
|
55
|
+
declare const OptionTile: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
56
|
+
type OptionTile = ReturnType<typeof OptionTile>;
|
|
57
|
+
export default OptionTile;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import type { HTMLAnchorAttributes, HTMLAttributes } from 'svelte/elements';
|
|
4
|
+
import { cn } from '../utils/cn.js';
|
|
5
|
+
|
|
6
|
+
type Level = 'h2' | 'h3';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
/** With `href` the tile is a link; without it an inert block — same
|
|
10
|
+
* contract as Tile, and the same reasoning: an unlinked tile has
|
|
11
|
+
* nowhere to go, so it should not be keyboard-reachable either. */
|
|
12
|
+
href?: string;
|
|
13
|
+
/** The photograph filling the tile. */
|
|
14
|
+
image: string;
|
|
15
|
+
/** Empty by default: the visible title already names the destination,
|
|
16
|
+
* so the image is decorative unless a consumer says otherwise. */
|
|
17
|
+
imageAlt?: string;
|
|
18
|
+
title: string;
|
|
19
|
+
description?: string;
|
|
20
|
+
/** Heading tag for the title. `h3` matches Tile's own convention; an
|
|
21
|
+
* app using photo tiles as top-level section links (the e-shop's two
|
|
22
|
+
* division tiles) passes `h2`. */
|
|
23
|
+
level?: Level;
|
|
24
|
+
/** Passed straight to the <img>. Photo tiles usually sit below the
|
|
25
|
+
* fold, so lazy is the right default; a consumer rendering one above
|
|
26
|
+
* the fold passes `eager`. */
|
|
27
|
+
loading?: 'lazy' | 'eager';
|
|
28
|
+
class?: string;
|
|
29
|
+
/** Extra content under the description — a Tag, a small CTA line. */
|
|
30
|
+
children?: Snippet;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let {
|
|
34
|
+
href,
|
|
35
|
+
image,
|
|
36
|
+
imageAlt = '',
|
|
37
|
+
title,
|
|
38
|
+
description,
|
|
39
|
+
level = 'h3',
|
|
40
|
+
loading = 'lazy',
|
|
41
|
+
class: className,
|
|
42
|
+
children,
|
|
43
|
+
...rest
|
|
44
|
+
}: Props & Omit<HTMLAnchorAttributes & HTMLAttributes<HTMLDivElement>, 'href' | 'title'> =
|
|
45
|
+
$props();
|
|
46
|
+
|
|
47
|
+
// A photo tile is Tile's full-bleed photographic sibling — first built in
|
|
48
|
+
// the e-shop storefront (its home-page division tiles), promoted here so
|
|
49
|
+
// consumers stop hand-rolling it. The guide itself has no .photo-tile
|
|
50
|
+
// rule; every value below is either a shared token the guide's own
|
|
51
|
+
// surfaces use, or is forced by the photograph:
|
|
52
|
+
//
|
|
53
|
+
// - Geometry/motion are Tile's: rounded-section (a full-bleed photo card
|
|
54
|
+
// is section-scale, not card-scale), shadow-resting → shadow-interactive
|
|
55
|
+
// with the 2px hover lift, both on the guide's own
|
|
56
|
+
// box-shadow/transform transition pair (jelinek.css:924 — see Tile for
|
|
57
|
+
// why transition-all is wrong here).
|
|
58
|
+
// - `no-underline`: base.css underlines every anchor on hover; on a tile
|
|
59
|
+
// that underline bleeds onto the caption text (the exact defect the
|
|
60
|
+
// e-shop shipped with).
|
|
61
|
+
// - The scrim and the text are LITERAL black/white, not theme tokens, on
|
|
62
|
+
// purpose: they sit on a photograph, which does not re-theme.
|
|
63
|
+
// --JELINEK-WHITE inverts to hsl(0,0%,0%) in dark mode (tokens.css:96)
|
|
64
|
+
// — white-on-scrim text routed through it would go invisible on its own
|
|
65
|
+
// photo, the same trap SiteFooter documents for its logo.
|
|
66
|
+
const BASE =
|
|
67
|
+
'group relative flex flex-col justify-end overflow-hidden aspect-[4/3] ' +
|
|
68
|
+
'rounded-section shadow-resting no-underline ' +
|
|
69
|
+
'transition-[box-shadow,transform] duration-[var(--dur-base)] ease-[var(--ease)] ' +
|
|
70
|
+
'hover:-translate-y-0.5 hover:shadow-interactive ' +
|
|
71
|
+
'dark:shadow-[var(--shadow-resting),inset_0_2px_1px_-1px_var(--box-primary)] ' +
|
|
72
|
+
'dark:hover:shadow-[var(--shadow-interactive),inset_0_2px_1px_-1px_var(--box-primary)]';
|
|
73
|
+
|
|
74
|
+
// The photo zooms slowly under the pointer while the tile itself lifts on
|
|
75
|
+
// --dur-base — two speeds on purpose: the lift is feedback ("this is a
|
|
76
|
+
// control"), the zoom is atmosphere. 700ms is deliberate and NOT a --dur
|
|
77
|
+
// token: the scale is 8× the size of the 2px lift, and at --dur-slow
|
|
78
|
+
// (0.2s) it reads as a twitch, not a drift.
|
|
79
|
+
const IMG =
|
|
80
|
+
'absolute inset-0 h-full w-full object-cover ' +
|
|
81
|
+
'transition-transform duration-700 ease-[var(--ease)] group-hover:scale-105';
|
|
82
|
+
|
|
83
|
+
// Bottom-weighted scrim so white text passes contrast over any photo —
|
|
84
|
+
// strongest where the text sits, gone by mid-frame so the photo stays a
|
|
85
|
+
// photo.
|
|
86
|
+
const SCRIM = 'absolute inset-0 bg-gradient-to-t from-black/70 via-black/10 to-transparent';
|
|
87
|
+
|
|
88
|
+
const TEXT = 'relative p-6 sm:p-8';
|
|
89
|
+
const TITLE = 'font-primary text-step3 mb-1 font-bold text-white';
|
|
90
|
+
const DESC = 'text-step0 m-0 max-w-xs text-white/90';
|
|
91
|
+
|
|
92
|
+
const classes = $derived(cn(BASE, href && 'cursor-pointer', className));
|
|
93
|
+
</script>
|
|
94
|
+
|
|
95
|
+
{#snippet content()}
|
|
96
|
+
<img src={image} alt={imageAlt} {loading} class={IMG} />
|
|
97
|
+
<div class={SCRIM}></div>
|
|
98
|
+
<div class={TEXT}>
|
|
99
|
+
<svelte:element this={level} class={TITLE}>{title}</svelte:element>
|
|
100
|
+
{#if description}<p class={DESC}>{description}</p>{/if}
|
|
101
|
+
{#if children}{@render children()}{/if}
|
|
102
|
+
</div>
|
|
103
|
+
{/snippet}
|
|
104
|
+
|
|
105
|
+
{#if href}
|
|
106
|
+
<a {...rest} {href} class={classes}>{@render content()}</a>
|
|
107
|
+
{:else}
|
|
108
|
+
<div {...rest} class={classes}>{@render content()}</div>
|
|
109
|
+
{/if}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { HTMLAnchorAttributes, HTMLAttributes } from 'svelte/elements';
|
|
3
|
+
type Level = 'h2' | 'h3';
|
|
4
|
+
interface Props {
|
|
5
|
+
/** With `href` the tile is a link; without it an inert block — same
|
|
6
|
+
* contract as Tile, and the same reasoning: an unlinked tile has
|
|
7
|
+
* nowhere to go, so it should not be keyboard-reachable either. */
|
|
8
|
+
href?: string;
|
|
9
|
+
/** The photograph filling the tile. */
|
|
10
|
+
image: string;
|
|
11
|
+
/** Empty by default: the visible title already names the destination,
|
|
12
|
+
* so the image is decorative unless a consumer says otherwise. */
|
|
13
|
+
imageAlt?: string;
|
|
14
|
+
title: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
/** Heading tag for the title. `h3` matches Tile's own convention; an
|
|
17
|
+
* app using photo tiles as top-level section links (the e-shop's two
|
|
18
|
+
* division tiles) passes `h2`. */
|
|
19
|
+
level?: Level;
|
|
20
|
+
/** Passed straight to the <img>. Photo tiles usually sit below the
|
|
21
|
+
* fold, so lazy is the right default; a consumer rendering one above
|
|
22
|
+
* the fold passes `eager`. */
|
|
23
|
+
loading?: 'lazy' | 'eager';
|
|
24
|
+
class?: string;
|
|
25
|
+
/** Extra content under the description — a Tag, a small CTA line. */
|
|
26
|
+
children?: Snippet;
|
|
27
|
+
}
|
|
28
|
+
type $$ComponentProps = Props & Omit<HTMLAnchorAttributes & HTMLAttributes<HTMLDivElement>, 'href' | 'title'>;
|
|
29
|
+
declare const PhotoTile: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
30
|
+
type PhotoTile = ReturnType<typeof PhotoTile>;
|
|
31
|
+
export default PhotoTile;
|
package/dist/index.d.ts
CHANGED
|
@@ -15,9 +15,11 @@ export { default as Glass } from './components/Glass.svelte';
|
|
|
15
15
|
export { default as Grid } from './components/Grid.svelte';
|
|
16
16
|
export { default as Label } from './components/Label.svelte';
|
|
17
17
|
export { isActive, type NavItem, type NavGroup } from './components/nav.js';
|
|
18
|
+
export { default as OptionTile } from './components/OptionTile.svelte';
|
|
18
19
|
export { default as PageHero } from './components/PageHero.svelte';
|
|
19
20
|
export { default as Pager } from './components/Pager.svelte';
|
|
20
21
|
export { default as Panel } from './components/Panel.svelte';
|
|
22
|
+
export { default as PhotoTile } from './components/PhotoTile.svelte';
|
|
21
23
|
export { default as ProductCard } from './components/ProductCard.svelte';
|
|
22
24
|
export { default as Radio } from './components/Radio.svelte';
|
|
23
25
|
export { RADIO_CONTEXT, type RadioContext } from './components/radio-context.js';
|
package/dist/index.js
CHANGED
|
@@ -15,9 +15,11 @@ export { default as Glass } from './components/Glass.svelte';
|
|
|
15
15
|
export { default as Grid } from './components/Grid.svelte';
|
|
16
16
|
export { default as Label } from './components/Label.svelte';
|
|
17
17
|
export { isActive } from './components/nav.js';
|
|
18
|
+
export { default as OptionTile } from './components/OptionTile.svelte';
|
|
18
19
|
export { default as PageHero } from './components/PageHero.svelte';
|
|
19
20
|
export { default as Pager } from './components/Pager.svelte';
|
|
20
21
|
export { default as Panel } from './components/Panel.svelte';
|
|
22
|
+
export { default as PhotoTile } from './components/PhotoTile.svelte';
|
|
21
23
|
export { default as ProductCard } from './components/ProductCard.svelte';
|
|
22
24
|
export { default as Radio } from './components/Radio.svelte';
|
|
23
25
|
export { RADIO_CONTEXT } from './components/radio-context.js';
|