@sehv-oss/basketball-upload 1.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ISC License
2
+
3
+ Copyright 2026 sehv-oss
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/NOTICE.md ADDED
@@ -0,0 +1,23 @@
1
+ # Notice
2
+
3
+ ## Design
4
+
5
+ The basketball file upload implemented here, from the dropzone that doubles as a backboard to the slingshot shot, the aiming dots, the swish of the net and the upload list, is a design by **Jorge Molina**, published on Figma Community.
6
+
7
+ - GitHub: [jm-fuster](https://github.com/jm-fuster)
8
+ - Figma Community: [@jm_fuster](https://www.figma.com/@jm_fuster)
9
+ - Website: [jorgemolinafuster.com](https://jorgemolinafuster.com)
10
+
11
+ This project is an independent code implementation of that design, made with admiration for the original work. It is not affiliated with or endorsed by the author.
12
+
13
+ Changes and additions made in this implementation:
14
+
15
+ - the Web Component and React APIs, and the upload pipeline behind the list;
16
+ - the physics of the shot, and keyboard (assisted) shots;
17
+ - the dark theme and the theming tokens;
18
+ - built-in and custom file types;
19
+ - form association, accessibility announcements and reduced motion support.
20
+
21
+ ## Code
22
+
23
+ The code is licensed under the [ISC License](LICENSE).
package/README.md ADDED
@@ -0,0 +1,435 @@
1
+ # @sehv-oss/basketball-upload
2
+
3
+ Drag and drop, or take the shot: a basketball-themed file upload Web Component.
4
+
5
+ `<basketball-upload>` is a dropzone shaped like a backboard. Files dropped on it go straight through the hoop; files dropped on the court (or picked with the file dialog) wait there as cards, to be pulled back like a slingshot and shot into the basket. Each file that scores is uploaded by your `uploader`, with its progress in a list.
6
+
7
+ It lives in a Shadow DOM, is themed with CSS custom properties, `::part()` and `:state()`, follows light, dark or system color schemes, and is a form-associated custom element.
8
+
9
+ **[Live demo](https://sehv-oss.github.io/basketball-upload/)** · React: [`@sehv-oss/basketball-upload-react`](../react)
10
+
11
+ Design by **Jorge Molina** ([jm-fuster](https://github.com/jm-fuster), [@jm_fuster](https://www.figma.com/@jm_fuster) on Figma Community). This package is an independent implementation of his design; see [NOTICE.md](NOTICE.md).
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ # npm
17
+ npm install @sehv-oss/basketball-upload
18
+
19
+ # pnpm
20
+ pnpm add @sehv-oss/basketball-upload
21
+
22
+ # yarn
23
+ yarn add @sehv-oss/basketball-upload
24
+ ```
25
+
26
+ The package is ESM-only and has no dependencies.
27
+
28
+ ## Usage
29
+
30
+ Registration is explicit: importing the package never defines an element on its own.
31
+
32
+ ```ts
33
+ import { registerBasketballUpload } from '@sehv-oss/basketball-upload';
34
+
35
+ registerBasketballUpload();
36
+ ```
37
+
38
+ ```html
39
+ <basketball-upload multiple accept="image/*,.pdf"></basketball-upload>
40
+ ```
41
+
42
+ `registerBasketballUpload` is idempotent and does nothing where `customElements` does not exist, so it is safe to call from any module, including on the server. Pass `tagName` to register under another name: `registerBasketballUpload({ tagName: 'my-upload' })`, then [declare it for TypeScript](#events).
43
+
44
+ The element is a block that grows with its content (at least `36rem` tall). Its width drives everything else: the hoop is up to `25rem` wide and the shot scales with it.
45
+
46
+ To avoid a flash of unstyled content before registration:
47
+
48
+ ```css
49
+ basketball-upload:not(:defined) {
50
+ visibility: hidden;
51
+ }
52
+ ```
53
+
54
+ ### How files get in
55
+
56
+ | You… | The file… |
57
+ | ---------------------------------------------------------- | --------------------------------------------------- |
58
+ | drop it on the dropzone (the backboard) | is dunked: it goes straight through the hoop |
59
+ | drop it anywhere else, or pick it with the file dialog | lands on the court, ready to be shot |
60
+ | pull a card back and let go | flies along the dotted arc: score, or miss |
61
+ | focus a card and hold <kbd>Enter</kbd> or <kbd>Space</kbd> | aims a perfect, assisted shot; letting go shoots it |
62
+ | set `instant` | always goes straight in, never to the court |
63
+
64
+ A file is in the basket once it goes through the net: it is counted, listed and uploaded. A missed card bounces and comes back to the court.
65
+
66
+ While a card is held with the keyboard, <kbd>←</kbd> / <kbd>→</kbd> turn the shot and <kbd>↑</kbd> / <kbd>↓</kbd> make it stronger or weaker, so it can miss too; a quick press is still a perfect shot.
67
+
68
+ Clicking the dropzone (or <kbd>Enter</kbd> / <kbd>Space</kbd> on it) opens the file dialog. <kbd>Escape</kbd> drops the current aim. With `prefers-reduced-motion: reduce`, shots go in without flying.
69
+
70
+ ## Uploads
71
+
72
+ Files are uploaded by a function you provide:
73
+
74
+ ```ts
75
+ type Uploader = (
76
+ file: File,
77
+ context: {
78
+ signal: AbortSignal; // aborted when the file is removed or the basket cleared
79
+ onProgress: (loaded: number, total?: number) => void; // total defaults to file.size
80
+ }
81
+ ) => Promise<unknown>; // resolve when stored, reject when it failed
82
+ ```
83
+
84
+ ```ts
85
+ const hoop = document.querySelector('basketball-upload');
86
+
87
+ hoop.uploader = async (file, { signal, onProgress }) => {
88
+ const response = await fetch(
89
+ `/api/uploads/${encodeURIComponent(file.name)}`,
90
+ {
91
+ method: 'PUT',
92
+ body: file,
93
+ signal,
94
+ }
95
+ );
96
+ if (!response.ok) throw new Error(`Upload failed: ${response.status}`);
97
+
98
+ onProgress(file.size);
99
+
100
+ return response.json();
101
+ };
102
+ ```
103
+
104
+ `fetch` cannot report the progress of a request body, so the package ships an uploader built on `XMLHttpRequest`, which can:
105
+
106
+ ```ts
107
+ import { createXhrUploader } from '@sehv-oss/basketball-upload';
108
+
109
+ hoop.uploader = createXhrUploader({
110
+ url: '/api/uploads', // or (file) => url
111
+ method: 'POST', // default
112
+ fieldName: 'file', // multipart field; null sends the file as the body
113
+ fields: { folder: 'tickets' }, // extra form fields, or (file) => fields
114
+ headers: { Authorization: `Bearer ${token}` }, // or (file) => headers
115
+ withCredentials: false,
116
+ });
117
+ ```
118
+
119
+ It resolves with the parsed JSON response (or its text) and rejects with an `UploadError` carrying the HTTP `status` and response `body`.
120
+
121
+ Up to `concurrency` uploads (3 by default) run at a time; the others wait as `queued`. Failed uploads show a retry button. Without an `uploader`, files are `ready`: they travel with their form (see below) and nothing is sent.
122
+
123
+ ## Forms
124
+
125
+ `<basketball-upload>` is a form-associated custom element:
126
+
127
+ ```html
128
+ <form method="post" enctype="multipart/form-data">
129
+ <basketball-upload name="attachments" multiple required></basketball-upload>
130
+ <button>Send</button>
131
+ </form>
132
+ ```
133
+
134
+ - The files in the basket are submitted under `name`, like `<input type="file" multiple>`.
135
+ - `required` makes the form invalid while the basket is empty; the message anchors on the dropzone.
136
+ - Resetting the form empties the element; disabling its `<fieldset>` disables it.
137
+ - `form`, `validity`, `validationMessage`, `willValidate`, `checkValidity()` and `reportValidity()` work as on native controls.
138
+
139
+ ## API
140
+
141
+ ### Attributes
142
+
143
+ | Attribute | Property | Default | Description |
144
+ | ------------- | ------------- | -------- | ------------------------------------------------------------------ |
145
+ | `theme` | `theme` | `system` | `light`, `dark` or `system` |
146
+ | `accept` | `accept` | `''` | Accepted files, as in `<input type="file" accept>`: `.pdf,image/*` |
147
+ | `multiple` | `multiple` | `false` | Several files. Without it, a new file replaces the previous one |
148
+ | `max-size` | `maxSize` | `null` | Largest accepted file, in bytes |
149
+ | `max-files` | `maxFiles` | `null` | Most files at once, on the court and in the basket |
150
+ | `name` | `name` | `''` | Form field the files are submitted under |
151
+ | `required` | `required` | `false` | The form is invalid while the basket is empty |
152
+ | `disabled` | `disabled` | `false` | Ignores files and shots |
153
+ | `instant` | `instant` | `false` | No shooting: every file goes straight into the basket |
154
+ | `concurrency` | `concurrency` | `3` | Uploads running at the same time |
155
+
156
+ ### Properties
157
+
158
+ | Property | Type | Description |
159
+ | ----------- | ----------------------- | ------------------------------------------------------------------- |
160
+ | `uploader` | `Uploader \| null` | Sends each file in the basket |
161
+ | `messages` | `Messages` | Copy and accessible names; assign a partial object to override some |
162
+ | `fileTypes` | `readonly FileType[]` | File types for this element, checked before the registered ones |
163
+ | `items` | `readonly UploadItem[]` | Files in the basket and their uploads |
164
+ | `files` | `readonly File[]` | Files in the basket: the form value |
165
+
166
+ ```ts
167
+ interface UploadItem {
168
+ id: string;
169
+ file: File;
170
+ status: 'ready' | 'queued' | 'uploading' | 'uploaded' | 'error';
171
+ progress: number; // 0 to 1
172
+ response: unknown; // what the uploader resolved with
173
+ error: unknown; // what it rejected with
174
+ }
175
+ ```
176
+
177
+ ### Methods
178
+
179
+ | Method | Description |
180
+ | ---------------- | ------------------------------------------------------------- |
181
+ | `openPicker()` | Opens the file dialog |
182
+ | `stage(files)` | Puts files on the court, ready to be shot (in with `instant`) |
183
+ | `dunk(files)` | Puts files straight into the basket |
184
+ | `shoot()` | Shoots the card on top of the court, with a perfect shot |
185
+ | `retryItem(id)` | Uploads a failed file again |
186
+ | `removeItem(id)` | Takes a file out of the basket, aborting its upload |
187
+ | `clear()` | Empties the court and the basket, aborting uploads |
188
+
189
+ `shoot()`, `retryItem()` and `removeItem()` return whether they did anything: `false` when there is no card on the court (or another one is held or in the air), no failed item with that `id` (or no `uploader`), or no item with that `id`.
190
+
191
+ ### Events
192
+
193
+ Every event is a `CustomEvent` that bubbles, with its payload in `detail`.
194
+
195
+ | Event | `detail` | When |
196
+ | ----------------- | ----------------------------------------------- | ------------------------------------------------------- |
197
+ | `file-reject` | `{ file, reason: 'type' \| 'size' \| 'count' }` | A file did not pass `accept`, `max-size` or `max-files` |
198
+ | `shot` | `{ file, result: 'score' \| 'miss' }` | A card went in (or a dunk did), or a shot missed |
199
+ | `upload-start` | `{ item }` | An upload started |
200
+ | `upload-progress` | `{ item }` | The uploader reported progress |
201
+ | `upload-success` | `{ item }` | An upload resolved; `item.response` has its value |
202
+ | `upload-error` | `{ item }` | An upload rejected; `item.error` has the reason |
203
+ | `change` | `{ items }` | Files entered or left the basket, or changed status |
204
+
205
+ ```ts
206
+ hoop.addEventListener('upload-success', (event) => {
207
+ console.log(event.detail.item.file.name, event.detail.item.response);
208
+ });
209
+ ```
210
+
211
+ `BasketballUploadEventMap` types `addEventListener` for these events, on the element that `querySelector('basketball-upload')` and `createElement('basketball-upload')` return. Registered under another name, declare that name once in your project, and they return the element for it too:
212
+
213
+ ```ts
214
+ import type { BasketballUploadElement } from '@sehv-oss/basketball-upload';
215
+
216
+ declare global {
217
+ interface HTMLElementTagNameMap {
218
+ 'my-upload': BasketballUploadElement;
219
+ }
220
+ }
221
+ ```
222
+
223
+ ### Messages
224
+
225
+ The default copy is the one of the design. Override any part of it, in any language:
226
+
227
+ ```ts
228
+ hoop.messages = {
229
+ title: 'Custom title',
230
+ description: 'Custom description',
231
+ prompt: 'Custom prompt',
232
+ hint: 'Custom hint',
233
+ counter: 'Custom counter',
234
+ uploading: 'Custom uploading',
235
+ uploaded: 'Custom uploaded',
236
+ shoot: (name) => `Custom shoot ${name}`,
237
+ };
238
+ ```
239
+
240
+ Keys left out, or set to `undefined`, keep their default. See `Messages` and `defaultMessages` for every key: accessible names, statuses, the validation message, and the announcements made to screen readers (`scored`, `missed`, `rejected`, `complete`, `error`).
241
+
242
+ ### Slots
243
+
244
+ `title`, `description`, `prompt` and `hint` replace the corresponding copy with your own markup:
245
+
246
+ ```html
247
+ <basketball-upload>
248
+ <span slot="title">Attachments</span>
249
+ <span slot="hint">PDF or images, up to 20 MB</span>
250
+ </basketball-upload>
251
+ ```
252
+
253
+ ## File types
254
+
255
+ Each file is drawn as a card of its type: a badge with its label and color, and a body (artwork).
256
+
257
+ | Kind | Matches | Color | Artwork |
258
+ | --------- | ---------------------------------------- | --------- | --------- |
259
+ | `pdf` | `.pdf`, `application/pdf` | `#d8374e` | lines |
260
+ | `image` | `image/*` | `#2f7cf6` | thumbnail |
261
+ | `video` | `video/*` | `#8b5cf6` | lines |
262
+ | `audio` | `audio/*` | `#e39a0b` | lines |
263
+ | `sheet` | `.csv`, `.tsv`, `.xls`, `.xlsx`, `.ods`… | `#1f9d55` | lines |
264
+ | `doc` | `.doc`, `.docx`, `.odt`, `.md`, `.txt`… | `#2563eb` | lines |
265
+ | `slides` | `.ppt`, `.pptx`, `.key`, `.odp` | `#ea580c` | lines |
266
+ | `archive` | `.zip`, `.rar`, `.7z`, `.tar`, `.gz`… | `#6b7280` | lines |
267
+ | `code` | `.json`, `.js`, `.ts`, `.html`, `.css`… | `#475569` | lines |
268
+ | `file` | anything else | neutral | lines |
269
+
270
+ The label defaults to the extension, in capitals (at most 4 characters). Image thumbnails use an object URL, revoked as soon as the image has loaded (or failed to), or when the card goes away before that.
271
+
272
+ Add your own types, for every element on the page:
273
+
274
+ ```ts
275
+ import { registerFileType } from '@sehv-oss/basketball-upload';
276
+
277
+ const unregister = registerFileType({
278
+ kind: 'figma', // lowercase letters, digits, dashes
279
+ match: '.fig', // accept syntax, or (file) => boolean
280
+ label: 'FIG', // or (file) => string
281
+ color: '#a259ff', // any CSS color, light-dark() included
282
+ artwork: (file) => figmaLogo(), // 'lines' | 'thumbnail' | (file) => Node
283
+ });
284
+ ```
285
+
286
+ or for one element, with `fileTypes`. Types are matched in order: the element's, then the registered ones (most recent first), then the built-in ones.
287
+
288
+ ```ts
289
+ hoop.fileTypes = [
290
+ { kind: 'contract', match: (file) => file.name.startsWith('contract-') },
291
+ ];
292
+ ```
293
+
294
+ From CSS, every kind has a color token and parts:
295
+
296
+ ```css
297
+ basketball-upload {
298
+ --basketball-upload-file-pdf: #e11d48;
299
+ }
300
+
301
+ basketball-upload::part(file-image) {
302
+ filter: drop-shadow(0 0.5rem 1rem rgb(47 124 246 / 0.35));
303
+ }
304
+ ```
305
+
306
+ Labels are set as text, never as HTML. A custom artwork is your own node, placed in the `file-artwork` part.
307
+
308
+ ## Theming
309
+
310
+ ### Themes
311
+
312
+ ```html
313
+ <basketball-upload theme="light"></basketball-upload>
314
+ <basketball-upload theme="dark"></basketball-upload>
315
+ <basketball-upload theme="system"></basketball-upload>
316
+ <!-- no attribute: same as system -->
317
+ ```
318
+
319
+ The attribute sets `color-scheme` on the element. Every default color is a `light-dark()` pair, so the dark theme needs no extra stylesheet. Files stay paper-white in both.
320
+
321
+ ### CSS custom properties
322
+
323
+ Set them on the element or on any ancestor (`:root` included) — they are read once, inside, with their default as fallback. Use `light-dark()` to keep an override theme-aware.
324
+
325
+ ```css
326
+ basketball-upload {
327
+ --basketball-upload-accent: light-dark(#0891b2, #22d3ee);
328
+ --basketball-upload-background: transparent;
329
+ }
330
+ ```
331
+
332
+ | Token | Default (light / dark) |
333
+ | ---------------------------------------- | ------------------------------------------------------- |
334
+ | `--basketball-upload-background` | `#eff1f5` / `#0f1115` |
335
+ | `--basketball-upload-foreground` | `#0f1115` / `#f4f5f7` |
336
+ | `--basketball-upload-muted-foreground` | `#6b6f78` / `#a3a8b2` |
337
+ | `--basketball-upload-subtle-foreground` | `#989a9f` / `#7c818b` |
338
+ | `--basketball-upload-surface` | `#ffffff` / `#181b21` (dropzone, list rows) |
339
+ | `--basketball-upload-border` | `#cbccd1` / `#3a3f48` (dashed outline) |
340
+ | `--basketball-upload-radius` | `1.125rem` |
341
+ | `--basketball-upload-accent` | `#f0612e` / `#f26a39` (square, rim, "+1", progress) |
342
+ | `--basketball-upload-accent-strong` | `#bd4d19` / `#c4521f` (bracket, back of the rim) |
343
+ | `--basketball-upload-accent-surface` | `#fff4ef` / accent over surface (dropzone while active) |
344
+ | `--basketball-upload-trajectory` | accent at 60% |
345
+ | `--basketball-upload-net` | `#a9abb3` / `#6b717c` |
346
+ | `--basketball-upload-counter-background` | `#e3e7ed` / `#232730` |
347
+ | `--basketball-upload-track` | `#e6e8ec` / `#2a2e36` (progress track) |
348
+ | `--basketball-upload-success` | `#2fb06d` / `#34c07a` |
349
+ | `--basketball-upload-danger` | `#d8374e` / `#f0606f` |
350
+ | `--basketball-upload-file-surface` | `#ffffff` |
351
+ | `--basketball-upload-file-line` | `#e4e5e9` |
352
+ | `--basketball-upload-file-fold` | `#e9eaee` |
353
+ | `--basketball-upload-file-badge` | `#6b7280` (types without a color) |
354
+ | `--basketball-upload-file-<kind>` | the color of each file type |
355
+ | `--basketball-upload-shadow` | soft shadow of the list rows |
356
+ | `--basketball-upload-focus-ring` | `2px solid` accent |
357
+ | `--basketball-upload-font-family` | `Inter, ui-sans-serif, system-ui, sans-serif` |
358
+ | `--basketball-upload-gutter` | `clamp(1.25rem, 8cqi, 3.75rem)` |
359
+ | `--basketball-upload-hoop-size` | `min(25rem, 100cqi - 2 * gutter)` (dropzone width) |
360
+
361
+ No stylesheet needs to be imported. The font is not bundled: load Inter yourself to match the design, or set your own.
362
+
363
+ ### Parts
364
+
365
+ For anything a token does not cover:
366
+
367
+ ```css
368
+ basketball-upload::part(dropzone) {
369
+ box-shadow: 0 1px 2px rgb(0 0 0 / 0.06);
370
+ }
371
+ ```
372
+
373
+ | Part | Element |
374
+ | --------------------------------------------- | ----------------------------------- |
375
+ | `frame` | Everything inside the element |
376
+ | `header`, `title`, `description` | The heading |
377
+ | `counter`, `counter-label`, `counter-value` | The "Uploaded N" pill |
378
+ | `hoop` | The backboard, rim and net |
379
+ | `dropzone`, `dropzone-icon`, `prompt`, `hint` | The dropzone button and its content |
380
+ | `backboard-square` | The orange target square |
381
+ | `rim` | Both halves of the rim |
382
+ | `net` | The net |
383
+ | `score-pop` | The "+1" |
384
+ | `trajectory` | The aiming dots |
385
+ | `file`, `file-<kind>` | A file card |
386
+ | `file-artwork`, `file-badge` | Its body and its badge |
387
+ | `list`, `item` | The upload list and its rows |
388
+ | `item-icon`, `item-icon-<kind>` | The small card of a row |
389
+ | `item-name`, `item-size`, `item-status` | Its texts |
390
+ | `progress`, `retry` | Its progress bar and retry button |
391
+
392
+ ### States
393
+
394
+ The element exposes what it is doing as custom states, for `:state()`:
395
+
396
+ | State | While |
397
+ | ------------- | ----------------------------------------------------------- |
398
+ | `dragging` | Files are dragged over the element |
399
+ | `drop-target` | Files are over the dropzone, or a card flies over it/scores |
400
+ | `aiming` | A card is pulled back, or held with the keyboard |
401
+ | `flying` | A card is in the air |
402
+ | `scoring` | A card goes through the hoop |
403
+ | `rejected` | Just after a file was rejected |
404
+ | `disabled` | The element or its fieldset is disabled |
405
+
406
+ ```css
407
+ basketball-upload:state(drop-target)::part(backboard-square) {
408
+ border-style: dashed;
409
+ }
410
+ ```
411
+
412
+ Parts, tokens and states are public API: removing or renaming one is a breaking change. Inside, the styles live in cascade layers (`tokens`, `reset`, `layout`, `components`, `states`, `motion`), which outside styles always override.
413
+
414
+ ## Accessibility
415
+
416
+ - The dropzone is a real button; cards on the court are buttons ("Shoot final.pdf") that aim and shoot with the keyboard.
417
+ - Scores, misses, rejections and finished uploads are announced in a polite live region.
418
+ - Progress bars are `progressbar`s with their value; retry buttons are labelled.
419
+ - With `prefers-reduced-motion: reduce`, nothing flies or swings.
420
+
421
+ ## Server-side rendering
422
+
423
+ The module can be imported where there is no DOM: the element class extends a stand-in, `registerBasketballUpload` does nothing, and the stylesheet is created on first use. The React component renders the element with its attributes on the server.
424
+
425
+ ## Browser support
426
+
427
+ Browsers released since mid 2024: Chrome and Edge 125, Firefox 129, Safari 17.5. The element relies on custom elements, Shadow DOM, adopted stylesheets, `ElementInternals` with custom states, cascade layers, CSS nesting, container queries and `light-dark()`.
428
+
429
+ ## Other exports
430
+
431
+ `defaultMessages`, `defaultFileTypes`, `resolveFileType(file, types?)`, `matchesAccept(file, accept)` and `formatBytes(bytes, locale?)` are exported for building around the element; `DEFAULT_TAG_NAME` is `'basketball-upload'`.
432
+
433
+ ## License
434
+
435
+ [ISC](LICENSE). Design credit in [NOTICE.md](NOTICE.md).