@hraness/design-kit 0.31.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARTICLE_COPY.md +208 -0
- package/HERO_FIELDS.md +55 -0
- package/ICONS.md +56 -0
- package/LANTERN_MATERIAL.md +60 -0
- package/LICENSE +21 -0
- package/MARKETING_COPY.md +49 -0
- package/MARKETING_PRESET.md +108 -0
- package/PALETTES.md +90 -0
- package/PAPER_THEME.md +128 -0
- package/README.md +745 -0
- package/STATUS_PAGES.md +67 -0
- package/STYLE.md +245 -0
- package/WRITING.md +72 -0
- package/dist/browser/index.js +2345 -0
- package/dist/chunk-44djxk16.js +6872 -0
- package/dist/chunk-5gtx3pza.js +9 -0
- package/dist/chunk-77391vmq.js +454 -0
- package/dist/chunk-7b1586eb.js +1209 -0
- package/dist/chunk-8834fh4n.js +6 -0
- package/dist/chunk-9t8xyqte.js +870 -0
- package/dist/chunk-cejpzyfh.js +446 -0
- package/dist/chunk-eh71jz57.js +429 -0
- package/dist/chunk-evb02bc1.js +516 -0
- package/dist/chunk-h4k7yv6x.js +368 -0
- package/dist/chunk-he8eznb1.js +760 -0
- package/dist/chunk-mr1vdcjq.js +404 -0
- package/dist/chunk-wzvdn8ey.js +116 -0
- package/dist/fonts/nebula-sans/social-fonts.generated.js +53 -0
- package/dist/icons.js +48 -0
- package/dist/index.js +237 -0
- package/dist/launch.js +41 -0
- package/dist/mockups/client.js +422 -0
- package/dist/mockups/index.js +1850 -0
- package/dist/portfolio.js +1549 -0
- package/dist/provider-marks.js +19 -0
- package/dist/react/charts.js +11 -0
- package/dist/react/hero-backdrop.js +8 -0
- package/dist/react/index.js +7252 -0
- package/dist/react/platform-install.js +10 -0
- package/dist/react/server.js +137 -0
- package/dist/stylex-manifest.json +1 -0
- package/dist/stylex.css +5392 -0
- package/dist/syntax-highlighting.js +15 -0
- package/dist/testing.js +250 -0
- package/package.json +377 -0
- package/portfolio-inventory.json +31 -0
- package/scripts/check-lantern-material-snapshot.d.mts +26 -0
- package/scripts/check-lantern-material-snapshot.mjs +126 -0
- package/scripts/check-marketing-snapshot.d.mts +12 -0
- package/scripts/check-marketing-snapshot.mjs +58 -0
- package/scripts/lantern-material-snapshot.ts +101 -0
- package/scripts/marketing-textures.ts +48 -0
- package/scripts/paper-theme-snapshot.ts +97 -0
- package/scripts/product-marketing-snapshot.ts +149 -0
- package/src/appearance-menu.css +266 -0
- package/src/appearance.ts +32 -0
- package/src/article-html.ts +269 -0
- package/src/article.ts +686 -0
- package/src/browser/appearance-menu.ts +604 -0
- package/src/browser/artifact-share.ts +164 -0
- package/src/browser/design-palette.ts +197 -0
- package/src/browser/foil.ts +124 -0
- package/src/browser/hero-light.ts +11 -0
- package/src/browser/index.ts +29 -0
- package/src/browser/status-field.ts +432 -0
- package/src/browser/status-page.ts +100 -0
- package/src/browser/sticky-offset.ts +82 -0
- package/src/browser/theme-color-sync.ts +213 -0
- package/src/charts.css +7 -0
- package/src/compiler-components.css +34 -0
- package/src/compiler-foundation.css +18 -0
- package/src/compiler-palettes.css +7 -0
- package/src/compiler-tokens.css +342 -0
- package/src/components.css +3 -0
- package/src/design-gallery.css +431 -0
- package/src/effects.css +14 -0
- package/src/fonts/geist-mono/GeistMono[wght].woff2 +0 -0
- package/src/fonts/geist-mono/OFL.txt +93 -0
- package/src/fonts/geist-mono/PROVENANCE.md +12 -0
- package/src/fonts/instrument-serif/OFL.txt +93 -0
- package/src/fonts/instrument-serif/UPSTREAM.md +14 -0
- package/src/fonts/instrument-serif/instrument-serif-latin-400.woff2 +0 -0
- package/src/fonts/nebula-sans/LICENSE.txt +96 -0
- package/src/fonts/nebula-sans/NebulaSans-Black.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-BlackItalic.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-Bold.otf +0 -0
- package/src/fonts/nebula-sans/NebulaSans-Bold.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-BoldItalic.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-Book.otf +0 -0
- package/src/fonts/nebula-sans/NebulaSans-Book.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-BookItalic.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-Light.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-LightItalic.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-Medium.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-MediumItalic.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-Semibold.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-SemiboldItalic.woff2 +0 -0
- package/src/fonts/nebula-sans/PROVENANCE.md +14 -0
- package/src/fonts/nebula-sans/social-fonts.generated.ts +3873 -0
- package/src/fonts.css +106 -0
- package/src/icons/act60/act60.svg +5 -0
- package/src/icons/act60/application.svg +9 -0
- package/src/icons/act60/chapter-compare.svg +3 -0
- package/src/icons/act60/decree-residence.svg +6 -0
- package/src/icons/act60/estimate.svg +7 -0
- package/src/icons/act60/export-services.svg +4 -0
- package/src/icons/act60/investor.svg +13 -0
- package/src/icons/act60/presence.svg +7 -0
- package/src/icons/aicharts/aicharts.svg +6 -0
- package/src/icons/aicharts/cost-compare.svg +4 -0
- package/src/icons/aicharts/task-audio.svg +7 -0
- package/src/icons/aicharts/task-coding.svg +6 -0
- package/src/icons/aicharts/task-images.svg +3 -0
- package/src/icons/aicharts/task-reasoning.svg +9 -0
- package/src/icons/aicharts/task-research.svg +5 -0
- package/src/icons/aicharts/task-video.svg +4 -0
- package/src/icons/manifest.json +1371 -0
- package/src/icons/platonik/conversation.svg +3 -0
- package/src/icons/platonik/creature.svg +7 -0
- package/src/icons/platonik/economy.svg +4 -0
- package/src/icons/platonik/keep-light.svg +6 -0
- package/src/icons/platonik/platonik.svg +7 -0
- package/src/icons/platonik/spark-route.svg +15 -0
- package/src/icons/receipts/act60-retry.receipt.json +888 -0
- package/src/icons/receipts/act60.receipt.json +1366 -0
- package/src/icons/receipts/aicharts.receipt.json +1546 -0
- package/src/icons/receipts/platonik.receipt.json +1011 -0
- package/src/icons/receipts/roughday.receipt.json +770 -0
- package/src/icons/receipts/shared.receipt.json +1296 -0
- package/src/icons/receipts/slopcamera.receipt.json +1495 -0
- package/src/icons/receipts/soundfish-retry.receipt.json +460 -0
- package/src/icons/receipts/soundfish.receipt.json +1709 -0
- package/src/icons/receipts/sponge.receipt.json +1743 -0
- package/src/icons/receipts/stripe-history-retry.receipt.json +145 -0
- package/src/icons/receipts/stripe-history.receipt.json +1202 -0
- package/src/icons/receipts/wordcell.receipt.json +1924 -0
- package/src/icons/roughday/finance.svg +7 -0
- package/src/icons/roughday/roughday.svg +6 -0
- package/src/icons/roughday/technology.svg +4 -0
- package/src/icons/roughday/world.svg +4 -0
- package/src/icons/sets/act60.json +63 -0
- package/src/icons/sets/aicharts.json +63 -0
- package/src/icons/sets/platonik.json +39 -0
- package/src/icons/sets/roughday.json +25 -0
- package/src/icons/sets/shared.json +12 -0
- package/src/icons/sets/slopcamera.json +47 -0
- package/src/icons/sets/soundfish.json +43 -0
- package/src/icons/sets/sponge.json +51 -0
- package/src/icons/sets/stripe-history.json +39 -0
- package/src/icons/sets/wordcell.json +51 -0
- package/src/icons/shared/agent-skill.svg +4 -0
- package/src/icons/shared/cli.svg +5 -0
- package/src/icons/shared/privacy.svg +3 -0
- package/src/icons/shared/research.svg +7 -0
- package/src/icons/shared/sdk.svg +3 -0
- package/src/icons/slopcamera/diagram.svg +3 -0
- package/src/icons/slopcamera/direct-scene.svg +5 -0
- package/src/icons/slopcamera/edit-video.svg +17 -0
- package/src/icons/slopcamera/mcp.svg +3 -0
- package/src/icons/slopcamera/native-world.svg +3 -0
- package/src/icons/slopcamera/slopcamera.svg +11 -0
- package/src/icons/soundfish/agent-ui.svg +7 -0
- package/src/icons/soundfish/collaborate.svg +5 -0
- package/src/icons/soundfish/compose.svg +3 -0
- package/src/icons/soundfish/hear.svg +6 -0
- package/src/icons/soundfish/human-ui.svg +6 -0
- package/src/icons/soundfish/share.svg +3 -0
- package/src/icons/soundfish/soundfish.svg +6 -0
- package/src/icons/sponge/api.svg +3 -0
- package/src/icons/sponge/documents.svg +3 -0
- package/src/icons/sponge/library.svg +3 -0
- package/src/icons/sponge/one-question.svg +5 -0
- package/src/icons/sponge/people-direct.svg +5 -0
- package/src/icons/sponge/read.svg +3 -0
- package/src/icons/sponge/save.svg +4 -0
- package/src/icons/sponge/sponge.svg +7 -0
- package/src/icons/stripe-history/company-history.svg +3 -0
- package/src/icons/stripe-history/evidence.svg +4 -0
- package/src/icons/stripe-history/independence.svg +7 -0
- package/src/icons/stripe-history/publications.svg +11 -0
- package/src/icons/stripe-history/sources.svg +6 -0
- package/src/icons/stripe-history/stripe-history.svg +3 -0
- package/src/icons/wordcell/backlinks.svg +6 -0
- package/src/icons/wordcell/capture.svg +6 -0
- package/src/icons/wordcell/git-provenance.svg +4 -0
- package/src/icons/wordcell/kb.svg +4 -0
- package/src/icons/wordcell/markdown.svg +4 -0
- package/src/icons/wordcell/scopes.svg +3 -0
- package/src/icons/wordcell/search.svg +7 -0
- package/src/icons.generated.ts +734 -0
- package/src/icons.ts +51 -0
- package/src/index.ts +275 -0
- package/src/lantern-material.css +213 -0
- package/src/launch.ts +624 -0
- package/src/marketing-assets/UPSTREAM.md +18 -0
- package/src/marketing-assets/cells.svg +1 -0
- package/src/marketing-assets/grain.svg +1 -0
- package/src/marketing-forced-colors.css +12 -0
- package/src/mockups/client.tsx +415 -0
- package/src/mockups/core.tsx +318 -0
- package/src/mockups/frames.tsx +368 -0
- package/src/mockups/index.ts +14 -0
- package/src/mockups/surfaces.tsx +681 -0
- package/src/mockups.css +2607 -0
- package/src/palette-appearance.ts +45 -0
- package/src/palette-bridge.css +124 -0
- package/src/palette-color.ts +41 -0
- package/src/palette-system.css +721 -0
- package/src/palette-themes.ts +23 -0
- package/src/palette-tokens.stylex.ts +519 -0
- package/src/palettes.css +3 -0
- package/src/palettes.ts +161 -0
- package/src/paper-theme.css +244 -0
- package/src/plain-publication.css +1241 -0
- package/src/plain-site.css +397 -0
- package/src/platforms.ts +151 -0
- package/src/portfolio.generated.json +1595 -0
- package/src/portfolio.generated.ts +1596 -0
- package/src/portfolio.ts +226 -0
- package/src/product-marketing-foundation.css +433 -0
- package/src/product-marketing-preset.css +261 -0
- package/src/product-marketing.css +2186 -0
- package/src/provider-marks.generated.ts +112 -0
- package/src/provider-marks.ts +143 -0
- package/src/react/animated-rail-stage.stylex.ts +15 -0
- package/src/react/animated-rail-stage.tsx +51 -0
- package/src/react/app-shell.stylex.ts +104 -0
- package/src/react/app-shell.tsx +121 -0
- package/src/react/article.tsx +602 -0
- package/src/react/aurora-dots-background.tsx +34 -0
- package/src/react/charts.stylex.ts +563 -0
- package/src/react/charts.tsx +610 -0
- package/src/react/chat.stylex.ts +34 -0
- package/src/react/chat.tsx +155 -0
- package/src/react/design-gallery.tsx +943 -0
- package/src/react/design-palette.stylex.ts +124 -0
- package/src/react/design-palette.tsx +196 -0
- package/src/react/design-theme-context.tsx +41 -0
- package/src/react/effects.stylex.ts +325 -0
- package/src/react/fader.stylex.ts +71 -0
- package/src/react/fader.tsx +151 -0
- package/src/react/foil-card-math.ts +100 -0
- package/src/react/foil-card-surface.stylex.ts +326 -0
- package/src/react/foil-card-surface.tsx +742 -0
- package/src/react/foil-mark.tsx +35 -0
- package/src/react/foil.stylex.ts +163 -0
- package/src/react/haptics.ts +228 -0
- package/src/react/hero-backdrop.tsx +22 -0
- package/src/react/index.ts +38 -0
- package/src/react/keyboard-shortcuts.ts +228 -0
- package/src/react/lantern-material-gallery.tsx +166 -0
- package/src/react/lantern-material.stylex.ts +39 -0
- package/src/react/launch-beats.tsx +73 -0
- package/src/react/navigation-rail.stylex.ts +130 -0
- package/src/react/navigation-rail.tsx +228 -0
- package/src/react/particle-halo.tsx +115 -0
- package/src/react/phaser-dots.tsx +501 -0
- package/src/react/platform-icons.tsx +94 -0
- package/src/react/platform-install.stylex.ts +385 -0
- package/src/react/platform-install.tsx +389 -0
- package/src/react/playback-transport.stylex.ts +14 -0
- package/src/react/playback-transport.tsx +112 -0
- package/src/react/procedural-backdrop.tsx +192 -0
- package/src/react/procedural-recipe.ts +323 -0
- package/src/react/product-marketing.stylex.ts +4469 -0
- package/src/react/product-marketing.tsx +1611 -0
- package/src/react/production-data-preview-notice.stylex.ts +40 -0
- package/src/react/production-data-preview-notice.tsx +34 -0
- package/src/react/provider-mark.stylex.ts +132 -0
- package/src/react/provider-mark.tsx +120 -0
- package/src/react/relative-time.tsx +117 -0
- package/src/react/route-state.stylex.ts +39 -0
- package/src/react/route-state.tsx +350 -0
- package/src/react/server.ts +11 -0
- package/src/react/social-kit-panel.tsx +103 -0
- package/src/react/sticky-offset.tsx +33 -0
- package/src/react/surfaces.stylex.ts +179 -0
- package/src/react/surfaces.tsx +330 -0
- package/src/react/syntax-code.tsx +33 -0
- package/src/react/theme-color-sync.ts +1 -0
- package/src/react/theme-resolution.ts +11 -0
- package/src/react/theme.stylex.ts +276 -0
- package/src/react/theme.tsx +402 -0
- package/src/reading.css +72 -0
- package/src/relative-time.ts +185 -0
- package/src/reset.css +2 -0
- package/src/site-shell.css +16 -0
- package/src/status-page-html.ts +63 -0
- package/src/status-page.css +276 -0
- package/src/status-page.ts +323 -0
- package/src/styles.css +21 -0
- package/src/syntax-code-lexers.ts +303 -0
- package/src/syntax-highlighting.css +78 -0
- package/src/syntax-highlighting.ts +477 -0
- package/src/syntax-tokens.ts +33 -0
- package/src/testing.ts +307 -0
- package/src/tokens.css +3 -0
- package/src/typography.css +86 -0
- package/vendor/evilcharts/LICENSE +21 -0
- package/vendor/evilcharts/UPSTREAM.md +14 -0
- package/vendor/platform-marks/LICENSE +30 -0
- package/vendor/platform-marks/UPSTREAM.md +15 -0
- package/vendor/platform-marks/apple.svg +1 -0
- package/vendor/platform-marks/linux.svg +1 -0
- package/vendor/provider-marks/CRUSH-LICENSE.md +134 -0
- package/vendor/provider-marks/LICENSE +21 -0
- package/vendor/provider-marks/UPSTREAM.md +27 -0
- package/vendor/provider-marks/aider.svg +3 -0
- package/vendor/provider-marks/crush-heartbit.svg +8 -0
- package/vendor/provider-marks/lobehub/alibabacloud-color.svg +1 -0
- package/vendor/provider-marks/lobehub/alibabacloud.svg +1 -0
- package/vendor/provider-marks/lobehub/amp-color.svg +1 -0
- package/vendor/provider-marks/lobehub/amp.svg +1 -0
- package/vendor/provider-marks/lobehub/anthropic.svg +1 -0
- package/vendor/provider-marks/lobehub/claude-color.svg +1 -0
- package/vendor/provider-marks/lobehub/claude.svg +1 -0
- package/vendor/provider-marks/lobehub/claudecode-color.svg +1 -0
- package/vendor/provider-marks/lobehub/claudecode.svg +1 -0
- package/vendor/provider-marks/lobehub/codex-color.svg +1 -0
- package/vendor/provider-marks/lobehub/codex.svg +1 -0
- package/vendor/provider-marks/lobehub/cursor.svg +1 -0
- package/vendor/provider-marks/lobehub/deepseek-color.svg +1 -0
- package/vendor/provider-marks/lobehub/deepseek.svg +1 -0
- package/vendor/provider-marks/lobehub/devin-color.svg +1 -0
- package/vendor/provider-marks/lobehub/devin.svg +1 -0
- package/vendor/provider-marks/lobehub/gemini-color.svg +1 -0
- package/vendor/provider-marks/lobehub/gemini.svg +1 -0
- package/vendor/provider-marks/lobehub/geminicli-color.svg +1 -0
- package/vendor/provider-marks/lobehub/geminicli.svg +1 -0
- package/vendor/provider-marks/lobehub/goose.svg +1 -0
- package/vendor/provider-marks/lobehub/meta-color.svg +1 -0
- package/vendor/provider-marks/lobehub/meta.svg +1 -0
- package/vendor/provider-marks/lobehub/mistral-color.svg +1 -0
- package/vendor/provider-marks/lobehub/mistral.svg +1 -0
- package/vendor/provider-marks/lobehub/moonshot.svg +1 -0
- package/vendor/provider-marks/lobehub/nvidia-color.svg +1 -0
- package/vendor/provider-marks/lobehub/nvidia.svg +1 -0
- package/vendor/provider-marks/lobehub/openai.svg +1 -0
- package/vendor/provider-marks/lobehub/opencode.svg +1 -0
- package/vendor/provider-marks/lobehub/perplexity-color.svg +1 -0
- package/vendor/provider-marks/lobehub/perplexity.svg +1 -0
- package/vendor/provider-marks/lobehub/qwen-color.svg +1 -0
- package/vendor/provider-marks/lobehub/qwen.svg +1 -0
- package/vendor/provider-marks/lobehub/xai.svg +1 -0
- package/vendor/provider-marks/lobehub/zai.svg +1 -0
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
import { Fragment, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Avatar,
|
|
5
|
+
compact,
|
|
6
|
+
MockupGlyph,
|
|
7
|
+
MockupRoot,
|
|
8
|
+
PlaceholderPhoto,
|
|
9
|
+
SampleParagraphs,
|
|
10
|
+
SampleText,
|
|
11
|
+
type MockupGlyphName,
|
|
12
|
+
type MockupOptOut,
|
|
13
|
+
type MockupRootProps,
|
|
14
|
+
} from "./core.js";
|
|
15
|
+
import { BrowserWindow, PhoneShell, phoneStyle } from "./frames.js";
|
|
16
|
+
|
|
17
|
+
function sample(optOut: MockupOptOut | undefined): { optOut?: MockupOptOut } {
|
|
18
|
+
return optOut === undefined ? {} : { optOut };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A compact count with its noun, singular only for exactly one. */
|
|
22
|
+
function counted(value: number, noun: string): string {
|
|
23
|
+
return `${compact(value)} ${value === 1 ? noun : `${noun}s`}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function assertUniqueKeys(keys: readonly string[], component: string): void {
|
|
27
|
+
const seen = new Set<string>();
|
|
28
|
+
for (const key of keys) {
|
|
29
|
+
if (seen.has(key)) throw new RangeError(`${component} keys must be unique: ${key}`);
|
|
30
|
+
seen.add(key);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* ------------------------------------------------------------------ */
|
|
35
|
+
/* Chat thread */
|
|
36
|
+
/* ------------------------------------------------------------------ */
|
|
37
|
+
|
|
38
|
+
/** One message in a chat thread. `me` sits on the right; `them` and `agent` on the left. */
|
|
39
|
+
export type ChatMessage = Readonly<{
|
|
40
|
+
from: "me" | "them" | "agent";
|
|
41
|
+
text: string;
|
|
42
|
+
/** A day or time divider shown above this message, such as "Today 9:14". */
|
|
43
|
+
time?: string;
|
|
44
|
+
/** A small label under the bubble, such as "Sent by the assistant". */
|
|
45
|
+
label?: string;
|
|
46
|
+
id?: string;
|
|
47
|
+
}>;
|
|
48
|
+
|
|
49
|
+
/** Whether a sender's message sits on the right, from this device's point of view. */
|
|
50
|
+
export function chatSide(from: ChatMessage["from"]): "in" | "out" {
|
|
51
|
+
return from === "me" ? "out" : "in";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The bubble tail: a concave hook off the bottom corner, drawn for the right side and mirrored on the left. */
|
|
55
|
+
const TAIL_PATH =
|
|
56
|
+
"M-16 -17.5H0C0 -7.6 1.7 -2.4 6.4 -0.45 6.95 -0.2 6.85 0.45 6.2 0.5 2.6 0.7 -1.2 -0.6 -3.4 -2.5 -4.3 -3.3 -5.2 -4.1 -6 -4.9L-16 -17.5Z";
|
|
57
|
+
|
|
58
|
+
/** One to three emoji and no text render large and without a bubble. */
|
|
59
|
+
export function isEmojiOnly(text: string): boolean {
|
|
60
|
+
const trimmed = text.trim();
|
|
61
|
+
if (trimmed === "") return false;
|
|
62
|
+
if (!/^(?:[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\u{2300}-\u{23FF}\u{1F1E6}-\u{1F1FF}\u{E0020}-\u{E007F}©®‼⁉™ℹ]||️|⃣|\s)+$/u.test(trimmed)) return false;
|
|
63
|
+
if (/[0-9#*]/u.test(trimmed)) return false;
|
|
64
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
65
|
+
return [...segmenter.segment(trimmed.replace(/\s+/gu, ""))].length <= 3;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A messaging conversation on a phone. `variant="bubbles"` draws blue bubbles
|
|
70
|
+
* with bottom tails on a plain screen; `variant="chat"` draws green bubbles on a
|
|
71
|
+
* wallpaper. Neither is any one app. `visibleCount` and `typing` let a player
|
|
72
|
+
* or film step the conversation in; the static render shows it finished.
|
|
73
|
+
*/
|
|
74
|
+
export function ChatThread({
|
|
75
|
+
contact,
|
|
76
|
+
device = "phone",
|
|
77
|
+
messages,
|
|
78
|
+
screenHeight,
|
|
79
|
+
statusTime,
|
|
80
|
+
typing = false,
|
|
81
|
+
variant = "bubbles",
|
|
82
|
+
via,
|
|
83
|
+
visibleCount,
|
|
84
|
+
width,
|
|
85
|
+
...root
|
|
86
|
+
}: MockupRootProps &
|
|
87
|
+
Readonly<{
|
|
88
|
+
messages: readonly ChatMessage[];
|
|
89
|
+
/** Name in the header. */
|
|
90
|
+
contact?: string;
|
|
91
|
+
/** A line under the contact name, such as "via a bridge". */
|
|
92
|
+
via?: string;
|
|
93
|
+
/** Show typing dots from the other side after the visible messages. */
|
|
94
|
+
typing?: boolean;
|
|
95
|
+
variant?: "bubbles" | "chat";
|
|
96
|
+
visibleCount?: number;
|
|
97
|
+
device?: "phone" | "none";
|
|
98
|
+
width?: number;
|
|
99
|
+
screenHeight?: number;
|
|
100
|
+
statusTime?: string;
|
|
101
|
+
}>) {
|
|
102
|
+
if (variant !== "bubbles" && variant !== "chat") throw new TypeError(`Unknown chat variant ${JSON.stringify(variant)}.`);
|
|
103
|
+
const count = visibleCount ?? messages.length;
|
|
104
|
+
if (!Number.isInteger(count) || count < 0 || count > messages.length) throw new RangeError("ChatThread visibleCount must be 0 to messages.length.");
|
|
105
|
+
const shown = messages.slice(0, count);
|
|
106
|
+
const sides = shown.map((message) => chatSide(message.from));
|
|
107
|
+
const lastOut = sides.lastIndexOf("out") === sides.length - 1 && !typing ? sides.length - 1 : -1;
|
|
108
|
+
const screen = (
|
|
109
|
+
<div className="hkm-chat" data-hkm-chat={variant} data-hkm-via={via === undefined ? undefined : ""}>
|
|
110
|
+
<div className="hkm-chat-thread">
|
|
111
|
+
<div className="hkm-chat-inner">
|
|
112
|
+
{shown.map((message, index) => {
|
|
113
|
+
const side = sides[index] ?? "in";
|
|
114
|
+
const first = index === 0 || sides[index - 1] !== side || message.time !== undefined;
|
|
115
|
+
const next = shown[index + 1];
|
|
116
|
+
const last = index === shown.length - 1 || sides[index + 1] !== side || next?.time !== undefined;
|
|
117
|
+
const emoji = isEmojiOnly(message.text);
|
|
118
|
+
const tail = variant === "chat" ? (first ? "top" : undefined) : last && !emoji ? "bottom" : undefined;
|
|
119
|
+
return (
|
|
120
|
+
<Fragment key={message.id ?? index}>
|
|
121
|
+
{message.time === undefined ? null : (
|
|
122
|
+
<div className="hkm-chat-divider">
|
|
123
|
+
<SampleText {...sample(root.optOut)}>{message.time}</SampleText>
|
|
124
|
+
</div>
|
|
125
|
+
)}
|
|
126
|
+
<div className="hkm-chat-row" data-hkm-first={first ? "" : undefined} data-hkm-from={message.from} data-hkm-last={last ? "" : undefined} data-hkm-side={side}>
|
|
127
|
+
<div className="hkm-bubble" data-hkm-emoji={emoji ? "" : undefined} data-hkm-tail={tail}>
|
|
128
|
+
<SampleText {...sample(root.optOut)}>{message.text}</SampleText>
|
|
129
|
+
{tail === "bottom" ? (
|
|
130
|
+
<svg aria-hidden="true" className="hkm-bubble-tail" focusable="false" viewBox="-16 -17.5 23 18">
|
|
131
|
+
<path d={TAIL_PATH} />
|
|
132
|
+
</svg>
|
|
133
|
+
) : null}
|
|
134
|
+
</div>
|
|
135
|
+
{message.label === undefined ? null : <span className="hkm-chat-label">{message.label}</span>}
|
|
136
|
+
</div>
|
|
137
|
+
{index === lastOut && variant === "bubbles" ? <div className="hkm-chat-receipt">Delivered</div> : null}
|
|
138
|
+
</Fragment>
|
|
139
|
+
);
|
|
140
|
+
})}
|
|
141
|
+
{typing ? (
|
|
142
|
+
<div className="hkm-chat-row" data-hkm-first="" data-hkm-last="" data-hkm-side="in">
|
|
143
|
+
<TypingIndicator />
|
|
144
|
+
</div>
|
|
145
|
+
) : null}
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
<div aria-hidden="true" className="hkm-chat-nav">
|
|
149
|
+
<span className="hkm-chat-back"><MockupGlyph name="back" size={22} /></span>
|
|
150
|
+
<span className="hkm-chat-contact">
|
|
151
|
+
<Avatar name={contact ?? "Contact"} size={variant === "chat" ? 32 : 44} />
|
|
152
|
+
<span className="hkm-chat-name">{contact ?? "Contact"}</span>
|
|
153
|
+
{via === undefined ? null : <span className="hkm-chat-via">{via}</span>}
|
|
154
|
+
</span>
|
|
155
|
+
<span className="hkm-chat-call"><MockupGlyph name="comment" size={18} /></span>
|
|
156
|
+
</div>
|
|
157
|
+
<div aria-hidden="true" className="hkm-chat-composer">
|
|
158
|
+
<span className="hkm-chat-plus"><MockupGlyph name="plus" size={16} /></span>
|
|
159
|
+
<span className="hkm-chat-field">Message</span>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
return (
|
|
164
|
+
<MockupRoot {...root} kind="chat" style={device === "phone" ? phoneStyle(width, screenHeight) : undefined}>
|
|
165
|
+
{device === "phone" ? <PhoneShell {...(statusTime === undefined ? {} : { statusTime })}>{screen}</PhoneShell> : <div className="hkm-chat-bare">{screen}</div>}
|
|
166
|
+
</MockupRoot>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Three pulsing dots in a bubble. Only people type; never show it before an automated message. */
|
|
171
|
+
export function TypingIndicator() {
|
|
172
|
+
return (
|
|
173
|
+
<span aria-hidden="true" className="hkm-typing">
|
|
174
|
+
<i />
|
|
175
|
+
<i />
|
|
176
|
+
<i />
|
|
177
|
+
</span>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/* ------------------------------------------------------------------ */
|
|
182
|
+
/* Feeds */
|
|
183
|
+
/* ------------------------------------------------------------------ */
|
|
184
|
+
|
|
185
|
+
type FeedShellProps<T> = MockupRootProps &
|
|
186
|
+
Readonly<{
|
|
187
|
+
posts: readonly T[];
|
|
188
|
+
renderPost: (post: T, index: number) => ReactNode;
|
|
189
|
+
/** Stable key per post. Defaults to the index. */
|
|
190
|
+
postKey?: (post: T, index: number) => string;
|
|
191
|
+
url?: string;
|
|
192
|
+
/** Cap the page area and fade it, like a window onto a longer page. */
|
|
193
|
+
height?: number;
|
|
194
|
+
/** Name of the signed-in sample person in the chrome. */
|
|
195
|
+
viewer?: string;
|
|
196
|
+
/** Content for the side rail; omit it for the default neutral rail. Hidden in narrow containers. */
|
|
197
|
+
aside?: ReactNode;
|
|
198
|
+
}>;
|
|
199
|
+
|
|
200
|
+
function keyed<T>(posts: readonly T[], postKey: ((post: T, index: number) => string) | undefined, component: string): string[] {
|
|
201
|
+
const keys = posts.map((post, index) => (postKey === undefined ? String(index) : postKey(post, index)));
|
|
202
|
+
assertUniqueKeys(keys, component);
|
|
203
|
+
return keys;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const SOCIAL_RAIL: readonly (readonly [MockupGlyphName, string])[] = [
|
|
207
|
+
["home", "Home"],
|
|
208
|
+
["search", "Explore"],
|
|
209
|
+
["bell", "Notifications"],
|
|
210
|
+
["mail", "Messages"],
|
|
211
|
+
["bookmark", "Saved"],
|
|
212
|
+
["user", "Profile"],
|
|
213
|
+
];
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* A neutral social timeline: a nav rail, a tabbed column of posts, and a side
|
|
217
|
+
* rail, inside a browser window. Each post comes from `renderPost`; use
|
|
218
|
+
* `SocialPost` for the default card. It copies no one network's logo or name.
|
|
219
|
+
*/
|
|
220
|
+
export function SocialFeed<T>({ aside, height, postKey, posts, renderPost, url = "social.example/home", viewer = "Alex Moreno", ...root }: FeedShellProps<T>) {
|
|
221
|
+
const keys = keyed(posts, postKey, "SocialFeed");
|
|
222
|
+
return (
|
|
223
|
+
<MockupRoot {...root} kind="social">
|
|
224
|
+
<BrowserWindow {...(height === undefined ? {} : { height })} url={url}>
|
|
225
|
+
<div className="hkm-social">
|
|
226
|
+
<div aria-hidden="true" className="hkm-social-rail">
|
|
227
|
+
<span className="hkm-social-mark"><MockupGlyph name="sparkle" size={22} /></span>
|
|
228
|
+
{SOCIAL_RAIL.map(([glyph, label], index) => (
|
|
229
|
+
<span className="hkm-rail-item" data-hkm-active={index === 0 ? "" : undefined} key={label}>
|
|
230
|
+
<MockupGlyph filled={index === 0} name={glyph} size={22} />
|
|
231
|
+
<span className="hkm-rail-label">{label}</span>
|
|
232
|
+
</span>
|
|
233
|
+
))}
|
|
234
|
+
<span className="hkm-rail-cta">
|
|
235
|
+
<span className="hkm-rail-label">Post</span>
|
|
236
|
+
<MockupGlyph className="hkm-rail-cta-glyph" name="pencil" size={18} />
|
|
237
|
+
</span>
|
|
238
|
+
<span className="hkm-rail-me">
|
|
239
|
+
<Avatar name={viewer} size={32} />
|
|
240
|
+
<span className="hkm-rail-label">{viewer}</span>
|
|
241
|
+
</span>
|
|
242
|
+
</div>
|
|
243
|
+
<div className="hkm-social-main">
|
|
244
|
+
<div aria-hidden="true" className="hkm-social-tabs">
|
|
245
|
+
<span data-hkm-active="">For you</span>
|
|
246
|
+
<span>Following</span>
|
|
247
|
+
</div>
|
|
248
|
+
<div aria-hidden="true" className="hkm-social-compose">
|
|
249
|
+
<Avatar name={viewer} size={36} />
|
|
250
|
+
<span className="hkm-social-compose-field">Share something</span>
|
|
251
|
+
<span className="hkm-social-compose-button">Post</span>
|
|
252
|
+
</div>
|
|
253
|
+
<div className="hkm-feed">
|
|
254
|
+
{posts.map((post, index) => (
|
|
255
|
+
<Fragment key={keys[index]}>{renderPost(post, index)}</Fragment>
|
|
256
|
+
))}
|
|
257
|
+
</div>
|
|
258
|
+
</div>
|
|
259
|
+
<div aria-hidden="true" className="hkm-social-aside">
|
|
260
|
+
{aside ?? (
|
|
261
|
+
<>
|
|
262
|
+
<span className="hkm-search-field"><MockupGlyph name="search" size={14} />Search</span>
|
|
263
|
+
<span className="hkm-card hkm-aside-card">
|
|
264
|
+
<span className="hkm-aside-title">Trending</span>
|
|
265
|
+
{["Night markets", "Rail timetables", "Sourdough"].map((topic) => (
|
|
266
|
+
<span className="hkm-aside-line" key={topic}>{topic}</span>
|
|
267
|
+
))}
|
|
268
|
+
</span>
|
|
269
|
+
</>
|
|
270
|
+
)}
|
|
271
|
+
</div>
|
|
272
|
+
</div>
|
|
273
|
+
</BrowserWindow>
|
|
274
|
+
</MockupRoot>
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Data attributes a skin sets on a post's unit, such as its own flag state. */
|
|
279
|
+
export type MockupUnitAttributes = Readonly<Record<`data-${string}`, string | undefined>>;
|
|
280
|
+
|
|
281
|
+
/** The default social post card. `attributes` and `overlay` let a skin mark the unit without forking it. */
|
|
282
|
+
export function SocialPost({
|
|
283
|
+
attributes,
|
|
284
|
+
counts,
|
|
285
|
+
handle,
|
|
286
|
+
name,
|
|
287
|
+
optOut,
|
|
288
|
+
overlay,
|
|
289
|
+
photo,
|
|
290
|
+
text,
|
|
291
|
+
time,
|
|
292
|
+
}: Readonly<{
|
|
293
|
+
name: string;
|
|
294
|
+
handle: string;
|
|
295
|
+
time: string;
|
|
296
|
+
text: string;
|
|
297
|
+
photo?: string;
|
|
298
|
+
counts?: readonly [replies: number, reposts: number, likes: number, views: number];
|
|
299
|
+
attributes?: MockupUnitAttributes;
|
|
300
|
+
overlay?: ReactNode;
|
|
301
|
+
optOut?: MockupOptOut;
|
|
302
|
+
}>) {
|
|
303
|
+
return (
|
|
304
|
+
<div {...attributes} className="hkm-unit hkm-post">
|
|
305
|
+
{overlay}
|
|
306
|
+
<Avatar name={name} size={40} />
|
|
307
|
+
<div className="hkm-post-body">
|
|
308
|
+
<div className="hkm-post-head">
|
|
309
|
+
<b><SampleText {...sample(optOut)}>{name}</SampleText></b>
|
|
310
|
+
<span className="hkm-muted"><SampleText {...sample(optOut)}>@{handle} · {time}</SampleText></span>
|
|
311
|
+
<span aria-hidden="true" className="hkm-muted hkm-post-more"><MockupGlyph name="more" size={18} /></span>
|
|
312
|
+
</div>
|
|
313
|
+
<div className="hkm-post-text"><SampleParagraphs {...sample(optOut)} text={text} /></div>
|
|
314
|
+
{photo === undefined ? null : <PlaceholderPhoto className="hkm-post-photo" seed={photo} />}
|
|
315
|
+
{counts === undefined ? null : (
|
|
316
|
+
<div aria-hidden="true" className="hkm-post-actions hkm-muted">
|
|
317
|
+
<span><MockupGlyph name="reply" size={17} />{compact(counts[0])}</span>
|
|
318
|
+
<span><MockupGlyph name="repost" size={17} />{compact(counts[1])}</span>
|
|
319
|
+
<span><MockupGlyph name="heart" size={17} />{compact(counts[2])}</span>
|
|
320
|
+
<span><MockupGlyph name="chart" size={17} />{compact(counts[3])}</span>
|
|
321
|
+
<span className="hkm-post-actions-end"><MockupGlyph name="share" size={17} /></span>
|
|
322
|
+
</div>
|
|
323
|
+
)}
|
|
324
|
+
</div>
|
|
325
|
+
</div>
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* A neutral professional-network feed: a top bar, a profile card, a column of
|
|
331
|
+
* posts, and a news rail. Use `WorkPost` for the default card.
|
|
332
|
+
*/
|
|
333
|
+
export function WorkFeed<T>({ aside, height, postKey, posts, renderPost, url = "network.example/feed", viewer = "Alex Moreno", ...root }: FeedShellProps<T>) {
|
|
334
|
+
const keys = keyed(posts, postKey, "WorkFeed");
|
|
335
|
+
return (
|
|
336
|
+
<MockupRoot {...root} kind="work">
|
|
337
|
+
<BrowserWindow {...(height === undefined ? {} : { height })} url={url}>
|
|
338
|
+
<div aria-hidden="true" className="hkm-work-top">
|
|
339
|
+
<span className="hkm-work-mark"><MockupGlyph name="grid" size={18} /></span>
|
|
340
|
+
<span className="hkm-search-field"><MockupGlyph name="search" size={14} />Search</span>
|
|
341
|
+
<span className="hkm-work-nav">
|
|
342
|
+
{(["home", "people", "comment", "bell"] as const).map((glyph, index) => (
|
|
343
|
+
<span data-hkm-active={index === 0 ? "" : undefined} key={glyph}><MockupGlyph filled={index === 0} name={glyph} size={20} /></span>
|
|
344
|
+
))}
|
|
345
|
+
<Avatar name={viewer} size={22} />
|
|
346
|
+
</span>
|
|
347
|
+
</div>
|
|
348
|
+
<div className="hkm-work">
|
|
349
|
+
<div aria-hidden="true" className="hkm-work-left">
|
|
350
|
+
<span className="hkm-card hkm-work-profile">
|
|
351
|
+
<span className="hkm-work-cover" />
|
|
352
|
+
<Avatar name={viewer} size={56} />
|
|
353
|
+
<b>{viewer}</b>
|
|
354
|
+
<span className="hkm-muted">Product designer</span>
|
|
355
|
+
</span>
|
|
356
|
+
</div>
|
|
357
|
+
<div className="hkm-work-main">
|
|
358
|
+
<div aria-hidden="true" className="hkm-card hkm-work-compose">
|
|
359
|
+
<Avatar name={viewer} size={40} />
|
|
360
|
+
<span className="hkm-social-compose-field">Start a post</span>
|
|
361
|
+
</div>
|
|
362
|
+
{posts.map((post, index) => (
|
|
363
|
+
<Fragment key={keys[index]}>{renderPost(post, index)}</Fragment>
|
|
364
|
+
))}
|
|
365
|
+
</div>
|
|
366
|
+
<div aria-hidden="true" className="hkm-work-right">
|
|
367
|
+
{aside ?? (
|
|
368
|
+
<span className="hkm-card hkm-aside-card">
|
|
369
|
+
<span className="hkm-aside-title">Network news</span>
|
|
370
|
+
{["Teams try shorter weeks", "Bakeries hire for nights", "Fewer meetings, same output"].map((topic) => (
|
|
371
|
+
<span className="hkm-aside-line" key={topic}>{topic}</span>
|
|
372
|
+
))}
|
|
373
|
+
</span>
|
|
374
|
+
)}
|
|
375
|
+
</div>
|
|
376
|
+
</div>
|
|
377
|
+
</BrowserWindow>
|
|
378
|
+
</MockupRoot>
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** The default work-network post card, with an optional reply. */
|
|
383
|
+
export function WorkPost({
|
|
384
|
+
attributes,
|
|
385
|
+
comment,
|
|
386
|
+
headline,
|
|
387
|
+
name,
|
|
388
|
+
optOut,
|
|
389
|
+
overlay,
|
|
390
|
+
photo,
|
|
391
|
+
reactions,
|
|
392
|
+
text,
|
|
393
|
+
time,
|
|
394
|
+
}: Readonly<{
|
|
395
|
+
name: string;
|
|
396
|
+
headline: string;
|
|
397
|
+
time: string;
|
|
398
|
+
text: string;
|
|
399
|
+
photo?: string;
|
|
400
|
+
reactions?: readonly [reactions: number, comments: number, reposts: number];
|
|
401
|
+
comment?: Readonly<{ name: string; headline: string; text: string; attributes?: MockupUnitAttributes; overlay?: ReactNode }>;
|
|
402
|
+
attributes?: MockupUnitAttributes;
|
|
403
|
+
overlay?: ReactNode;
|
|
404
|
+
optOut?: MockupOptOut;
|
|
405
|
+
}>) {
|
|
406
|
+
return (
|
|
407
|
+
<div {...attributes} className="hkm-unit hkm-card hkm-work-post">
|
|
408
|
+
{overlay}
|
|
409
|
+
<div className="hkm-work-post-head">
|
|
410
|
+
<Avatar name={name} size={44} />
|
|
411
|
+
<span className="hkm-work-who">
|
|
412
|
+
<b><SampleText {...sample(optOut)}>{name}</SampleText></b>
|
|
413
|
+
<span className="hkm-muted"><SampleText {...sample(optOut)}>{headline}</SampleText></span>
|
|
414
|
+
<span className="hkm-muted">{time}</span>
|
|
415
|
+
</span>
|
|
416
|
+
<span aria-hidden="true" className="hkm-work-follow">+ Follow</span>
|
|
417
|
+
</div>
|
|
418
|
+
<div className="hkm-post-text"><SampleParagraphs {...sample(optOut)} text={text} /></div>
|
|
419
|
+
{photo === undefined ? null : <PlaceholderPhoto className="hkm-work-photo" ratio="1.91 / 1" seed={photo} />}
|
|
420
|
+
{reactions === undefined ? null : (
|
|
421
|
+
<div aria-hidden="true" className="hkm-work-counts hkm-muted">
|
|
422
|
+
<span>{counted(reactions[0], "reaction")}</span>
|
|
423
|
+
<span>{counted(reactions[1], "comment")} · {counted(reactions[2], "repost")}</span>
|
|
424
|
+
</div>
|
|
425
|
+
)}
|
|
426
|
+
<div aria-hidden="true" className="hkm-work-actions hkm-muted">
|
|
427
|
+
<span><MockupGlyph name="thumb" size={18} />Like</span>
|
|
428
|
+
<span><MockupGlyph name="comment" size={18} />Comment</span>
|
|
429
|
+
<span><MockupGlyph name="repost" size={18} />Repost</span>
|
|
430
|
+
<span><MockupGlyph name="send" size={18} />Send</span>
|
|
431
|
+
</div>
|
|
432
|
+
{comment === undefined ? null : (
|
|
433
|
+
<div className="hkm-work-comment">
|
|
434
|
+
<Avatar name={comment.name} size={30} />
|
|
435
|
+
<div {...comment.attributes} className="hkm-unit hkm-work-comment-box">
|
|
436
|
+
{comment.overlay}
|
|
437
|
+
<b><SampleText {...sample(optOut)}>{comment.name}</SampleText></b>
|
|
438
|
+
<span className="hkm-muted"><SampleText {...sample(optOut)}>{comment.headline}</SampleText></span>
|
|
439
|
+
<SampleParagraphs {...sample(optOut)} text={comment.text} />
|
|
440
|
+
</div>
|
|
441
|
+
</div>
|
|
442
|
+
)}
|
|
443
|
+
</div>
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/* ------------------------------------------------------------------ */
|
|
448
|
+
/* Inbox */
|
|
449
|
+
/* ------------------------------------------------------------------ */
|
|
450
|
+
|
|
451
|
+
const FOLDERS: readonly (readonly [MockupGlyphName, string])[] = [
|
|
452
|
+
["inbox", "Inbox"],
|
|
453
|
+
["star", "Starred"],
|
|
454
|
+
["clock", "Snoozed"],
|
|
455
|
+
["send", "Sent"],
|
|
456
|
+
["file", "Drafts"],
|
|
457
|
+
["archive", "Archive"],
|
|
458
|
+
];
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* A neutral mail client: folders, a message list, and an optional open
|
|
462
|
+
* message. Rows come from `renderRow` (use `InboxRow`); the open message from
|
|
463
|
+
* `renderMessage` (use `InboxMessage`).
|
|
464
|
+
*/
|
|
465
|
+
export function Inbox<T>({
|
|
466
|
+
height,
|
|
467
|
+
open,
|
|
468
|
+
renderMessage,
|
|
469
|
+
renderRow,
|
|
470
|
+
rowKey,
|
|
471
|
+
rows,
|
|
472
|
+
unread,
|
|
473
|
+
url = "mail.example/inbox",
|
|
474
|
+
...root
|
|
475
|
+
}: MockupRootProps &
|
|
476
|
+
Readonly<{
|
|
477
|
+
rows: readonly T[];
|
|
478
|
+
renderRow: (row: T, index: number) => ReactNode;
|
|
479
|
+
rowKey?: (row: T, index: number) => string;
|
|
480
|
+
open?: T;
|
|
481
|
+
renderMessage?: (row: T) => ReactNode;
|
|
482
|
+
/** Unread count by the Inbox folder. Invented; never a real metric. */
|
|
483
|
+
unread?: number;
|
|
484
|
+
url?: string;
|
|
485
|
+
height?: number;
|
|
486
|
+
}>) {
|
|
487
|
+
const keys = keyed(rows, rowKey, "Inbox");
|
|
488
|
+
if (open !== undefined && renderMessage === undefined) throw new TypeError("Inbox needs renderMessage when open is set.");
|
|
489
|
+
return (
|
|
490
|
+
<MockupRoot {...root} kind="inbox">
|
|
491
|
+
<BrowserWindow {...(height === undefined ? {} : { height })} url={url}>
|
|
492
|
+
<div className="hkm-inbox" data-hkm-open={open === undefined ? undefined : ""}>
|
|
493
|
+
<div aria-hidden="true" className="hkm-inbox-folders">
|
|
494
|
+
<span className="hkm-inbox-compose"><MockupGlyph name="pencil" size={16} />Compose</span>
|
|
495
|
+
{FOLDERS.map(([glyph, label], index) => (
|
|
496
|
+
<span className="hkm-rail-item" data-hkm-active={index === 0 ? "" : undefined} key={label}>
|
|
497
|
+
<MockupGlyph name={glyph} size={17} />
|
|
498
|
+
<span className="hkm-rail-label">{label}</span>
|
|
499
|
+
{index === 0 && unread !== undefined ? <span className="hkm-inbox-count">{compact(unread)}</span> : null}
|
|
500
|
+
</span>
|
|
501
|
+
))}
|
|
502
|
+
</div>
|
|
503
|
+
<div className="hkm-inbox-list">
|
|
504
|
+
<div aria-hidden="true" className="hkm-inbox-toolbar">
|
|
505
|
+
<span className="hkm-search-field"><MockupGlyph name="search" size={14} />Search mail</span>
|
|
506
|
+
</div>
|
|
507
|
+
<ul className="hkm-inbox-rows">
|
|
508
|
+
{rows.map((row, index) => (
|
|
509
|
+
<li key={keys[index]}>{renderRow(row, index)}</li>
|
|
510
|
+
))}
|
|
511
|
+
</ul>
|
|
512
|
+
</div>
|
|
513
|
+
{open === undefined || renderMessage === undefined ? null : <div className="hkm-inbox-reader">{renderMessage(open)}</div>}
|
|
514
|
+
</div>
|
|
515
|
+
</BrowserWindow>
|
|
516
|
+
</MockupRoot>
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** The default inbox row. */
|
|
521
|
+
export function InboxRow({
|
|
522
|
+
attributes,
|
|
523
|
+
from,
|
|
524
|
+
optOut,
|
|
525
|
+
overlay,
|
|
526
|
+
preview,
|
|
527
|
+
selected = false,
|
|
528
|
+
starred = false,
|
|
529
|
+
subject,
|
|
530
|
+
time,
|
|
531
|
+
unread = false,
|
|
532
|
+
}: Readonly<{
|
|
533
|
+
from: string;
|
|
534
|
+
subject: string;
|
|
535
|
+
preview: string;
|
|
536
|
+
time: string;
|
|
537
|
+
unread?: boolean;
|
|
538
|
+
starred?: boolean;
|
|
539
|
+
selected?: boolean;
|
|
540
|
+
attributes?: MockupUnitAttributes;
|
|
541
|
+
overlay?: ReactNode;
|
|
542
|
+
optOut?: MockupOptOut;
|
|
543
|
+
}>) {
|
|
544
|
+
return (
|
|
545
|
+
<div {...attributes} className="hkm-unit hkm-inbox-row" data-hkm-selected={selected ? "" : undefined} data-hkm-unread={unread ? "" : undefined}>
|
|
546
|
+
{overlay}
|
|
547
|
+
<span aria-hidden="true" className="hkm-inbox-star" data-hkm-on={starred ? "" : undefined}><MockupGlyph filled={starred} name="star" size={15} /></span>
|
|
548
|
+
<span className="hkm-inbox-from"><SampleText {...sample(optOut)}>{from}</SampleText></span>
|
|
549
|
+
<span className="hkm-inbox-line">
|
|
550
|
+
<span className="hkm-inbox-subject"><SampleText {...sample(optOut)}>{subject}</SampleText></span>
|
|
551
|
+
<span className="hkm-muted"> · <SampleText {...sample(optOut)}>{preview}</SampleText></span>
|
|
552
|
+
</span>
|
|
553
|
+
<span className="hkm-inbox-time hkm-muted">{time}</span>
|
|
554
|
+
</div>
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** The default open message in the reading pane. */
|
|
559
|
+
export function InboxMessage({
|
|
560
|
+
attributes,
|
|
561
|
+
body,
|
|
562
|
+
from,
|
|
563
|
+
optOut,
|
|
564
|
+
overlay,
|
|
565
|
+
subject,
|
|
566
|
+
time,
|
|
567
|
+
to = "me",
|
|
568
|
+
}: Readonly<{ from: string; subject: string; body: string; time: string; to?: string; attributes?: MockupUnitAttributes; overlay?: ReactNode; optOut?: MockupOptOut }>) {
|
|
569
|
+
return (
|
|
570
|
+
<div {...attributes} className="hkm-unit hkm-inbox-message">
|
|
571
|
+
{overlay}
|
|
572
|
+
<div className="hkm-inbox-message-subject"><SampleText {...sample(optOut)}>{subject}</SampleText></div>
|
|
573
|
+
<div className="hkm-inbox-message-head">
|
|
574
|
+
<Avatar name={from} size={36} />
|
|
575
|
+
<span className="hkm-inbox-message-who">
|
|
576
|
+
<b><SampleText {...sample(optOut)}>{from}</SampleText></b>
|
|
577
|
+
<span className="hkm-muted">to {to}</span>
|
|
578
|
+
</span>
|
|
579
|
+
<span className="hkm-muted">{time}</span>
|
|
580
|
+
</div>
|
|
581
|
+
<div className="hkm-inbox-message-body"><SampleParagraphs {...sample(optOut)} text={body} /></div>
|
|
582
|
+
</div>
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/* ------------------------------------------------------------------ */
|
|
587
|
+
/* Article */
|
|
588
|
+
/* ------------------------------------------------------------------ */
|
|
589
|
+
|
|
590
|
+
/** A reader comment under an article. */
|
|
591
|
+
export type ArticleComment = Readonly<{ name: string; time: string; text: string; attributes?: MockupUnitAttributes; overlay?: ReactNode }>;
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* A neutral news or blog article: masthead, kicker, title, dek, byline, a
|
|
595
|
+
* lead picture, paragraphs, and optional comments. The title is styled text,
|
|
596
|
+
* not a heading, so the host page's outline stays its own. `decorate` wraps
|
|
597
|
+
* each paragraph so a skin can mark it.
|
|
598
|
+
*/
|
|
599
|
+
export function ArticlePage({
|
|
600
|
+
byline,
|
|
601
|
+
comments,
|
|
602
|
+
date,
|
|
603
|
+
decorate,
|
|
604
|
+
dek,
|
|
605
|
+
height,
|
|
606
|
+
kicker,
|
|
607
|
+
paragraphs,
|
|
608
|
+
photo,
|
|
609
|
+
site = "The Daily Example",
|
|
610
|
+
title,
|
|
611
|
+
url = "news.example/story",
|
|
612
|
+
...root
|
|
613
|
+
}: MockupRootProps &
|
|
614
|
+
Readonly<{
|
|
615
|
+
title: string;
|
|
616
|
+
/** The author's name. The page adds "By", so pass "Mira Okafor", not "By Mira Okafor". */
|
|
617
|
+
byline: string;
|
|
618
|
+
paragraphs: readonly string[];
|
|
619
|
+
dek?: string;
|
|
620
|
+
kicker?: string;
|
|
621
|
+
date?: string;
|
|
622
|
+
site?: string;
|
|
623
|
+
photo?: string;
|
|
624
|
+
comments?: readonly ArticleComment[];
|
|
625
|
+
decorate?: (paragraph: ReactNode, index: number) => ReactNode;
|
|
626
|
+
url?: string;
|
|
627
|
+
height?: number;
|
|
628
|
+
}>) {
|
|
629
|
+
if (/^by\s/iu.test(byline.trim())) throw new RangeError('ArticlePage byline is the author\'s name; the page adds "By".');
|
|
630
|
+
const opt = sample(root.optOut);
|
|
631
|
+
return (
|
|
632
|
+
<MockupRoot {...root} kind="article">
|
|
633
|
+
<BrowserWindow {...(height === undefined ? {} : { height })} url={url}>
|
|
634
|
+
<div className="hkm-article">
|
|
635
|
+
<div aria-hidden="true" className="hkm-article-masthead">
|
|
636
|
+
<MockupGlyph name="menu" size={18} />
|
|
637
|
+
<span className="hkm-article-site">{site}</span>
|
|
638
|
+
<MockupGlyph name="search" size={18} />
|
|
639
|
+
</div>
|
|
640
|
+
<div className="hkm-article-body">
|
|
641
|
+
{kicker === undefined ? null : <div className="hkm-article-kicker">{kicker}</div>}
|
|
642
|
+
<div className="hkm-article-title"><SampleText {...opt}>{title}</SampleText></div>
|
|
643
|
+
{dek === undefined ? null : <p className="hkm-article-dek"><SampleText {...opt}>{dek}</SampleText></p>}
|
|
644
|
+
<div className="hkm-article-byline">
|
|
645
|
+
<Avatar name={byline} size={28} />
|
|
646
|
+
<span>By <SampleText {...opt}>{byline}</SampleText></span>
|
|
647
|
+
{date === undefined ? null : <span className="hkm-muted">{date}</span>}
|
|
648
|
+
</div>
|
|
649
|
+
{photo === undefined ? null : <PlaceholderPhoto className="hkm-article-photo" ratio="3 / 2" seed={photo} />}
|
|
650
|
+
<div className="hkm-article-text">
|
|
651
|
+
{paragraphs.map((paragraph, index) => {
|
|
652
|
+
const node = (
|
|
653
|
+
<p className="hkm-unit" key={index}>
|
|
654
|
+
<SampleText {...opt}>{paragraph}</SampleText>
|
|
655
|
+
</p>
|
|
656
|
+
);
|
|
657
|
+
return decorate === undefined ? node : <Fragment key={index}>{decorate(node, index)}</Fragment>;
|
|
658
|
+
})}
|
|
659
|
+
</div>
|
|
660
|
+
{comments === undefined || comments.length === 0 ? null : (
|
|
661
|
+
<div className="hkm-article-comments">
|
|
662
|
+
<div className="hkm-article-comments-title">{counted(comments.length, "comment")}</div>
|
|
663
|
+
{comments.map((comment, index) => (
|
|
664
|
+
<div key={index} {...comment.attributes} className="hkm-unit hkm-article-comment">
|
|
665
|
+
{comment.overlay}
|
|
666
|
+
<Avatar name={comment.name} size={30} />
|
|
667
|
+
<span className="hkm-article-comment-body">
|
|
668
|
+
<b><SampleText {...opt}>{comment.name}</SampleText></b>
|
|
669
|
+
<span className="hkm-muted"> {comment.time}</span>
|
|
670
|
+
<SampleParagraphs {...opt} text={comment.text} />
|
|
671
|
+
</span>
|
|
672
|
+
</div>
|
|
673
|
+
))}
|
|
674
|
+
</div>
|
|
675
|
+
)}
|
|
676
|
+
</div>
|
|
677
|
+
</div>
|
|
678
|
+
</BrowserWindow>
|
|
679
|
+
</MockupRoot>
|
|
680
|
+
);
|
|
681
|
+
}
|