@jtakeit/astro 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.
- package/LICENSE +21 -0
- package/README.md +59 -0
- package/bin/jtk.mjs +41 -0
- package/docs/booking.md +164 -0
- package/docs/catalogue.md +459 -0
- package/docs/collections.md +249 -0
- package/docs/css.md +86 -0
- package/docs/gallery.md +127 -0
- package/docs/hero-motion.md +189 -0
- package/docs/kit.md +454 -0
- package/docs/languages.md +182 -0
- package/docs/lead-form.md +109 -0
- package/docs/pages.md +193 -0
- package/docs/photos.md +314 -0
- package/docs/scaffold.md +75 -0
- package/docs/shapes.md +140 -0
- package/docs/surface.md +187 -0
- package/lib/catalogue.mjs +1678 -0
- package/lib/codes.mjs +171 -0
- package/lib/create.mjs +282 -0
- package/package.json +16 -0
- package/template/astro.config.mjs +84 -0
- package/template/figures.mjs +122 -0
- package/template/gitignore +16 -0
- package/template/jtakeit-meta.mjs +112 -0
- package/template/jtk/content/index.json +38 -0
- package/template/jtk/design.json +24 -0
- package/template/markdown.mjs +36 -0
- package/template/package-lock.json +5320 -0
- package/template/package.json +26 -0
- package/template/specimens.mjs +46 -0
- package/template/src/components/Blocks.astro +151 -0
- package/template/src/components/BookingForm.astro +506 -0
- package/template/src/components/Clip.astro +155 -0
- package/template/src/components/Hero.astro +66 -0
- package/template/src/components/LeadForm.astro +347 -0
- package/template/src/components/OpeningHours.astro +69 -0
- package/template/src/components/Pile.astro +185 -0
- package/template/src/components/Shot.astro +472 -0
- package/template/src/components/gallery/Gallery.astro +381 -0
- package/template/src/components/gallery/galleries.ts +139 -0
- package/template/src/components/motion/HeroField.astro +520 -0
- package/template/src/components/motion/fields.ts +430 -0
- package/template/src/components/surface/Pattern.astro +278 -0
- package/template/src/components/surface/patterns.ts +187 -0
- package/template/src/content/blocks.ts +758 -0
- package/template/src/content.config.ts +19 -0
- package/template/src/copy/LOCALE.ts +324 -0
- package/template/src/data/site.ts +137 -0
- package/template/src/layouts/Layout.astro +282 -0
- package/template/src/lib/alive.ts +49 -0
- package/template/src/lib/entries.ts +106 -0
- package/template/src/lib/entryLoader.ts +315 -0
- package/template/src/lib/noise.ts +26 -0
- package/template/src/lib/page.ts +287 -0
- package/template/src/lib/photos.ts +168 -0
- package/template/src/lib/under.ts +32 -0
- package/template/src/lib/uploads.ts +85 -0
- package/template/src/pages/[...entry].astro +207 -0
- package/template/src/pages/[...feed].xml.ts +64 -0
- package/template/src/pages/index.astro +90 -0
- package/template/src/pages/llms.txt.ts +50 -0
- package/template/src/pages/privacy.astro +59 -0
- package/template/src/pages/robots.txt.ts +21 -0
- package/template/src/pages/sitemap.xml.ts +50 -0
- package/template/src/styles/global.css +411 -0
- package/template/src/styles/surface.css +375 -0
- package/template/tsconfig.json +5 -0
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* One photograph.
|
|
4
|
+
*
|
|
5
|
+
* A missing file must not take the layout with it: the frame keeps its aspect
|
|
6
|
+
* ratio and its plate from CSS alone, so a failed lookup costs the picture and
|
|
7
|
+
* nothing else. That is what lets a photo be swapped — or arrive late — without
|
|
8
|
+
* touching any page.
|
|
9
|
+
*
|
|
10
|
+
* The other rule this component exists to enforce: **a photograph is never
|
|
11
|
+
* stretched.** A frame narrower or taller than the picture crops into it; it
|
|
12
|
+
* never squeezes it. Distortion is the one image fault a client always sees and
|
|
13
|
+
* never has words for — "it looks weird" — so it is not left to per-variant
|
|
14
|
+
* CSS. `object-fit` is set here, in every branch, and a frame that would throw
|
|
15
|
+
* away most of the picture says so during the build.
|
|
16
|
+
*
|
|
17
|
+
* ── the treatment ───────────────────────────────────────────────────────────
|
|
18
|
+
*
|
|
19
|
+
* The second thing this component owns is what a photograph is *printed* like.
|
|
20
|
+
* Client photos arrive as a feed — twenty pictures in twenty lights, three
|
|
21
|
+
* phones, two years, white balance disagreeing between every pair — and cropped
|
|
22
|
+
* well they still disagree. A treatment is what makes them one set: a couple of
|
|
23
|
+
* blended layers over the picture, in the variant's own tokens, with no build
|
|
24
|
+
* step and no second copy of any file.
|
|
25
|
+
*
|
|
26
|
+
* Six of them, in references/photos.md, and two rules: one per site, and never
|
|
27
|
+
* a strong one on their face. The second is enforced below rather than advised.
|
|
28
|
+
*/
|
|
29
|
+
import { Image } from 'astro:assets';
|
|
30
|
+
import { GRAIN_TILE, GRAIN_SIZE_PHOTO } from '../lib/noise';
|
|
31
|
+
import { STRONG, altFor, isPerson, photo, shape, type Treatment } from '../lib/photos';
|
|
32
|
+
import design from '../../jtk/design.json';
|
|
33
|
+
|
|
34
|
+
interface Props {
|
|
35
|
+
/** File base name in src/assets, without the extension. */
|
|
36
|
+
name: string;
|
|
37
|
+
/** Overrides the alt text registered in photos.ts. */
|
|
38
|
+
alt?: string;
|
|
39
|
+
/**
|
|
40
|
+
* CSS aspect-ratio for the frame, e.g. '4 / 5'. Omit it to let the photograph
|
|
41
|
+
* keep its own proportions — client photos are shot in every orientation and
|
|
42
|
+
* a square crop takes the subject out of half of them.
|
|
43
|
+
*/
|
|
44
|
+
ratio?: string;
|
|
45
|
+
/**
|
|
46
|
+
* How the picture meets a frame it does not match. `cover` crops, `contain`
|
|
47
|
+
* fits the whole picture inside and leaves the plate visible around it. There
|
|
48
|
+
* is deliberately no third option: `fill` distorts.
|
|
49
|
+
*/
|
|
50
|
+
fit?: 'cover' | 'contain';
|
|
51
|
+
/**
|
|
52
|
+
* What the crop keeps when it cannot keep everything — any `object-position`
|
|
53
|
+
* value. The default centres, which is wrong for nearly every portrait: a
|
|
54
|
+
* face sits in the top third, and a wide hero frame crops it off. `50% 25%`
|
|
55
|
+
* is the usual fix.
|
|
56
|
+
*/
|
|
57
|
+
focus?: string;
|
|
58
|
+
/** Rendered width hint for the srcset. */
|
|
59
|
+
width?: number;
|
|
60
|
+
/** The first contentful image on the page. One per page, never two. */
|
|
61
|
+
eager?: boolean;
|
|
62
|
+
/** Describes the layout, not the file. A wrong value is why a phone still
|
|
63
|
+
downloads the 2400px variant. */
|
|
64
|
+
sizes?: string;
|
|
65
|
+
caption?: string;
|
|
66
|
+
/**
|
|
67
|
+
* `data-jtk-path` for this picture, where it comes from the content document.
|
|
68
|
+
*
|
|
69
|
+
* It goes on the `<img>` and on the placeholder that stands in for a missing
|
|
70
|
+
* file — **never on the figure around them**. The admin replaces a picture by
|
|
71
|
+
* setting `src` on the element it was told about, and an element that is not
|
|
72
|
+
* an image gets its text replaced instead: annotating the figure turns the
|
|
73
|
+
* client's own photograph into a URL printed where the picture was.
|
|
74
|
+
*/
|
|
75
|
+
path?: string;
|
|
76
|
+
/**
|
|
77
|
+
* This picture says nothing the page does not already say.
|
|
78
|
+
*
|
|
79
|
+
* A pile of four photographs is one thing to look at and four alt texts read
|
|
80
|
+
* out in a row, which is worse for a screen reader than one. So the frame
|
|
81
|
+
* that carries the meaning keeps its alt and the rest are marked here: empty
|
|
82
|
+
* alt, which is how a decorative image is skipped rather than described
|
|
83
|
+
* badly. It is a composition decision, so it is per use, not per file.
|
|
84
|
+
*/
|
|
85
|
+
decorative?: boolean;
|
|
86
|
+
/**
|
|
87
|
+
* How the picture is printed: none · grade · duotone · film · press · recede.
|
|
88
|
+
*
|
|
89
|
+
* Defaults to whatever `jtk/design.json` chose for the site, which is
|
|
90
|
+
* the answer nearly always — a treatment is a property of the page, not of
|
|
91
|
+
* one picture, and a page where two photographs are printed differently reads
|
|
92
|
+
* as a page where nobody decided. Set it per photo only to take one *out* of
|
|
93
|
+
* the treatment, or to push one photograph back under copy with `recede`.
|
|
94
|
+
*/
|
|
95
|
+
treatment?: Treatment;
|
|
96
|
+
/** 0–1, how far the treatment goes. Below ~0.4 nobody can tell it is on. */
|
|
97
|
+
amount?: number;
|
|
98
|
+
class?: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const {
|
|
102
|
+
name,
|
|
103
|
+
alt,
|
|
104
|
+
ratio,
|
|
105
|
+
fit = 'cover',
|
|
106
|
+
focus = '50% 50%',
|
|
107
|
+
width = 900,
|
|
108
|
+
eager = false,
|
|
109
|
+
/* Why the <Image> below is given layout="none".
|
|
110
|
+
This component picks its own widths and its own sizes, so the project's
|
|
111
|
+
default layout has nothing to add and would only put its own attributes on
|
|
112
|
+
top of them. That default exists for markdown bodies, which have no props
|
|
113
|
+
to pick with — see astro.config.mjs.
|
|
114
|
+
|
|
115
|
+
The note lives here rather than beside the attribute it explains, because
|
|
116
|
+
a JSX-style comment between two attributes is not valid Astro. It parses
|
|
117
|
+
as the start of an expression, and the five errors it produced began with
|
|
118
|
+
"unterminated string literal" a hundred lines further down. */
|
|
119
|
+
sizes = '(min-width: 900px) 50vw, 92vw',
|
|
120
|
+
caption,
|
|
121
|
+
path,
|
|
122
|
+
decorative = false,
|
|
123
|
+
treatment = (design.photos?.treatment || 'none') as Treatment,
|
|
124
|
+
/* 0 in the document means unchosen, the same as everywhere else in it — a
|
|
125
|
+
treatment that is genuinely off is `treatment: "none"`, not amount zero. */
|
|
126
|
+
amount = design.photos?.amount || undefined,
|
|
127
|
+
class: className,
|
|
128
|
+
} = Astro.props;
|
|
129
|
+
|
|
130
|
+
const src = photo(name);
|
|
131
|
+
const text = decorative ? '' : (alt ?? altFor(name));
|
|
132
|
+
|
|
133
|
+
/*
|
|
134
|
+
* A face is not a surface to decorate.
|
|
135
|
+
*
|
|
136
|
+
* `duotone` and `press` replace skin colour, and a one-person business is
|
|
137
|
+
* chosen by that face — turned two-tone it reads as stock art, which is the
|
|
138
|
+
* opposite of what the photograph is there to say. photos.md has the long
|
|
139
|
+
* version; this is the part that cannot be forgotten under time pressure,
|
|
140
|
+
* because it stops the build instead of looking fine in the window it was
|
|
141
|
+
* written in.
|
|
142
|
+
*
|
|
143
|
+
* The escape is explicit and per photo: pass `treatment="grade"`, or do not
|
|
144
|
+
* register the slot as a person if it genuinely is not their face.
|
|
145
|
+
*/
|
|
146
|
+
if (STRONG.includes(treatment) && isPerson(name)) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`<Shot name="${name}" treatment="${treatment}"> — that slot is registered as the person ` +
|
|
149
|
+
'this business is, and this treatment replaces skin colour. Their face is what a ' +
|
|
150
|
+
'customer is choosing: leave it alone, or pass treatment="grade". See photos.md.',
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const treated = treatment !== 'none';
|
|
155
|
+
const tone = [
|
|
156
|
+
amount !== undefined ? `--photo-amount:${amount}` : '',
|
|
157
|
+
treatment === 'film' ? `--photo-grain:${GRAIN_TILE};--photo-grain-size:${GRAIN_SIZE_PHOTO}` : '',
|
|
158
|
+
]
|
|
159
|
+
.filter(Boolean)
|
|
160
|
+
.join(';');
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Measure before placing. A frame is a decision about which part of the
|
|
164
|
+
* photograph the visitor never sees, and taken by feel it is usually taken
|
|
165
|
+
* wrong — a 4:5 portrait dropped into a 16:9 band loses 55% of itself, most of
|
|
166
|
+
* it the half with the subject in it.
|
|
167
|
+
*
|
|
168
|
+
* This does not fail the build: some crops are deliberate. It puts the number
|
|
169
|
+
* in the build log in front of whoever chose the frame, at the moment they can
|
|
170
|
+
* still change it.
|
|
171
|
+
*/
|
|
172
|
+
const asked = ratio ? Number(ratio.split('/')[0]) / Number(ratio.split('/')[1] ?? 1) : null;
|
|
173
|
+
const own = shape(name);
|
|
174
|
+
if (asked && own && fit === 'cover') {
|
|
175
|
+
const kept = Math.min(asked, own.ratio) / Math.max(asked, own.ratio);
|
|
176
|
+
if (kept < 0.6) {
|
|
177
|
+
console.warn(
|
|
178
|
+
`[shot] ${name} is ${own.width}×${own.height} (${own.orientation}) and the frame asks for ` +
|
|
179
|
+
`${ratio} — the crop throws away ${Math.round((1 - kept) * 100)}% of it.`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const frame = ratio ? `aspect-ratio:${ratio}` : undefined;
|
|
185
|
+
const placement = `--shot-fit:${fit};--shot-focus:${focus}`;
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
{
|
|
189
|
+
src ? (
|
|
190
|
+
<figure class:list={['shot', !ratio && 'shot--natural', className]}>
|
|
191
|
+
<div
|
|
192
|
+
class="shot__frame"
|
|
193
|
+
data-treatment={treated ? treatment : undefined}
|
|
194
|
+
style={[frame, placement, tone].filter(Boolean).join(';')}
|
|
195
|
+
>
|
|
196
|
+
<Image
|
|
197
|
+
src={src}
|
|
198
|
+
alt={text}
|
|
199
|
+
data-jtk-path={path}
|
|
200
|
+
widths={[Math.round(width / 2), width, width * 2]}
|
|
201
|
+
sizes={sizes}
|
|
202
|
+
layout="none"
|
|
203
|
+
loading={eager ? 'eager' : 'lazy'}
|
|
204
|
+
decoding={eager ? 'sync' : 'async'}
|
|
205
|
+
fetchpriority={eager ? 'high' : undefined}
|
|
206
|
+
/>
|
|
207
|
+
{treated && (
|
|
208
|
+
<>
|
|
209
|
+
<span class="shot__tone shot__tone--a" aria-hidden="true" />
|
|
210
|
+
<span class="shot__tone shot__tone--b" aria-hidden="true" />
|
|
211
|
+
</>
|
|
212
|
+
)}
|
|
213
|
+
</div>
|
|
214
|
+
{caption && <figcaption class="shot__caption">{caption}</figcaption>}
|
|
215
|
+
</figure>
|
|
216
|
+
) : (
|
|
217
|
+
<div class:list={['shot', 'shot--placeholder', className]} style={frame ?? 'aspect-ratio:4 / 3'}>
|
|
218
|
+
{name}
|
|
219
|
+
{/*
|
|
220
|
+
The frame is empty and it is still the thing to tap.
|
|
221
|
+
A client who adds a photograph in the admin has to see it land, and what
|
|
222
|
+
the admin patches is an image's `src` — so there is an image here, one
|
|
223
|
+
transparent pixel of it, waiting. Without it the annotation would have to
|
|
224
|
+
go on the box, and patching a box replaces its text.
|
|
225
|
+
*/}
|
|
226
|
+
{path && (
|
|
227
|
+
<img
|
|
228
|
+
class="shot__ghost"
|
|
229
|
+
data-jtk-path={path}
|
|
230
|
+
alt=""
|
|
231
|
+
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
|
|
232
|
+
/>
|
|
233
|
+
)}
|
|
234
|
+
</div>
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
<style>
|
|
239
|
+
.shot {
|
|
240
|
+
margin: 0;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/*
|
|
244
|
+
* The frame, and why it is not the <figure>.
|
|
245
|
+
*
|
|
246
|
+
* The crop, the radius and the treatment's layers all belong to the picture's
|
|
247
|
+
* box; a caption sits under it and must be in none of them. With the ratio on
|
|
248
|
+
* the figure, a captioned <Shot> also clipped its own caption — the figure was
|
|
249
|
+
* exactly the picture's height and `overflow: hidden` did the rest.
|
|
250
|
+
*/
|
|
251
|
+
.shot__frame {
|
|
252
|
+
position: relative;
|
|
253
|
+
isolation: isolate;
|
|
254
|
+
overflow: hidden;
|
|
255
|
+
border-radius: var(--r-2, 8px);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/*
|
|
259
|
+
* The picture fills the frame and is cropped by it. `object-fit` is not
|
|
260
|
+
* optional here and there is no path through this component that leaves it
|
|
261
|
+
* unset: an <img> given both dimensions without it is stretched, which is the
|
|
262
|
+
* fault this component exists to make impossible.
|
|
263
|
+
*/
|
|
264
|
+
.shot :global(img) {
|
|
265
|
+
display: block;
|
|
266
|
+
inline-size: 100%;
|
|
267
|
+
block-size: 100%;
|
|
268
|
+
object-fit: var(--shot-fit, cover);
|
|
269
|
+
object-position: var(--shot-focus, 50% 50%);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/* Without a declared ratio the picture sets the height and object-fit has
|
|
273
|
+
nothing to crop — the frame is only there for the radius and the caption. */
|
|
274
|
+
.shot--natural :global(img) {
|
|
275
|
+
block-size: auto;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/* The waiting image: over the label, filling the frame, invisible until it
|
|
279
|
+
is given a picture. */
|
|
280
|
+
.shot__ghost {
|
|
281
|
+
position: absolute;
|
|
282
|
+
inset: 0;
|
|
283
|
+
inline-size: 100%;
|
|
284
|
+
block-size: 100%;
|
|
285
|
+
object-fit: cover;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/* Deliberately visible and labelled: a gap you can see is a gap somebody
|
|
289
|
+
fills, and a gap that looks like a bug is a gap that ships. */
|
|
290
|
+
.shot--placeholder {
|
|
291
|
+
position: relative;
|
|
292
|
+
overflow: hidden;
|
|
293
|
+
border-radius: var(--r-2, 8px);
|
|
294
|
+
display: grid;
|
|
295
|
+
place-items: center;
|
|
296
|
+
background: var(--paper-deep, #eceae5);
|
|
297
|
+
color: var(--ink-quiet, #8a857d);
|
|
298
|
+
font: 500 0.75rem/1 system-ui, sans-serif;
|
|
299
|
+
letter-spacing: 0.08em;
|
|
300
|
+
text-transform: uppercase;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/* ── treatments ────────────────────────────────────────────────────────────
|
|
304
|
+
*
|
|
305
|
+
* Two layers over the picture and a filter on it. Nothing here names a
|
|
306
|
+
* colour: the ink comes from --photo-shadow / --photo-light / --photo-ink,
|
|
307
|
+
* which are tokens, so a treatment follows the variant's palette and the
|
|
308
|
+
* studio bar's live accent the same way a ground does.
|
|
309
|
+
*
|
|
310
|
+
* --photo-amount is the volume knob and multiplies every layer. At 0 the
|
|
311
|
+
* photograph is untouched, which is what makes it safe to turn down rather
|
|
312
|
+
* than off when a client says "less".
|
|
313
|
+
*
|
|
314
|
+
* Where blend modes do not land, what is left is the photograph. That is the
|
|
315
|
+
* whole failure path.
|
|
316
|
+
*/
|
|
317
|
+
.shot__tone {
|
|
318
|
+
position: absolute;
|
|
319
|
+
inset: 0;
|
|
320
|
+
pointer-events: none;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/*
|
|
324
|
+
* grade — the page's own light. Colour is kept; the shadows are pulled toward
|
|
325
|
+
* the ground's hue and the highlights toward the paper, which is what stops
|
|
326
|
+
* eight photographs shot in eight kitchens from disagreeing. The gentle one,
|
|
327
|
+
* and the only one that is safe on a face.
|
|
328
|
+
*/
|
|
329
|
+
.shot__frame[data-treatment='grade'] :global(img) {
|
|
330
|
+
filter: saturate(0.94) contrast(1.05);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
.shot__frame[data-treatment='grade'] .shot__tone--a {
|
|
334
|
+
background: var(--photo-shadow);
|
|
335
|
+
mix-blend-mode: multiply;
|
|
336
|
+
opacity: calc(0.22 * var(--photo-amount, 1));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
.shot__frame[data-treatment='grade'] .shot__tone--b {
|
|
340
|
+
background: var(--photo-light);
|
|
341
|
+
mix-blend-mode: screen;
|
|
342
|
+
opacity: calc(0.12 * var(--photo-amount, 1));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/*
|
|
346
|
+
* duotone — the colour is thrown away and the tonal range is remapped onto two
|
|
347
|
+
* tokens: `lighten` raises the blacks to the shadow colour, `multiply` pulls
|
|
348
|
+
* the whites down to the light one. The strongest unifier here, because a
|
|
349
|
+
* photograph's original colour is exactly what was disagreeing.
|
|
350
|
+
*
|
|
351
|
+
* Never on their face. <Shot> fails the build rather than let it happen.
|
|
352
|
+
*/
|
|
353
|
+
.shot__frame[data-treatment='duotone'] :global(img) {
|
|
354
|
+
filter: grayscale(1) contrast(1.12) brightness(1.02);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
.shot__frame[data-treatment='duotone'] .shot__tone--a {
|
|
358
|
+
background: var(--photo-shadow);
|
|
359
|
+
mix-blend-mode: lighten;
|
|
360
|
+
opacity: calc(0.92 * var(--photo-amount, 1));
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
.shot__frame[data-treatment='duotone'] .shot__tone--b {
|
|
364
|
+
background: var(--photo-light);
|
|
365
|
+
mix-blend-mode: multiply;
|
|
366
|
+
opacity: calc(0.92 * var(--photo-amount, 1));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/*
|
|
370
|
+
* film — lifted blacks, a milky highlight and grain. It is what makes a phone
|
|
371
|
+
* photograph look like it was taken on purpose.
|
|
372
|
+
*
|
|
373
|
+
* True halation — the bright-pass blurred back over the picture — is
|
|
374
|
+
* deliberately not here: it costs a second copy of every image and a
|
|
375
|
+
* full-width blur to composite, and on a page of eight photographs that is
|
|
376
|
+
* paid eight times for the last tenth of the effect.
|
|
377
|
+
*/
|
|
378
|
+
.shot__frame[data-treatment='film'] :global(img) {
|
|
379
|
+
filter: saturate(0.88) contrast(0.94) brightness(1.05);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
.shot__frame[data-treatment='film'] .shot__tone--a {
|
|
383
|
+
background: var(--photo-light);
|
|
384
|
+
mix-blend-mode: screen;
|
|
385
|
+
opacity: calc(0.12 * var(--photo-amount, 1));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/* Without masks the grain layer would be a flat wash of ink over the
|
|
389
|
+
photograph, so it is not drawn at all. */
|
|
390
|
+
.shot__frame[data-treatment='film'] .shot__tone--b {
|
|
391
|
+
display: none;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
@supports (mask-image: linear-gradient(#000, #000)) or
|
|
395
|
+
(-webkit-mask-image: linear-gradient(#000, #000)) {
|
|
396
|
+
.shot__frame[data-treatment='film'] .shot__tone--b {
|
|
397
|
+
display: block;
|
|
398
|
+
background-color: var(--photo-shadow);
|
|
399
|
+
-webkit-mask-image: var(--photo-grain);
|
|
400
|
+
mask-image: var(--photo-grain);
|
|
401
|
+
-webkit-mask-size: var(--photo-grain-size, 90px) var(--photo-grain-size, 90px);
|
|
402
|
+
mask-size: var(--photo-grain-size, 90px) var(--photo-grain-size, 90px);
|
|
403
|
+
mix-blend-mode: overlay;
|
|
404
|
+
opacity: calc(0.55 * var(--photo-amount, 1));
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/*
|
|
409
|
+
* press — screen-printed: one ink, hard contrast, a dot lattice over it. The
|
|
410
|
+
* photograph stops pretending to be a photograph, which is the point on a page
|
|
411
|
+
* whose ground is already `riso` or `hatch`.
|
|
412
|
+
*
|
|
413
|
+
* Not a real halftone — the dots are one size rather than sized by the tone
|
|
414
|
+
* under them, which would need a per-pixel pass. What it borrows is the
|
|
415
|
+
* register, and at arm's length that is the whole tell.
|
|
416
|
+
*/
|
|
417
|
+
.shot__frame[data-treatment='press'] :global(img) {
|
|
418
|
+
filter: grayscale(1) contrast(1.45) brightness(1.06);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
.shot__frame[data-treatment='press'] .shot__tone--a {
|
|
422
|
+
background-image: radial-gradient(var(--photo-shadow) 40%, transparent 44%);
|
|
423
|
+
background-size: var(--photo-dot, 4px) var(--photo-dot, 4px);
|
|
424
|
+
mix-blend-mode: multiply;
|
|
425
|
+
opacity: calc(0.5 * var(--photo-amount, 1));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
.shot__frame[data-treatment='press'] .shot__tone--b {
|
|
429
|
+
background: var(--photo-ink);
|
|
430
|
+
mix-blend-mode: multiply;
|
|
431
|
+
opacity: calc(0.35 * var(--photo-amount, 1));
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/*
|
|
435
|
+
* recede — the photograph pushed back into the page so that copy can sit on
|
|
436
|
+
* it. It is the band-sized answer to the same problem `scrim` solves for the
|
|
437
|
+
* hero's canvas: contrast that holds over the light half of a picture does
|
|
438
|
+
* not hold over the dark half.
|
|
439
|
+
*
|
|
440
|
+
* A plain veil, not a blend: it has to work over a photograph whose tones are
|
|
441
|
+
* unknown, and every blend mode has a picture that defeats it.
|
|
442
|
+
*/
|
|
443
|
+
.shot__frame[data-treatment='recede'] :global(img) {
|
|
444
|
+
filter: saturate(0.7) contrast(0.92);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
.shot__frame[data-treatment='recede'] .shot__tone--a {
|
|
448
|
+
background: var(--photo-veil);
|
|
449
|
+
opacity: calc(0.55 * var(--photo-amount, 1));
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
.shot__frame[data-treatment='recede'] .shot__tone--b {
|
|
453
|
+
display: none;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/* Ink is expensive and a treatment is not the picture. */
|
|
457
|
+
@media print {
|
|
458
|
+
.shot__tone {
|
|
459
|
+
display: none;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
.shot__frame :global(img) {
|
|
463
|
+
filter: none;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
.shot__caption {
|
|
468
|
+
padding-block-start: 0.5rem;
|
|
469
|
+
font-size: 0.8125rem;
|
|
470
|
+
color: var(--ink-quiet, #6f6a63);
|
|
471
|
+
}
|
|
472
|
+
</style>
|