@fishies/ui 0.8.3
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 +26 -0
- package/README.md +60 -0
- package/dist/FishiesImg.d.ts +9 -0
- package/dist/FishiesImg.js +64 -0
- package/dist/Select.d.ts +22 -0
- package/dist/Select.js +95 -0
- package/dist/fx-image.d.ts +3 -0
- package/dist/fx-image.js +25 -0
- package/dist/icons.d.ts +28 -0
- package/dist/icons.generated.d.ts +5 -0
- package/dist/icons.generated.js +658 -0
- package/dist/icons.js +53 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/kit.css +267 -0
- package/dist/kit.d.ts +159 -0
- package/dist/kit.js +104 -0
- package/dist/loader.css +48 -0
- package/dist/loader.d.ts +17 -0
- package/dist/loader.js +27 -0
- package/dist/select-state.d.ts +34 -0
- package/dist/select-state.js +55 -0
- package/dist/select.css +87 -0
- package/dist/tokens.d.ts +17 -0
- package/dist/tokens.generated.css +97 -0
- package/dist/tokens.js +175 -0
- package/handbook/README.md +236 -0
- package/icons/add.png +0 -0
- package/icons/announce.png +0 -0
- package/icons/arrange.png +0 -0
- package/icons/bell.png +0 -0
- package/icons/block.png +0 -0
- package/icons/book.png +0 -0
- package/icons/bug.png +0 -0
- package/icons/camera.png +0 -0
- package/icons/cart-add.png +0 -0
- package/icons/cart.png +0 -0
- package/icons/chat.png +0 -0
- package/icons/check.png +0 -0
- package/icons/clean.png +0 -0
- package/icons/clock.png +0 -0
- package/icons/coin.png +0 -0
- package/icons/coral.png +0 -0
- package/icons/decorate.png +0 -0
- package/icons/download.png +0 -0
- package/icons/drop.png +0 -0
- package/icons/edit.png +0 -0
- package/icons/egg.png +0 -0
- package/icons/emote-food.png +0 -0
- package/icons/emote-heart.png +0 -0
- package/icons/emote-hello.png +0 -0
- package/icons/emote-music.png +0 -0
- package/icons/emote-question.png +0 -0
- package/icons/emote-sleep.png +0 -0
- package/icons/emote-sparkle.png +0 -0
- package/icons/emote-surprise.png +0 -0
- package/icons/exit.png +0 -0
- package/icons/explore.png +0 -0
- package/icons/external.png +0 -0
- package/icons/feed.png +0 -0
- package/icons/fish.png +0 -0
- package/icons/follow.png +0 -0
- package/icons/fullscreen.png +0 -0
- package/icons/gear.png +0 -0
- package/icons/gem.png +0 -0
- package/icons/gift.png +0 -0
- package/icons/globe.png +0 -0
- package/icons/growth.png +0 -0
- package/icons/heart.png +0 -0
- package/icons/help.png +0 -0
- package/icons/home.png +0 -0
- package/icons/idea.png +0 -0
- package/icons/inspect.png +0 -0
- package/icons/inventory.png +0 -0
- package/icons/keeper.png +0 -0
- package/icons/kelp.png +0 -0
- package/icons/keyboard.png +0 -0
- package/icons/level.png +0 -0
- package/icons/link.png +0 -0
- package/icons/lock.png +0 -0
- package/icons/mail.png +0 -0
- package/icons/map.png +0 -0
- package/icons/menu.png +0 -0
- package/icons/music.png +0 -0
- package/icons/mute.png +0 -0
- package/icons/net.png +0 -0
- package/icons/people.png +0 -0
- package/icons/pin.png +0 -0
- package/icons/quest.png +0 -0
- package/icons/quote.png +0 -0
- package/icons/remedy.png +0 -0
- package/icons/reply.png +0 -0
- package/icons/report.png +0 -0
- package/icons/search.png +0 -0
- package/icons/sell.png +0 -0
- package/icons/send.png +0 -0
- package/icons/share.png +0 -0
- package/icons/shell.png +0 -0
- package/icons/shop.png +0 -0
- package/icons/showcase.png +0 -0
- package/icons/smile.png +0 -0
- package/icons/solved.png +0 -0
- package/icons/sound.png +0 -0
- package/icons/staff.png +0 -0
- package/icons/star.png +0 -0
- package/icons/tank.png +0 -0
- package/icons/thermometer.png +0 -0
- package/icons/trash.png +0 -0
- package/icons/trophy.png +0 -0
- package/icons/updates.png +0 -0
- package/icons/upvote.png +0 -0
- package/icons/views.png +0 -0
- package/icons/warning.png +0 -0
- package/icons/wave.png +0 -0
- package/icons/whisper.png +0 -0
- package/package.json +66 -0
- package/src/FishiesImg.tsx +58 -0
- package/src/Select.tsx +119 -0
- package/src/css.d.ts +2 -0
- package/src/fx-image.ts +17 -0
- package/src/icons.generated.ts +658 -0
- package/src/icons.ts +74 -0
- package/src/index.ts +8 -0
- package/src/kit.css +267 -0
- package/src/kit.tsx +190 -0
- package/src/loader.css +48 -0
- package/src/loader.tsx +35 -0
- package/src/select-state.ts +59 -0
- package/src/select.css +87 -0
- package/src/tokens.generated.css +97 -0
- package/src/tokens.ts +196 -0
package/src/icons.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export type IconGroup = 'care' | 'places' | 'system' | 'things' | 'expressions'
|
|
2
|
+
|
|
3
|
+
export const ICON_GROUPS: readonly { readonly id: IconGroup; readonly title: string; readonly use: string }[] = [
|
|
4
|
+
{ id: 'care', title: 'Care tools', use: 'The verbs of keeping fish: the dock’s tools and the cursors they become.' },
|
|
5
|
+
{ id: 'places', title: 'Places and lists', use: 'Where you can go and what you can open: rooms, shop, stash, map, people.' },
|
|
6
|
+
{ id: 'system', title: 'System', use: 'Settings, sound, notices and the frame: the same in the game, the OS and the player.' },
|
|
7
|
+
{ id: 'things', title: 'Things', use: 'Nouns of the reef: coins, fish, eggs, plants, rewards.' },
|
|
8
|
+
{ id: 'expressions', title: 'Expressions', use: 'The emotes keepers send in Open Water and the chat.' },
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
export type SystemIcon = {
|
|
12
|
+
readonly name: string
|
|
13
|
+
readonly file: string
|
|
14
|
+
readonly label: string
|
|
15
|
+
|
|
16
|
+
readonly meaning: string
|
|
17
|
+
readonly group: IconGroup
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const ui = (name: string, label: string, group: IconGroup, meaning: string): SystemIcon => ({ name, file: `/game/ui-icons/${name}.png`, label, group, meaning })
|
|
21
|
+
const emote = (name: string, label: string, meaning: string): SystemIcon => ({ name: `emote-${name}`, file: `/game/emotes/${name}.png`, label, group: 'expressions', meaning })
|
|
22
|
+
|
|
23
|
+
export const SYSTEM_ICONS = [
|
|
24
|
+
ui('feed', 'Feed', 'care', 'give fish food'), ui('net', 'Net', 'care', 'catch a fish to sell or move'), ui('clean', 'Clean', 'care', 'wipe the glass'),
|
|
25
|
+
ui('decorate', 'Decorate', 'care', 'place a new decoration'), ui('arrange', 'Arrange', 'care', 'move what is already placed'),
|
|
26
|
+
ui('remedy', 'Remedy', 'care', 'heal a sick fish'), ui('inspect', 'Inspect', 'care', 'look closer at a fish'),
|
|
27
|
+
ui('home', 'Home', 'places', 'your own aquarium'), ui('explore', 'Explore', 'places', 'find other keepers'), ui('map', 'Map', 'places', 'the ocean map and its places'),
|
|
28
|
+
ui('shop', 'Shop', 'places', 'buy fish, decor and care'), ui('cart', 'Cart', 'places', 'open the basket'), ui('cart-add', 'Add to cart', 'places', 'put something in the basket'),
|
|
29
|
+
ui('inventory', 'Stash', 'places', 'what you own but have not placed'), ui('book', 'Book', 'places', 'the Reef Journal and guides'),
|
|
30
|
+
ui('people', 'People', 'places', 'keepers, friends, followers'), ui('share', 'Share', 'places', 'send a link elsewhere'), ui('pin', 'Pin', 'places', 'keep it on top'),
|
|
31
|
+
ui('menu', 'Menu', 'system', 'more choices'), ui('bell', 'Notices', 'system', 'what happened while you were away'), ui('gear', 'Settings', 'system', 'change how it works'),
|
|
32
|
+
ui('help', 'Help', 'system', 'ask or learn how'), ui('sound', 'Sound', 'system', 'sound is on'), ui('mute', 'Muted', 'system', 'sound is off'),
|
|
33
|
+
ui('music', 'Music', 'system', 'the soundtrack'), ui('fullscreen', 'Full screen', 'system', 'fill the screen'), ui('camera', 'Camera', 'system', 'take a picture or a video'),
|
|
34
|
+
ui('coin', 'Coin', 'things', 'money in the game'), ui('fish', 'Fish', 'things', 'a fish, the game itself'), ui('egg', 'Egg', 'things', 'an egg, something hatching'),
|
|
35
|
+
ui('growth', 'Growth', 'things', 'growing up, rising numbers'), ui('heart', 'Heart', 'things', 'a like, kindness'), ui('star', 'Star', 'things', 'level, favourite, reward'),
|
|
36
|
+
ui('shell', 'Shell', 'things', 'a shell, reef chat'), ui('kelp', 'Kelp', 'things', 'plants'), ui('coral', 'Coral', 'things', 'coral and ornaments'),
|
|
37
|
+
ui('lock', 'Locked', 'system', 'closed, cannot be changed or answered'), ui('solved', 'Solved', 'things', 'answered, done, approved'),
|
|
38
|
+
ui('staff', 'Staff', 'things', 'someone from the Fishies team'), ui('edit', 'Edit', 'system', 'change words, or was changed'),
|
|
39
|
+
ui('upvote', 'Upvote', 'things', 'this is useful, raise it'), ui('report', 'Report', 'system', 'tell the team something is wrong'),
|
|
40
|
+
ui('follow', 'Follow', 'system', 'get notices about it'), ui('views', 'Views', 'things', 'how many looked, who can see it'),
|
|
41
|
+
ui('chat', 'Chat', 'places', 'a conversation, replies, general talk'),
|
|
42
|
+
ui('announce', 'Announcements', 'places', 'news from the team'), ui('updates', 'Updates', 'places', 'patch notes and what changed'),
|
|
43
|
+
ui('bug', 'Bugs', 'places', 'something broken'), ui('idea', 'Ideas', 'places', 'a suggestion'),
|
|
44
|
+
|
|
45
|
+
ui('reply', 'Reply', 'system', 'answer this'), ui('quote', 'Quote', 'system', "repeat someone's words in yours"),
|
|
46
|
+
ui('clock', 'Time', 'system', 'when, how long, last activity, away'), ui('showcase', 'Showcase', 'places', 'show what you made'),
|
|
47
|
+
ui('tank', 'Aquarium', 'things', 'an aquarium you own or buy'), ui('block', 'Block', 'system', 'block a keeper, ban'),
|
|
48
|
+
ui('trash', 'Remove', 'system', 'remove, delete'), ui('link', 'Link', 'system', 'copy or open a link'),
|
|
49
|
+
ui('smile', 'Emoji', 'system', 'pick an emoji'), ui('send', 'Send', 'system', 'send a message or post'),
|
|
50
|
+
ui('whisper', 'Whisper', 'places', 'a private message'), ui('wave', 'Open water', 'places', 'open water, the reef lane'),
|
|
51
|
+
ui('thermometer', 'Temperature', 'care', 'water temperature, the heater'), ui('drop', 'Water', 'care', 'water quality'),
|
|
52
|
+
ui('warning', 'Warning', 'system', 'warning, danger'), ui('gift', 'Gift', 'things', 'gifts, something given'),
|
|
53
|
+
ui('gem', 'Grade', 'things', 'quality tier, grade'), ui('trophy', 'Trophy', 'things', 'achievements, badges'),
|
|
54
|
+
ui('level', 'Level', 'things', 'level, XP, level-up'), ui('quest', 'Quest', 'things', 'a quest, a goal'),
|
|
55
|
+
ui('check', 'Done', 'system', 'done, selected, met'), ui('keeper', 'Keeper', 'places', 'an account, a person'),
|
|
56
|
+
ui('exit', 'Leave', 'system', 'sign out, quit, leave'), ui('globe', 'Language', 'system', 'language, all languages'),
|
|
57
|
+
ui('search', 'Search', 'system', 'search'), ui('sell', 'Sell', 'care', 'sell, a price'),
|
|
58
|
+
ui('keyboard', 'Keys', 'system', 'shortcuts and keys'), ui('external', 'Opens elsewhere', 'system', 'opens in a new tab'),
|
|
59
|
+
ui('add', 'New', 'system', 'new, create'), ui('download', 'Download', 'system', 'download'), ui('mail', 'Mail', 'system', 'email, the newsletter'),
|
|
60
|
+
emote('hello', 'Hello', 'wave hello'), emote('heart', 'Love', 'send love'), emote('question', 'Question', 'wonder aloud'), emote('surprise', 'Surprise', 'be surprised'),
|
|
61
|
+
emote('food', 'Food', 'ask for food'), emote('sleep', 'Sleepy', 'feel sleepy'), emote('music', 'Music', 'enjoy the music'), emote('sparkle', 'Sparkle', 'something wonderful'),
|
|
62
|
+
] as const satisfies readonly SystemIcon[]
|
|
63
|
+
|
|
64
|
+
export type SystemIconName = (typeof SYSTEM_ICONS)[number]['name']
|
|
65
|
+
|
|
66
|
+
export const ICON_SIZES = { xs: 16, sm: 20, md: 28, lg: 46, xl: 84 } as const
|
|
67
|
+
export type IconSize = keyof typeof ICON_SIZES
|
|
68
|
+
|
|
69
|
+
export const iconByName = (name: string): SystemIcon | undefined => SYSTEM_ICONS.find(icon => icon.name === name)
|
|
70
|
+
|
|
71
|
+
let resolveUrl = (icon: SystemIcon): string => icon.file
|
|
72
|
+
|
|
73
|
+
export function configureIcons(options: { url: (icon: SystemIcon) => string }): void { resolveUrl = options.url }
|
|
74
|
+
export const iconUrl = (icon: SystemIcon): string => resolveUrl(icon)
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
export * from './kit'
|
|
3
|
+
export { TOKEN_GROUPS, TOKENS, fx, tokensCss, type Token, type TokenGroup } from './tokens'
|
|
4
|
+
export { SYSTEM_ICONS, ICON_GROUPS, ICON_SIZES, iconByName, iconUrl, configureIcons, type SystemIcon as SystemIconEntry, type SystemIconName, type IconGroup, type IconSize } from './icons'
|
|
5
|
+
export { ICON_FACTS } from './icons.generated'
|
|
6
|
+
export { SPINNER_SVG, svgDataUri } from './loader'
|
|
7
|
+
export { FX_IMAGE_SCRIPT, reveal } from './fx-image'
|
|
8
|
+
export type { SelectOption } from './Select'
|
package/src/kit.css
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
@layer base, skin, system, responsive, kit;
|
|
2
|
+
|
|
3
|
+
@layer kit {
|
|
4
|
+
|
|
5
|
+
.fx-icon { display: inline-grid; place-items: center; flex: none; line-height: 0; }
|
|
6
|
+
.fx-icon img { display: block; width: 100%; height: 100%; object-fit: contain; pointer-events: none; user-select: none; -webkit-user-drag: none; }
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
.fx-button {
|
|
10
|
+
position: relative; isolation: isolate; display: inline-flex; align-items: center; justify-content: center; gap: var(--fx-s-2);
|
|
11
|
+
min-height: 46px; padding: 0 var(--fx-s-5); border: 1px solid var(--fx-rim); border-radius: var(--fx-r-full);
|
|
12
|
+
font: 800 var(--fx-t-md)/1 var(--fx-font-display); letter-spacing: var(--fx-track-tight); white-space: nowrap; text-decoration: none;
|
|
13
|
+
cursor: pointer; -webkit-tap-highlight-color: transparent;
|
|
14
|
+
transition: transform var(--m-quick) var(--m-ease-spring), box-shadow var(--m-quick) var(--m-ease-out), filter var(--m-quick) var(--m-ease-out);
|
|
15
|
+
}
|
|
16
|
+
.fx-button.is-sm { min-height: 36px; padding: 0 var(--fx-s-4); font-size: var(--fx-t-sm); gap: var(--fx-s-1); }
|
|
17
|
+
.fx-button.is-lg { min-height: 56px; padding: 0 var(--fx-s-6); font-size: var(--fx-t-lg); }
|
|
18
|
+
.fx-button:focus-visible { outline: 3px solid var(--fx-gold); outline-offset: 3px; }
|
|
19
|
+
.fx-button:disabled, .fx-button[aria-disabled='true'] { cursor: not-allowed; filter: saturate(.35) brightness(.85); transform: none; }
|
|
20
|
+
.fx-button:hover:not(:disabled) { transform: translateY(-2px); }
|
|
21
|
+
.fx-button:active:not(:disabled) { transform: translateY(2px) scale(.98); }
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
.fx-button, .fx-button.is-glass {
|
|
25
|
+
background: var(--fx-glass); color: var(--fx-ink);
|
|
26
|
+
box-shadow: var(--fx-rim-light), var(--fx-foot);
|
|
27
|
+
}
|
|
28
|
+
.fx-button.is-glass:hover:not(:disabled), .fx-button:not([class*='is-']):hover:not(:disabled) { border-color: var(--fx-rim-bright); }
|
|
29
|
+
.fx-button:active:not(:disabled) { box-shadow: var(--fx-rim-light), 0 1px 0 rgb(4 18 34 / 55%); }
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
:is(.fx-button.is-gold, .gold-button) {
|
|
33
|
+
border-color: var(--fx-pearl);
|
|
34
|
+
background: radial-gradient(60% 46% at 30% 10%, rgb(255 255 255 / 55%), transparent 70%), linear-gradient(180deg, var(--fx-gold-face), var(--fx-gold));
|
|
35
|
+
box-shadow: inset 0 1px rgb(255 255 255 / 55%), inset 0 -3px 6px rgb(200 138 61 / 30%), 0 3px 0 var(--fx-gold-foot), var(--fx-lift-1);
|
|
36
|
+
color: var(--fx-ink-on-gold); text-shadow: none; -webkit-text-stroke: 0;
|
|
37
|
+
}
|
|
38
|
+
:is(.fx-button.is-gold, .gold-button):hover:not(:disabled) { filter: brightness(1.05) saturate(1.05); }
|
|
39
|
+
:is(.fx-button.is-gold, .gold-button):active:not(:disabled) { box-shadow: inset 0 1px rgb(255 255 255 / 55%), inset 0 -2px 4px rgb(200 138 61 / 30%), 0 1px 0 var(--fx-gold-foot); }
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
.fx-button.is-ghost { background: transparent; border-color: transparent; box-shadow: none; color: var(--fx-ink-2); }
|
|
43
|
+
.fx-button.is-ghost:hover:not(:disabled) { background: rgb(255 255 255 / 8%); color: var(--fx-ink); transform: none; }
|
|
44
|
+
.fx-button.is-ghost:active:not(:disabled) { box-shadow: none; }
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
.fx-button.is-danger {
|
|
48
|
+
border-color: color-mix(in oklab, var(--fx-tone-coral), white 30%);
|
|
49
|
+
background: radial-gradient(60% 46% at 30% 10%, rgb(255 255 255 / 34%), transparent 70%), linear-gradient(180deg, var(--fx-tone-coral), color-mix(in oklab, var(--fx-alarm), black 28%));
|
|
50
|
+
box-shadow: inset 0 1px rgb(255 255 255 / 40%), 0 3px 0 color-mix(in oklab, var(--fx-alarm), black 55%), var(--fx-lift-1);
|
|
51
|
+
color: white;
|
|
52
|
+
}
|
|
53
|
+
.fx-button > .fx-icon { margin-left: calc(var(--fx-s-2) * -1); }
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
.fx-round {
|
|
57
|
+
display: inline-grid; place-items: center; flex: none; width: 38px; height: 38px; padding: 0;
|
|
58
|
+
border: 1px solid var(--fx-rim); border-radius: 50%; color: var(--fx-ink); cursor: pointer;
|
|
59
|
+
background: radial-gradient(circle at 35% 28%, rgb(255 255 255 / 26%), transparent 52%), var(--fx-navy);
|
|
60
|
+
box-shadow: inset 0 -2px 4px rgb(0 0 0 / 25%), 0 2px 0 rgb(2 10 24 / 50%);
|
|
61
|
+
transition: transform var(--m-quick) var(--m-ease-spring), filter var(--m-quick) var(--m-ease-out);
|
|
62
|
+
}
|
|
63
|
+
.fx-round.is-sm { width: 30px; height: 30px; }
|
|
64
|
+
.fx-round.is-lg { width: 48px; height: 48px; }
|
|
65
|
+
.fx-round svg { width: 55%; height: 55%; }
|
|
66
|
+
.fx-round .fx-icon { width: 70%; height: 70%; }
|
|
67
|
+
.fx-round:hover:not(:disabled) { transform: translateY(-1px) scale(1.06); filter: brightness(1.1); }
|
|
68
|
+
.fx-round:active:not(:disabled) { transform: translateY(1px) scale(.94); }
|
|
69
|
+
.fx-round:focus-visible { outline: 3px solid var(--fx-gold); outline-offset: 2px; }
|
|
70
|
+
.fx-round.is-coral { border-color: color-mix(in oklab, var(--fx-tone-coral), white 30%); background: radial-gradient(circle at 35% 28%, rgb(255 255 255 / 30%), transparent 52%), color-mix(in oklab, var(--fx-alarm), black 25%); }
|
|
71
|
+
.fx-round.is-gold { border-color: var(--fx-pearl); color: var(--fx-ink-on-gold); background: radial-gradient(circle at 35% 28%, rgb(255 255 255 / 60%), transparent 52%), linear-gradient(180deg, var(--fx-gold-face), var(--fx-gold)); box-shadow: inset 0 -2px 4px rgb(200 138 61 / 35%), 0 2px 0 var(--fx-gold-foot); }
|
|
72
|
+
.fx-round[aria-pressed='true'] { border-color: var(--fx-gold-rim); box-shadow: inset 0 0 0 2px rgb(255 196 107 / 40%), 0 2px 0 rgb(2 10 24 / 50%); }
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
.fx-chip {
|
|
76
|
+
display: inline-flex; align-items: center; gap: 6px; min-height: 32px; padding: 0 var(--fx-s-3);
|
|
77
|
+
border: 1px solid var(--fx-rim); border-radius: var(--fx-r-full); background: rgb(255 255 255 / 6%); color: var(--fx-ink);
|
|
78
|
+
font: 800 var(--fx-t-sm)/1 var(--fx-font-display); font-variant-numeric: tabular-nums; white-space: nowrap;
|
|
79
|
+
transition: background var(--m-quick) var(--m-ease-out), transform var(--m-quick) var(--m-ease-spring);
|
|
80
|
+
}
|
|
81
|
+
button.fx-chip { cursor: pointer; }
|
|
82
|
+
button.fx-chip:hover { background: rgb(255 255 255 / 13%); }
|
|
83
|
+
button.fx-chip:active { transform: scale(.95); }
|
|
84
|
+
.fx-chip:focus-visible { outline: 3px solid var(--fx-gold); outline-offset: 2px; }
|
|
85
|
+
.fx-chip > i { width: 8px; height: 8px; border-radius: 50%; background: var(--tone, var(--fx-tone-mute)); box-shadow: 0 0 0 3px color-mix(in oklab, var(--tone, var(--fx-tone-mute)), transparent 75%); }
|
|
86
|
+
.fx-chip .fx-icon { width: 18px; height: 18px; margin-left: -4px; }
|
|
87
|
+
.fx-chip[aria-pressed='true'], .fx-chip[aria-checked='true'], .fx-chip.is-on {
|
|
88
|
+
border-color: var(--fx-pearl); color: var(--fx-ink-on-gold);
|
|
89
|
+
background: linear-gradient(180deg, var(--fx-gold-face), var(--fx-gold)); box-shadow: inset 0 1px rgb(255 255 255 / 55%), 0 2px 0 var(--fx-gold-foot);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
.fx-badge {
|
|
94
|
+
display: inline-grid; place-items: center; min-width: 24px; height: 24px; padding: 0 7px; border: 2px solid var(--fx-pearl); border-radius: var(--fx-r-full);
|
|
95
|
+
background: linear-gradient(180deg, var(--fx-tone-coral), color-mix(in oklab, var(--fx-alarm), black 22%));
|
|
96
|
+
color: white; font: 900 var(--fx-t-xs)/1 var(--fx-font-ui); font-variant-numeric: tabular-nums; box-shadow: 0 3px 8px rgb(0 0 0 / 35%);
|
|
97
|
+
}
|
|
98
|
+
.fx-badge.is-dot { min-width: 14px; width: 14px; height: 14px; padding: 0; }
|
|
99
|
+
.fx-tag {
|
|
100
|
+
display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border: 1px solid var(--fx-gold-rim); border-radius: var(--fx-r-full);
|
|
101
|
+
color: var(--fx-gold); font: 900 var(--fx-t-xs)/1.2 var(--fx-font-ui); letter-spacing: var(--fx-track-label); text-transform: uppercase;
|
|
102
|
+
}
|
|
103
|
+
.fx-eyebrow { display: block; color: var(--fx-gold); font: 900 var(--fx-t-xs)/1 var(--fx-font-ui); letter-spacing: var(--fx-track-label); text-transform: uppercase; }
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
.fx-card, .fx-panel, .fx-window, .fx-dock, .fx-toast, .fx-tooltip, .fx-stat {
|
|
107
|
+
border: 1px solid var(--fx-rim); background: var(--fx-glass); color: var(--fx-ink);
|
|
108
|
+
-webkit-backdrop-filter: var(--fx-glass-blur); backdrop-filter: var(--fx-glass-blur);
|
|
109
|
+
}
|
|
110
|
+
.fx-card { display: grid; gap: var(--fx-s-2); padding: var(--fx-s-4); border-radius: var(--fx-r-md); box-shadow: var(--fx-rim-light), var(--fx-lift-2); }
|
|
111
|
+
.fx-card > h3 { margin: 0; font: 800 var(--fx-t-lg)/1.15 var(--fx-font-display); letter-spacing: var(--fx-track-tight); }
|
|
112
|
+
.fx-card > p { margin: 0; color: var(--fx-ink-2); font: 600 var(--fx-t-md)/1.5 var(--fx-font-ui); }
|
|
113
|
+
|
|
114
|
+
.fx-panel { display: flex; flex-direction: column; min-width: 0; border-color: var(--fx-gold-rim); border-radius: var(--fx-r-lg); box-shadow: var(--fx-rim-light), var(--fx-lift-2); overflow: hidden; }
|
|
115
|
+
.fx-panel-head { display: flex; align-items: center; gap: var(--fx-s-3); min-height: 56px; padding: var(--fx-s-2) var(--fx-s-3) var(--fx-s-2) var(--fx-s-4); border-bottom: 1px solid var(--fx-gold-rim-quiet); }
|
|
116
|
+
.fx-panel-head > .fx-icon { width: 30px; height: 30px; filter: drop-shadow(0 3px 3px rgb(0 0 0 / 35%)); }
|
|
117
|
+
.fx-panel-head h2 { flex: 1; min-width: 0; margin: 0; overflow: hidden; color: var(--fx-gold-light); font: 800 var(--fx-t-lg)/1.1 var(--fx-font-display); text-overflow: ellipsis; white-space: nowrap; }
|
|
118
|
+
.fx-panel-body { padding: var(--fx-s-4); }
|
|
119
|
+
|
|
120
|
+
.fx-window { display: flex; flex-direction: column; min-width: 0; border-color: var(--fx-gold-rim); border-radius: 22px; overflow: hidden; box-shadow: var(--fx-rim-light), 0 4px 0 rgb(4 18 34 / 50%), var(--fx-lift-3); }
|
|
121
|
+
.fx-window-bar { display: flex; align-items: center; gap: 10px; min-height: 50px; padding: 6px 8px 6px 14px; background: linear-gradient(180deg, rgb(255 255 255 / 12%), rgb(255 255 255 / 2%)); border-bottom: 1px solid var(--fx-gold-rim-quiet); }
|
|
122
|
+
.fx-window-bar > .fx-icon { width: 26px; height: 26px; }
|
|
123
|
+
.fx-window-bar h2 { flex: 1; min-width: 0; margin: 0; overflow: hidden; color: var(--fx-gold-light); font: 800 17px/1 var(--fx-font-display); text-overflow: ellipsis; white-space: nowrap; }
|
|
124
|
+
.fx-window-buttons { display: flex; gap: 6px; }
|
|
125
|
+
.fx-window-body { flex: 1; min-height: 0; overflow: auto; padding: var(--fx-s-4); background: var(--fx-glass-well); scrollbar-width: thin; }
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
.fx-tabs { display: inline-flex; gap: 4px; max-width: 100%; padding: 4px; overflow-x: auto; border: 1px solid var(--fx-rim); border-radius: var(--fx-r-full); background: var(--fx-deep); box-shadow: inset 0 2px 6px rgb(0 0 0 / 40%); scrollbar-width: none; }
|
|
129
|
+
.fx-tab {
|
|
130
|
+
display: inline-flex; align-items: center; gap: 6px; min-height: 38px; padding: 0 var(--fx-s-4); border: 1px solid transparent; border-radius: var(--fx-r-full);
|
|
131
|
+
background: none; color: var(--fx-ink-2); font: 800 var(--fx-t-sm)/1 var(--fx-font-display); white-space: nowrap; cursor: pointer;
|
|
132
|
+
transition: background var(--m-base) var(--m-ease-out), color var(--m-base) var(--m-ease-out), transform var(--m-quick) var(--m-ease-spring);
|
|
133
|
+
}
|
|
134
|
+
.fx-tab .fx-icon { width: 22px; height: 22px; }
|
|
135
|
+
.fx-tab:hover { color: var(--fx-ink); background: rgb(255 255 255 / 7%); }
|
|
136
|
+
.fx-tab:active { transform: scale(.96); }
|
|
137
|
+
.fx-tab:focus-visible { outline: 3px solid var(--fx-gold); outline-offset: 1px; }
|
|
138
|
+
.fx-tab[aria-selected='true'] {
|
|
139
|
+
border-color: var(--fx-pearl); color: var(--fx-ink-on-gold);
|
|
140
|
+
background: linear-gradient(180deg, var(--fx-gold-face), var(--fx-gold)); box-shadow: inset 0 1px rgb(255 255 255 / 55%), 0 2px 0 var(--fx-gold-foot);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
.fx-list { display: grid; gap: 2px; margin: 0; padding: 0; list-style: none; }
|
|
145
|
+
.fx-row {
|
|
146
|
+
display: flex; align-items: center; gap: var(--fx-s-3); width: 100%; min-height: 56px; padding: var(--fx-s-2) var(--fx-s-3);
|
|
147
|
+
border: 1px solid transparent; border-radius: var(--fx-r-md); background: none; color: var(--fx-ink); text-align: left; font: inherit;
|
|
148
|
+
transition: background var(--m-quick) var(--m-ease-out), border-color var(--m-quick) var(--m-ease-out);
|
|
149
|
+
}
|
|
150
|
+
button.fx-row, a.fx-row { cursor: pointer; text-decoration: none; }
|
|
151
|
+
:is(button, a).fx-row:hover, .fx-row.is-on { background: rgb(108 200 255 / 14%); border-color: var(--fx-gold-rim-quiet); }
|
|
152
|
+
.fx-row:focus-visible { outline: 3px solid var(--fx-gold); outline-offset: 1px; }
|
|
153
|
+
.fx-row > .fx-icon { width: 36px; height: 36px; filter: drop-shadow(0 3px 3px rgb(0 0 0 / 30%)); }
|
|
154
|
+
.fx-row-text { display: grid; flex: 1; gap: 3px; min-width: 0; }
|
|
155
|
+
.fx-row-text b { overflow: hidden; font: 800 var(--fx-t-md)/1.15 var(--fx-font-display); text-overflow: ellipsis; white-space: nowrap; }
|
|
156
|
+
.fx-row-text small { overflow: hidden; color: var(--fx-ink-3); font: 700 var(--fx-t-sm)/1.25 var(--fx-font-ui); text-overflow: ellipsis; white-space: nowrap; }
|
|
157
|
+
.fx-row-end { flex: none; color: var(--fx-gold-light); font: 800 var(--fx-t-md)/1 var(--fx-font-display); font-variant-numeric: tabular-nums; }
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
.fx-field { display: grid; gap: 6px; min-width: 0; }
|
|
161
|
+
.fx-field > span { color: var(--fx-ink-blue); font: 800 var(--fx-t-xs)/1 var(--fx-font-ui); letter-spacing: var(--fx-track-label); text-transform: uppercase; }
|
|
162
|
+
.fx-field > small { color: var(--fx-ink-3); font: 600 var(--fx-t-sm)/1.35 var(--fx-font-ui); }
|
|
163
|
+
.fx-input {
|
|
164
|
+
width: 100%; min-height: 46px; padding: 0 var(--fx-s-4); border: 1px solid var(--fx-rim); border-radius: var(--fx-r-full);
|
|
165
|
+
background: var(--fx-deep); color: var(--fx-ink); font: 700 var(--fx-t-md)/1.2 var(--fx-font-ui);
|
|
166
|
+
box-shadow: inset 0 2px 6px rgb(0 0 0 / 45%), 0 1px 0 rgb(255 255 255 / 7%); outline: none;
|
|
167
|
+
transition: border-color var(--m-quick) var(--m-ease-out), box-shadow var(--m-quick) var(--m-ease-out);
|
|
168
|
+
}
|
|
169
|
+
.fx-input::placeholder { color: var(--fx-ink-3); }
|
|
170
|
+
.fx-input:focus-visible { border-color: var(--fx-gold-rim); box-shadow: inset 0 2px 6px rgb(0 0 0 / 45%), 0 0 0 4px rgb(255 196 107 / 22%); }
|
|
171
|
+
.fx-input[aria-invalid='true'] { border-color: var(--fx-alarm); }
|
|
172
|
+
|
|
173
|
+
.fx-toggle { display: inline-flex; align-items: center; gap: var(--fx-s-3); color: var(--fx-ink); font: 800 var(--fx-t-md)/1.2 var(--fx-font-display); cursor: pointer; }
|
|
174
|
+
.fx-toggle > input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
|
175
|
+
.fx-toggle > i {
|
|
176
|
+
position: relative; flex: none; width: 52px; height: 30px; border: 1px solid var(--fx-rim); border-radius: var(--fx-r-full);
|
|
177
|
+
background: var(--fx-deep); box-shadow: inset 0 2px 6px rgb(0 0 0 / 50%);
|
|
178
|
+
transition: background var(--m-base) var(--m-ease-out), border-color var(--m-base) var(--m-ease-out);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.fx-toggle > i::after {
|
|
182
|
+
content: ''; position: absolute; top: 3px; left: 3px; width: 22px; height: 22px; border-radius: 50%;
|
|
183
|
+
background: radial-gradient(circle at 34% 28%, white, var(--fx-pearl) 55%, var(--fx-cream));
|
|
184
|
+
box-shadow: 0 2px 0 rgb(4 18 34 / 45%), 0 3px 8px rgb(0 0 0 / 35%);
|
|
185
|
+
transition: transform var(--m-move) var(--m-ease-spring);
|
|
186
|
+
}
|
|
187
|
+
.fx-toggle > input:checked + i { border-color: var(--fx-pearl); background: linear-gradient(180deg, var(--fx-gold-face), var(--fx-gold)); box-shadow: inset 0 2px 4px rgb(200 138 61 / 45%); }
|
|
188
|
+
.fx-toggle > input:checked + i::after { transform: translateX(22px); }
|
|
189
|
+
.fx-toggle > input:focus-visible + i { outline: 3px solid var(--fx-gold); outline-offset: 3px; }
|
|
190
|
+
.fx-toggle > input:disabled + i { filter: saturate(.3); opacity: .6; }
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
.fx-tip { position: relative; display: inline-flex; }
|
|
194
|
+
.fx-tooltip {
|
|
195
|
+
position: absolute; z-index: var(--fx-z-tooltip); left: 50%; bottom: calc(100% + 12px); width: max-content; min-width: 180px; max-width: 280px;
|
|
196
|
+
display: grid; gap: 8px; padding: var(--fx-s-3); border-color: var(--fx-gold-rim); border-radius: var(--fx-r-md);
|
|
197
|
+
box-shadow: var(--fx-rim-light), var(--fx-lift-2); text-align: left; pointer-events: none;
|
|
198
|
+
opacity: 0; visibility: hidden; transform: translate(-50%, 6px) scale(.96); transform-origin: 50% 100%;
|
|
199
|
+
transition: opacity var(--m-base) var(--m-ease-out), transform var(--m-move) var(--m-ease-spring), visibility 0s linear var(--m-base);
|
|
200
|
+
}
|
|
201
|
+
.fx-tooltip::after { content: ''; position: absolute; left: 50%; bottom: -7px; width: 12px; height: 12px; margin-left: -6px; rotate: 45deg; border: solid var(--fx-gold-rim); border-width: 0 1px 1px 0; background: var(--fx-glass-bottom); }
|
|
202
|
+
.fx-tip:is(:hover, :focus-within) > .fx-tooltip, .fx-tooltip.is-open { opacity: 1; visibility: visible; transform: translate(-50%, 0); transition-delay: 0s; }
|
|
203
|
+
.fx-tooltip-head { display: flex; align-items: center; gap: var(--fx-s-2); font: 800 var(--fx-t-md)/1.1 var(--fx-font-display); color: var(--fx-gold-light); }
|
|
204
|
+
.fx-tooltip-head .fx-icon { width: 26px; height: 26px; }
|
|
205
|
+
.fx-tooltip p { margin: 0; color: var(--fx-ink-2); font: 600 var(--fx-t-sm)/1.4 var(--fx-font-ui); }
|
|
206
|
+
.fx-tooltip dl { display: grid; gap: 4px; margin: 0; padding-top: 8px; border-top: 1px solid var(--fx-gold-rim-quiet); }
|
|
207
|
+
.fx-tooltip dl > div { display: flex; justify-content: space-between; gap: var(--fx-s-3); }
|
|
208
|
+
.fx-tooltip dt { color: var(--fx-ink-blue); font: 800 var(--fx-t-sm)/1.2 var(--fx-font-ui); }
|
|
209
|
+
.fx-tooltip dd { margin: 0; font: 800 var(--fx-t-sm)/1.2 var(--fx-font-display); font-variant-numeric: tabular-nums; }
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
.fx-toast {
|
|
213
|
+
position: relative; display: flex; align-items: center; gap: var(--fx-s-3); width: min(380px, 100%); padding: var(--fx-s-3) var(--fx-s-3) var(--fx-s-3) var(--fx-s-3);
|
|
214
|
+
border-radius: var(--fx-r-lg); box-shadow: var(--fx-rim-light), var(--fx-lift-2); overflow: hidden;
|
|
215
|
+
}
|
|
216
|
+
.fx-toast::before { content: ''; position: absolute; inset: 10px auto 10px 0; width: 4px; border-radius: 0 4px 4px 0; background: var(--tone, var(--fx-gold)); }
|
|
217
|
+
.fx-toast-art { display: grid; place-items: center; flex: none; width: 46px; height: 46px; border-radius: 50%; background: radial-gradient(circle at 35% 28%, rgb(255 255 255 / 30%), transparent 55%), color-mix(in oklab, var(--tone, var(--fx-gold)), var(--fx-deep) 62%); box-shadow: inset 0 -3px 6px rgb(0 0 0 / 30%), 0 2px 0 rgb(2 10 24 / 45%); }
|
|
218
|
+
.fx-toast-art .fx-icon { width: 32px; height: 32px; }
|
|
219
|
+
.fx-toast-text { display: grid; flex: 1; gap: 2px; min-width: 0; }
|
|
220
|
+
.fx-toast-text b { font: 800 var(--fx-t-md)/1.2 var(--fx-font-display); }
|
|
221
|
+
.fx-toast-text span { color: var(--fx-ink-2); font: 600 var(--fx-t-sm)/1.35 var(--fx-font-ui); }
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
.fx-stats { display: flex; flex-wrap: wrap; gap: 10px; }
|
|
225
|
+
.fx-stat { display: inline-flex; align-items: center; gap: 10px; min-height: 58px; padding: 8px 16px 8px 10px; border-radius: 18px; box-shadow: var(--fx-rim-light), var(--fx-foot); }
|
|
226
|
+
.fx-stat > .fx-icon { width: 38px; height: 38px; filter: drop-shadow(0 3px 3px rgb(0 0 0 / 35%)); }
|
|
227
|
+
.fx-stat b { display: block; color: var(--fx-gold-light); font: 800 22px/1 var(--fx-font-display); font-variant-numeric: tabular-nums; }
|
|
228
|
+
.fx-stat b small { margin-left: 3px; color: var(--fx-ink-3); font-size: var(--fx-t-sm); }
|
|
229
|
+
.fx-stat > span > span { color: var(--fx-ink-3); font: 700 var(--fx-t-sm)/1.3 var(--fx-font-ui); }
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
.fx-tile { display: flex; flex-direction: column; align-items: stretch; gap: 8px; width: 118px; padding: 0; border: 0; background: none; color: var(--fx-ink); font: inherit; cursor: pointer; }
|
|
233
|
+
.fx-tile-glass {
|
|
234
|
+
position: relative; display: grid; place-items: center; aspect-ratio: 1; border: 1.5px solid var(--fx-gold-rim); border-radius: var(--fx-r-xl);
|
|
235
|
+
background: radial-gradient(circle at 30% 22%, rgb(255 255 255 / 34%), transparent 34%), linear-gradient(160deg, oklch(62% .12 var(--hue, 220)), oklch(36% .1 var(--hue, 220)) 70%, oklch(28% .08 var(--hue, 220)));
|
|
236
|
+
box-shadow: var(--fx-plump), var(--fx-foot-deep), var(--fx-lift-2);
|
|
237
|
+
transition: transform var(--m-quick) var(--m-ease-spring), box-shadow var(--m-quick) var(--m-ease-out);
|
|
238
|
+
}
|
|
239
|
+
.fx-tile-glass > .fx-icon { width: 62%; height: 62%; filter: drop-shadow(0 6px 6px rgb(0 0 0 / 35%)); transition: transform var(--m-move) var(--m-ease-spring); }
|
|
240
|
+
.fx-tile-glass > .fx-badge { position: absolute; top: -6px; right: -6px; }
|
|
241
|
+
.fx-tile:hover .fx-tile-glass, .fx-tile:focus-visible .fx-tile-glass { transform: translateY(-3px); }
|
|
242
|
+
.fx-tile:hover .fx-tile-glass > .fx-icon { transform: scale(1.08) rotate(-3deg); }
|
|
243
|
+
.fx-tile:active .fx-tile-glass { transform: translateY(3px); box-shadow: var(--fx-plump), 0 2px 0 rgb(4 18 34 / 55%); }
|
|
244
|
+
.fx-tile:focus-visible { outline: none; }
|
|
245
|
+
.fx-tile:focus-visible .fx-tile-glass { outline: 3px solid var(--fx-gold); outline-offset: 4px; }
|
|
246
|
+
.fx-tile.is-open .fx-tile-glass::after { content: ''; position: absolute; bottom: -12px; width: 18px; height: 5px; border-radius: 3px; background: var(--fx-gold); box-shadow: 0 0 10px var(--fx-gold); }
|
|
247
|
+
.fx-tile-title { text-align: center; font: 800 var(--fx-t-md)/1.2 var(--fx-font-display); text-shadow: var(--fx-on-water); }
|
|
248
|
+
|
|
249
|
+
.fx-dock { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; padding: 8px 10px; overflow-x: auto; border-color: var(--fx-gold-rim); border-radius: var(--fx-r-xl); box-shadow: var(--fx-rim-light), var(--fx-lift-2); scrollbar-width: none; }
|
|
250
|
+
.fx-dock-item {
|
|
251
|
+
position: relative; flex: none; display: grid; place-items: center; width: 60px; height: 60px; padding: 0; border: 0; border-radius: 18px; background: none; cursor: pointer;
|
|
252
|
+
transition: transform var(--m-quick) var(--m-ease-spring), background var(--m-quick) var(--m-ease-out);
|
|
253
|
+
}
|
|
254
|
+
.fx-dock-item:hover { background: rgb(255 255 255 / 8%); transform: translateY(-4px) scale(1.06); }
|
|
255
|
+
.fx-dock-item:focus-visible { outline: 3px solid var(--fx-gold); outline-offset: 1px; }
|
|
256
|
+
.fx-dock-item > .fx-icon { width: 46px; height: 46px; filter: drop-shadow(0 4px 4px rgb(0 0 0 / 35%)); }
|
|
257
|
+
.fx-dock-item > i { position: absolute; bottom: 1px; left: 50%; width: 6px; height: 6px; margin-left: -3px; border-radius: 50%; }
|
|
258
|
+
.fx-dock-item.is-open > i { background: var(--fx-ink-2); }
|
|
259
|
+
.fx-dock-item.is-front > i { width: 16px; margin-left: -8px; border-radius: 3px; background: var(--fx-gold); box-shadow: 0 0 8px var(--fx-gold); }
|
|
260
|
+
.fx-dock-item > .fx-badge { position: absolute; top: 0; right: 0; }
|
|
261
|
+
.fx-dock-sep { flex: none; width: 1px; height: 40px; margin: 0 4px; background: var(--fx-gold-rim-quiet); }
|
|
262
|
+
|
|
263
|
+
@media (prefers-reduced-motion: reduce) {
|
|
264
|
+
:is(.fx-button, .fx-round, .fx-tile-glass, .fx-dock-item, .fx-tab, .fx-chip):is(:hover, :active) { transform: none !important; }
|
|
265
|
+
.fx-tile:hover .fx-tile-glass > .fx-icon { transform: none; }
|
|
266
|
+
}
|
|
267
|
+
}
|
package/src/kit.tsx
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useId, useState, type AnchorHTMLAttributes, type ButtonHTMLAttributes, type CSSProperties, type InputHTMLAttributes, type ReactNode } from 'react'
|
|
4
|
+
import FishiesImg from './FishiesImg'
|
|
5
|
+
import { ICON_SIZES, iconByName, iconUrl, type IconSize, type SystemIconName } from './icons'
|
|
6
|
+
import './kit.css'
|
|
7
|
+
|
|
8
|
+
const cx = (...names: (string | false | null | undefined)[]) => names.filter(Boolean).join(' ')
|
|
9
|
+
const px = (size: IconSize | number) => typeof size === 'number' ? size : ICON_SIZES[size]
|
|
10
|
+
|
|
11
|
+
export function SystemIcon({ name, size = 'md', fallback, className }: { name: SystemIconName; size?: IconSize | number; fallback?: ReactNode; className?: string }) {
|
|
12
|
+
const [failed, setFailed] = useState(false)
|
|
13
|
+
const icon = iconByName(name)
|
|
14
|
+
const side = px(size)
|
|
15
|
+
if (!icon || failed) return fallback ? <>{fallback}</> : null
|
|
16
|
+
return <span className={cx('fx-icon', className)} style={{ width: side, height: side }} aria-hidden="true">
|
|
17
|
+
<FishiesImg src={iconUrl(icon)} width={side} height={side} alt="" draggable={false} decoding="async" onError={() => setFailed(true)}/>
|
|
18
|
+
</span>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type ButtonTone = 'glass' | 'gold' | 'ghost' | 'danger'
|
|
22
|
+
type ButtonOwn = { tone?: ButtonTone; size?: 'sm' | 'md' | 'lg'; icon?: SystemIconName; children?: ReactNode }
|
|
23
|
+
|
|
24
|
+
/** A plump toy button: glass for everyday actions, gold for the one thing to press, ghost for a quiet third choice, danger for what cannot be undone. */
|
|
25
|
+
export function Button({ tone = 'glass', size = 'md', icon, children, className, type = 'button', ...rest }: ButtonOwn & ButtonHTMLAttributes<HTMLButtonElement>) {
|
|
26
|
+
return <button type={type} className={cx('fx-button', `is-${tone}`, size !== 'md' && `is-${size}`, className)} {...rest}>
|
|
27
|
+
{icon && <SystemIcon name={icon} size={size === 'lg' ? 30 : size === 'sm' ? 20 : 26}/>}{children}
|
|
28
|
+
</button>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The same button as a link, for an action that goes somewhere. */
|
|
32
|
+
export function ButtonLink({ tone = 'glass', size = 'md', icon, children, className, ...rest }: ButtonOwn & AnchorHTMLAttributes<HTMLAnchorElement>) {
|
|
33
|
+
return <a className={cx('fx-button', `is-${tone}`, size !== 'md' && `is-${size}`, className)} {...rest}>
|
|
34
|
+
{icon && <SystemIcon name={icon} size={size === 'lg' ? 30 : size === 'sm' ? 20 : 26}/>}{children}
|
|
35
|
+
</a>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A round toy button for window controls, the video player and close; `label` is what it says to a screen reader. */
|
|
39
|
+
export function RoundButton({ label, tone = 'navy', size = 'md', icon, children, className, type = 'button', ...rest }: { label: string; tone?: 'navy' | 'coral' | 'gold'; size?: 'sm' | 'md' | 'lg'; icon?: SystemIconName; children?: ReactNode } & ButtonHTMLAttributes<HTMLButtonElement>) {
|
|
40
|
+
return <button type={type} aria-label={label} title={label} className={cx('fx-round', tone !== 'navy' && `is-${tone}`, size !== 'md' && `is-${size}`, className)} {...rest}>
|
|
41
|
+
{icon ? <SystemIcon name={icon} size={size === 'lg' ? 34 : 26}/> : children}
|
|
42
|
+
</button>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A small reading or choice: a dot of colour for a kind, an icon, or gold when chosen. */
|
|
46
|
+
export function Chip({ tone, icon, selected, onClick, children, className }: { tone?: string; icon?: SystemIconName; selected?: boolean; onClick?: () => void; children: ReactNode; className?: string }) {
|
|
47
|
+
const style = tone ? { '--tone': tone } as CSSProperties : undefined
|
|
48
|
+
const inner = <>{tone && <i aria-hidden="true"/>}{icon && <SystemIcon name={icon} size={18}/>}{children}</>
|
|
49
|
+
return onClick
|
|
50
|
+
? <button type="button" className={cx('fx-chip', className)} style={style} aria-pressed={selected} onClick={onClick}>{inner}</button>
|
|
51
|
+
: <span className={cx('fx-chip', selected && 'is-on', className)} style={style}>{inner}</span>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function Badge({ count, label }: { count?: number; label?: string }) {
|
|
55
|
+
return count === undefined
|
|
56
|
+
? <span className="fx-badge is-dot" role="status" aria-label={label}/>
|
|
57
|
+
: <span className="fx-badge" role="status" aria-label={label}>{count > 99 ? '99+' : count}</span>
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function Tag({ children }: { children: ReactNode }) {
|
|
61
|
+
return <span className="fx-tag">{children}</span>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A glass card: an optional eyebrow, a title and a few lines. */
|
|
65
|
+
export function Card({ eyebrow, title, children, className }: { eyebrow?: string; title?: ReactNode; children?: ReactNode; className?: string }) {
|
|
66
|
+
return <article className={cx('fx-card', className)}>
|
|
67
|
+
{eyebrow && <span className="fx-eyebrow">{eyebrow}</span>}
|
|
68
|
+
{title && <h3>{title}</h3>}
|
|
69
|
+
{children}
|
|
70
|
+
</article>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function Panel({ title, icon, actions, children, className, style }: { title: ReactNode; icon?: SystemIconName; actions?: ReactNode; children?: ReactNode; className?: string; style?: CSSProperties }) {
|
|
74
|
+
return <section className={cx('fx-panel', className)} style={style}>
|
|
75
|
+
<header className="fx-panel-head">{icon && <SystemIcon name={icon} size={30}/>}<h2>{title}</h2>{actions}</header>
|
|
76
|
+
<div className="fx-panel-body">{children}</div>
|
|
77
|
+
</section>
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A Keeper OS window frame: a title bar with round minimise and close buttons over a darker well. */
|
|
81
|
+
export function Window({ title, icon, onClose, onMinimize, closeLabel = 'Close', minimizeLabel = 'Minimise', children, className, style }: {
|
|
82
|
+
title: ReactNode; icon?: SystemIconName; onClose?: () => void; onMinimize?: () => void; closeLabel?: string; minimizeLabel?: string
|
|
83
|
+
children?: ReactNode; className?: string; style?: CSSProperties
|
|
84
|
+
}) {
|
|
85
|
+
return <section className={cx('fx-window', className)} style={style} role="dialog" aria-label={typeof title === 'string' ? title : undefined}>
|
|
86
|
+
<header className="fx-window-bar">
|
|
87
|
+
{icon && <SystemIcon name={icon} size={26}/>}
|
|
88
|
+
<h2>{title}</h2>
|
|
89
|
+
<span className="fx-window-buttons">
|
|
90
|
+
{onMinimize && <RoundButton label={minimizeLabel} size="sm" onClick={onMinimize}><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3.5 8h9" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round"/></svg></RoundButton>}
|
|
91
|
+
{onClose && <RoundButton label={closeLabel} size="sm" tone="coral" onClick={onClose}><svg viewBox="0 0 16 16" aria-hidden="true"><path d="m4.5 4.5 7 7m0-7-7 7" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round"/></svg></RoundButton>}
|
|
92
|
+
</span>
|
|
93
|
+
</header>
|
|
94
|
+
<div className="fx-window-body">{children}</div>
|
|
95
|
+
</section>
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type TabItem = { id: string; label: string; icon?: SystemIconName }
|
|
99
|
+
|
|
100
|
+
export function Tabs({ items, value, onChange, label }: { items: readonly TabItem[]; value: string; onChange: (id: string) => void; label: string }) {
|
|
101
|
+
const move = (from: number, step: number) => onChange(items[(from + step + items.length) % items.length]!.id)
|
|
102
|
+
return <div className="fx-tabs" role="tablist" aria-label={label}>
|
|
103
|
+
{items.map((item, index) => <button key={item.id} type="button" role="tab" className="fx-tab" aria-selected={item.id === value} tabIndex={item.id === value ? 0 : -1}
|
|
104
|
+
onClick={() => onChange(item.id)} onKeyDown={event => { if (event.key === 'ArrowRight') move(index, 1); if (event.key === 'ArrowLeft') move(index, -1) }}>
|
|
105
|
+
{item.icon && <SystemIcon name={item.icon} size={22}/>}{item.label}
|
|
106
|
+
</button>)}
|
|
107
|
+
</div>
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A list of rows; each row has an icon, a title, a detail line and a value at its end. */
|
|
111
|
+
export function List({ children, label }: { children: ReactNode; label?: string }) {
|
|
112
|
+
return <ul className="fx-list" aria-label={label}>{children}</ul>
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function ListRow({ icon, title, detail, end, onClick, href, selected }: { icon?: SystemIconName; title: ReactNode; detail?: ReactNode; end?: ReactNode; onClick?: () => void; href?: string; selected?: boolean }) {
|
|
116
|
+
const inner = <>{icon && <SystemIcon name={icon} size={36}/>}<span className="fx-row-text"><b>{title}</b>{detail && <small>{detail}</small>}</span>{end !== undefined && <span className="fx-row-end">{end}</span>}</>
|
|
117
|
+
return <li>{href ? <a className={cx('fx-row', selected && 'is-on')} href={href}>{inner}</a>
|
|
118
|
+
: onClick ? <button type="button" className={cx('fx-row', selected && 'is-on')} onClick={onClick} aria-pressed={selected}>{inner}</button>
|
|
119
|
+
: <div className={cx('fx-row', selected && 'is-on')}>{inner}</div>}</li>
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function Field({ label, hint, className, ...input }: { label: string; hint?: string } & InputHTMLAttributes<HTMLInputElement>) {
|
|
123
|
+
const id = useId()
|
|
124
|
+
return <label className={cx('fx-field', className)} htmlFor={id}>
|
|
125
|
+
<span>{label}</span>
|
|
126
|
+
<input id={id} className="fx-input" aria-describedby={hint ? `${id}-hint` : undefined} {...input}/>
|
|
127
|
+
{hint && <small id={`${id}-hint`}>{hint}</small>}
|
|
128
|
+
</label>
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function Toggle({ label, checked, onChange, disabled }: { label: ReactNode; checked: boolean; onChange: (checked: boolean) => void; disabled?: boolean }) {
|
|
132
|
+
return <label className="fx-toggle">
|
|
133
|
+
<input type="checkbox" role="switch" checked={checked} disabled={disabled} onChange={event => onChange(event.target.checked)}/>
|
|
134
|
+
<i aria-hidden="true"/>{label}
|
|
135
|
+
</label>
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export type TooltipRow = { label: ReactNode; value: ReactNode }
|
|
139
|
+
|
|
140
|
+
/** A designed tooltip, never plain text: an icon and title, a line, and labelled values; it opens on hover and on keyboard focus. */
|
|
141
|
+
export function Tooltip({ title, icon, text, rows, children, open }: { title: ReactNode; icon?: SystemIconName; text?: ReactNode; rows?: readonly TooltipRow[]; children: ReactNode; open?: boolean }) {
|
|
142
|
+
const id = useId()
|
|
143
|
+
return <span className="fx-tip" aria-describedby={id}>
|
|
144
|
+
{children}
|
|
145
|
+
<span className={cx('fx-tooltip', open && 'is-open')} role="tooltip" id={id}>
|
|
146
|
+
<span className="fx-tooltip-head">{icon && <SystemIcon name={icon} size={26}/>}{title}</span>
|
|
147
|
+
{text && <p>{text}</p>}
|
|
148
|
+
{rows && rows.length > 0 && <dl>{rows.map((row, index) => <div key={index}><dt>{row.label}</dt><dd>{row.value}</dd></div>)}</dl>}
|
|
149
|
+
</span>
|
|
150
|
+
</span>
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function Toast({ title, children, icon, tone, onClose, closeLabel = 'Close' }: { title: ReactNode; children?: ReactNode; icon?: SystemIconName; tone?: string; onClose?: () => void; closeLabel?: string }) {
|
|
154
|
+
return <div className="fx-toast" role="status" style={tone ? { '--tone': tone } as CSSProperties : undefined}>
|
|
155
|
+
{icon && <span className="fx-toast-art"><SystemIcon name={icon} size={32}/></span>}
|
|
156
|
+
<span className="fx-toast-text"><b>{title}</b>{children && <span>{children}</span>}</span>
|
|
157
|
+
{onClose && <RoundButton label={closeLabel} size="sm" onClick={onClose}><svg viewBox="0 0 16 16" aria-hidden="true"><path d="m4.5 4.5 7 7m0-7-7 7" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round"/></svg></RoundButton>}
|
|
158
|
+
</div>
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** A number a keeper reads at a glance: an icon, the value in gold with its unit, and what it counts. */
|
|
162
|
+
export function Stat({ icon, value, unit, label }: { icon?: SystemIconName; value: ReactNode; unit?: string; label: ReactNode }) {
|
|
163
|
+
return <div className="fx-stat">
|
|
164
|
+
{icon && <SystemIcon name={icon} size={38}/>}
|
|
165
|
+
<span><b>{value}{unit && <small>{unit}</small>}</b><span>{label}</span></span>
|
|
166
|
+
</div>
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** A desktop tile: a plump glass square in its own hue holding an icon, a title under it, a badge and an open mark. */
|
|
170
|
+
export function DockTile({ icon, title, hue, badge, open, onClick }: { icon: SystemIconName; title: string; hue: number; badge?: number; open?: boolean; onClick?: () => void }) {
|
|
171
|
+
return <button type="button" className={cx('fx-tile', open && 'is-open')} onClick={onClick} style={{ '--hue': hue } as CSSProperties}>
|
|
172
|
+
<span className="fx-tile-glass"><SystemIcon name={icon} size={72}/>{badge !== undefined && badge > 0 && <Badge count={badge}/>}</span>
|
|
173
|
+
<span className="fx-tile-title">{title}</span>
|
|
174
|
+
</button>
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function Dock({ children, label }: { children: ReactNode; label: string }) {
|
|
178
|
+
return <nav className="fx-dock" aria-label={label}>{children}</nav>
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** One icon on a dock. */
|
|
182
|
+
export function DockItem({ icon, label, front, open, badge, onClick }: { icon: SystemIconName; label: string; front?: boolean; open?: boolean; badge?: number; onClick?: () => void }) {
|
|
183
|
+
return <button type="button" className={cx('fx-dock-item', open && 'is-open', front && 'is-front')} aria-label={label} title={label} onClick={onClick}>
|
|
184
|
+
<SystemIcon name={icon} size="lg"/>{badge !== undefined && badge > 0 && <Badge count={badge}/>}<i aria-hidden="true"/>
|
|
185
|
+
</button>
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export { Spinner, Progress } from './loader'
|
|
189
|
+
export { default as Select } from './Select'
|
|
190
|
+
export { default as Image } from './FishiesImg'
|
package/src/loader.css
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
.fx-spinner {
|
|
2
|
+
display: inline-grid; place-items: center; flex: none;
|
|
3
|
+
width: var(--fx-size, 32px); height: var(--fx-size, 32px);
|
|
4
|
+
filter: drop-shadow(0 4px 10px rgb(1 8 20 / 45%));
|
|
5
|
+
}
|
|
6
|
+
.fx-spinner > svg { display: block; width: 100%; height: 100%; }
|
|
7
|
+
|
|
8
|
+
.fx-progress {
|
|
9
|
+
position: relative; display: block; overflow: hidden;
|
|
10
|
+
width: min(280px, 100%); height: 10px; border-radius: 999px;
|
|
11
|
+
border: 1px solid rgb(196 236 250 / 20%);
|
|
12
|
+
background: linear-gradient(180deg, rgb(4 16 26 / 72%), rgb(12 38 56 / 72%));
|
|
13
|
+
box-shadow: inset 0 1px 3px rgb(0 0 0 / 50%), 0 1px 0 rgb(255 255 255 / 7%);
|
|
14
|
+
}
|
|
15
|
+
.fx-progress > i {
|
|
16
|
+
position: absolute; top: 1px; bottom: 1px; left: 1px;
|
|
17
|
+
width: max(8px, calc((100% - 2px) * var(--fx-value, 0)));
|
|
18
|
+
border-radius: inherit;
|
|
19
|
+
background: linear-gradient(180deg, var(--fx-gold-face), var(--fx-gold) 55%, var(--fx-gold-deep));
|
|
20
|
+
box-shadow: 0 0 8px var(--fx-gold-rim);
|
|
21
|
+
transition: width 320ms cubic-bezier(.2, .8, .2, 1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.fx-progress > i::after {
|
|
25
|
+
content: ''; position: absolute; top: 50%; right: 1px; width: 6px; height: 6px; margin-top: -3px;
|
|
26
|
+
border-radius: 50%; background: radial-gradient(circle at 35% 35%, #fff, rgb(255 255 255 / 35%) 70%);
|
|
27
|
+
}
|
|
28
|
+
.fx-progress[data-indeterminate] > i { width: 34%; animation: fx-sweep 1.4s cubic-bezier(.45, 0, .55, 1) infinite; }
|
|
29
|
+
@keyframes fx-sweep { from { transform: translateX(-105%); } to { transform: translateX(300%); } }
|
|
30
|
+
|
|
31
|
+
:is(img, video)[data-fx='wait'], img[data-fx='error'] { object-position: -99999px -99999px !important; }
|
|
32
|
+
:is(img, video)[data-fx='wait'] {
|
|
33
|
+
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cstyle%3E.a%7Btransform-origin:24px 24px;animation:s 1.1s linear infinite%7D.b%7Bopacity:0;animation:u 1.8s ease-in infinite%7D.c%7Banimation-delay:.6s%7D.d%7Banimation-delay:1.2s%7D@keyframes s%7Bto%7Btransform:rotate(360deg)%7D%7D@keyframes u%7B0%25%7Bopacity:0;transform:translateY(7px)%7D30%25%7Bopacity:.95%7D100%25%7Bopacity:0;transform:translateY(-9px)%7D%7D@media (prefers-reduced-motion:reduce)%7B.a%7Banimation-duration:3.6s%7D.b%7Banimation:none;opacity:.8%7D%7D%3C/style%3E%3Ccircle cx='24' cy='24' r='21' fill='%230b2a3c' fill-opacity='.74' stroke='%23c4ecfa' stroke-opacity='.24'/%3E%3Ccircle cx='24' cy='24' r='16' fill='none' stroke='%23c4ecfa' stroke-opacity='.13' stroke-width='3'/%3E%3Ccircle class='a' cx='24' cy='24' r='16' fill='none' stroke='%23ffc46b' stroke-width='3' stroke-linecap='round' stroke-dasharray='25 76'/%3E%3Cg fill='%23dff6ff' fill-opacity='.16' stroke='%23dff6ff' stroke-width='1.1'%3E%3Ccircle class='b' cx='21.5' cy='26' r='2.7'/%3E%3Ccircle class='b c' cx='27' cy='27.5' r='1.9'/%3E%3Ccircle class='b d' cx='23.5' cy='30' r='1.3'/%3E%3C/g%3E%3C/svg%3E") center / clamp(12px, 36%, 40px) no-repeat,
|
|
34
|
+
radial-gradient(closest-side, rgb(196 236 250 / 10%), rgb(196 236 250 / 4%)) !important;
|
|
35
|
+
}
|
|
36
|
+
img[data-fx='error'] { background: rgb(196 236 250 / 5%) !important; }
|
|
37
|
+
:is(img, video)[data-fx='fade'] { animation: fx-in 240ms cubic-bezier(.2, .8, .2, 1); }
|
|
38
|
+
@keyframes fx-in { from { opacity: 0; } }
|
|
39
|
+
|
|
40
|
+
@media (prefers-reduced-motion: reduce) {
|
|
41
|
+
.fx-progress > i { transition: none; }
|
|
42
|
+
.fx-progress[data-indeterminate] > i { animation-duration: 4s; }
|
|
43
|
+
:is(img, video)[data-fx='fade'] { animation: none; }
|
|
44
|
+
:is(img, video)[data-fx='wait'] {
|
|
45
|
+
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Ccircle cx='24' cy='24' r='21' fill='%230b2a3c' fill-opacity='.74' stroke='%23c4ecfa' stroke-opacity='.24'/%3E%3Ccircle cx='24' cy='24' r='16' fill='none' stroke='%23c4ecfa' stroke-opacity='.13' stroke-width='3'/%3E%3Ccircle class='a' cx='24' cy='24' r='16' fill='none' stroke='%23ffc46b' stroke-width='3' stroke-linecap='round' stroke-dasharray='25 76'/%3E%3Cg fill='%23dff6ff' fill-opacity='.16' stroke='%23dff6ff' stroke-width='1.1'%3E%3Ccircle class='b' cx='21.5' cy='26' r='2.7'/%3E%3Ccircle class='b c' cx='27' cy='27.5' r='1.9'/%3E%3Ccircle class='b d' cx='23.5' cy='30' r='1.3'/%3E%3C/g%3E%3C/svg%3E") center / clamp(12px, 36%, 40px) no-repeat,
|
|
46
|
+
radial-gradient(closest-side, rgb(196 236 250 / 10%), rgb(196 236 250 / 4%)) !important;
|
|
47
|
+
}
|
|
48
|
+
}
|