diff --git a/src/design-system/BUILD.bazel b/src/design-system/BUILD.bazel
new file mode 100644
index 0000000..bfd29b8
--- /dev/null
+++ b/src/design-system/BUILD.bazel
@@ -0,0 +1,8 @@
+load("@npm//@bazel/typescript:index.bzl", "ts_library")
+
+package(default_visibility = ["//visibility:public"])
+
+ts_library(
+ name = "lib",
+ srcs = glob(["*.ts"]),
+)
diff --git a/src/design-system/molecules/demo-discord/DemoDiscord.story.tsx b/src/design-system/molecules/demo-discord/DemoDiscord.story.tsx
new file mode 100644
index 0000000..601267d
--- /dev/null
+++ b/src/design-system/molecules/demo-discord/DemoDiscord.story.tsx
@@ -0,0 +1,7 @@
+import { moleculeStories } from 'molecules/molecules.story';
+import * as React from 'react';
+import { DemoDiscord } from './DemoDiscord';
+
+const story = moleculeStories('Landing Demos', module);
+
+story.add('Discord', () => );
diff --git a/src/design-system/molecules/demo-discord/DemoDiscord.styled.ts b/src/design-system/molecules/demo-discord/DemoDiscord.styled.ts
new file mode 100644
index 0000000..b0c2c72
--- /dev/null
+++ b/src/design-system/molecules/demo-discord/DemoDiscord.styled.ts
@@ -0,0 +1,67 @@
+import styled, { keyframes } from 'styled-components';
+import { palette } from 'atoms/colors';
+
+export const Base = styled.div`
+ background-color: ${palette.discord100};
+ border: solid 1px rgba(0, 0, 0, 0.15);
+ border-radius: 3px;
+ padding: 10px;
+ user-select: none;
+`;
+
+export const Timestamp = styled.span`
+ padding: 0 5px;
+ font-size: 0.7em;
+ opacity: 0.3;
+`;
+
+export const TextParts = styled.span`
+ padding: 0 5px;
+`;
+
+export const Username = styled(TextParts)`
+ &:hover {
+ text-decoration: underline;
+ cursor: pointer;
+ }
+`;
+
+export const InputBox = styled.div`
+ margin-top: 10px;
+ background-color: ${palette.discord200};
+ padding: 7px 10px;
+ border-radius: 3px;
+`;
+
+const lineBlink = keyframes`
+ 0% {
+ opacity: 1;
+ }
+
+ 40% {
+ opacity: 1;
+ }
+
+ 60% {
+ opacity: 0;
+ }
+
+ 100% {
+ opacity: 0;
+ }
+`;
+
+export const Line = styled.div`
+ background-color: ${palette.grey600};
+ width: 1px;
+ height: 1.5em;
+ display: inline-block;
+ position: absolute;
+ right: -5px;
+ animation: ${lineBlink} 0.5s ease-in-out infinite alternate-reverse;
+`;
+
+export const InputTextAlignment = styled.div`
+ position: relative;
+ display: inline-block;
+`;
diff --git a/src/design-system/molecules/demo-discord/DemoDiscord.tsx b/src/design-system/molecules/demo-discord/DemoDiscord.tsx
new file mode 100644
index 0000000..5b207c3
--- /dev/null
+++ b/src/design-system/molecules/demo-discord/DemoDiscord.tsx
@@ -0,0 +1,53 @@
+import * as React from 'react';
+import {
+ Base,
+ Timestamp,
+ TextParts,
+ Username,
+ InputBox,
+ Line,
+ InputTextAlignment,
+} from './DemoDiscord.styled';
+import { demoData } from 'hack/fixtures/demoData';
+import { Typist } from 'atoms/typist';
+
+export const DemoDiscord = () => {
+ const time = new Date();
+ const timeString = time.toTimeString();
+
+ const [easterEggCount, setEasterEggCount] = React.useState(0);
+
+ return (
+
+
+ {time.getHours() % 12}:{timeString.slice(3, 5)}
+ {time.getHours() <= 12 ? 'AM' : 'PM'}
+
+ setEasterEggCount(easterEggCount + 1)}>
+ okano cat
+
+
+ {easterEggCount >= 15
+ ? `NYAAAAAAA${'A'.repeat(easterEggCount - 15)}`
+ : easterEggCount >= 11
+ ? `I'm.. I'm gonna...`
+ : easterEggCount >= 10
+ ? `S-senpai... Be careful...`
+ : easterEggCount >= 5
+ ? `H-hey... Stop that..`
+ : `Hey, I'd like some roles!`}
+
+
+
+
+ `.iam ${role.name}`)}
+ />
+
+
+
+
+ );
+};
diff --git a/src/design-system/molecules/demo-discord/index.ts b/src/design-system/molecules/demo-discord/index.ts
new file mode 100644
index 0000000..70e62cb
--- /dev/null
+++ b/src/design-system/molecules/demo-discord/index.ts
@@ -0,0 +1 @@
+export * from './DemoDiscord';
diff --git a/src/design-system/molecules/demo-picker/DemoPicker.story.tsx b/src/design-system/molecules/demo-picker/DemoPicker.story.tsx
new file mode 100644
index 0000000..6411423
--- /dev/null
+++ b/src/design-system/molecules/demo-picker/DemoPicker.story.tsx
@@ -0,0 +1,7 @@
+import * as React from 'react';
+import { moleculeStories } from 'molecules/molecules.story';
+import { DemoPicker } from './DemoPicker';
+
+const story = moleculeStories('Landing Demos', module);
+
+story.add('Picker', () => );
diff --git a/src/design-system/molecules/demo-picker/DemoPicker.tsx b/src/design-system/molecules/demo-picker/DemoPicker.tsx
new file mode 100644
index 0000000..5137e86
--- /dev/null
+++ b/src/design-system/molecules/demo-picker/DemoPicker.tsx
@@ -0,0 +1,46 @@
+import * as React from 'react';
+import { Role } from 'atoms/role';
+import { Role as RPCRole } from '@roleypoly/rpc/shared';
+import styled from 'styled-components';
+import { demoData } from 'hack/fixtures/demoData';
+
+const Container = styled.div`
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ align-items: center;
+ align-content: center;
+ height: 95px;
+`;
+
+const RoleWrap = styled.div`
+ padding: 2.5px;
+ display: inline-block;
+`;
+
+export const DemoPicker = () => {
+ const [selectedStates, setSelectedStates] = React.useState<
+ {
+ [key in RPCRole.AsObject['id']]: boolean;
+ }
+ >(demoData.reduce((acc, role) => ({ ...acc, [role.id]: false }), {}));
+
+ return (
+
+ {demoData.map((role) => (
+
+ {
+ setSelectedStates({
+ ...selectedStates,
+ [role.id]: !selectedStates[role.id],
+ });
+ }}
+ />
+
+ ))}
+
+ );
+};
diff --git a/src/design-system/molecules/demo-picker/index.ts b/src/design-system/molecules/demo-picker/index.ts
new file mode 100644
index 0000000..1b9c7d3
--- /dev/null
+++ b/src/design-system/molecules/demo-picker/index.ts
@@ -0,0 +1 @@
+export * from './DemoPicker';
diff --git a/src/design-system/molecules/footer/Flags.tsx b/src/design-system/molecules/footer/Flags.tsx
new file mode 100644
index 0000000..3acfee4
--- /dev/null
+++ b/src/design-system/molecules/footer/Flags.tsx
@@ -0,0 +1,94 @@
+import * as React from 'react';
+
+type FlagsProps = {
+ width?: number | string;
+ height?: number | string;
+};
+
+export const Flags = (props: FlagsProps) => (
+
+);
diff --git a/src/design-system/molecules/footer/Footer.story.tsx b/src/design-system/molecules/footer/Footer.story.tsx
new file mode 100644
index 0000000..20c6f0d
--- /dev/null
+++ b/src/design-system/molecules/footer/Footer.story.tsx
@@ -0,0 +1,7 @@
+import * as React from 'react';
+import { moleculeStories } from 'molecules/molecules.story';
+import { Footer } from './Footer';
+
+const story = moleculeStories('Footer', module);
+
+story.add('Basic', () => );
diff --git a/src/design-system/molecules/footer/Footer.styled.ts b/src/design-system/molecules/footer/Footer.styled.ts
new file mode 100644
index 0000000..35b89be
--- /dev/null
+++ b/src/design-system/molecules/footer/Footer.styled.ts
@@ -0,0 +1,30 @@
+import styled from 'styled-components';
+import { palette } from 'atoms/colors';
+import { transitions } from 'atoms/timings';
+
+export const FooterWrapper = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+
+ a {
+ color: ${palette.taupe500};
+ text-decoration: none;
+ transition: color ${transitions.actionable}s ease-in-out;
+ &:hover {
+ color: ${palette.taupe600};
+ }
+ }
+`;
+
+export const HoverColor = styled.div`
+ opacity: 0.3;
+ filter: saturate(0);
+ transition: all ${transitions.in2in}s ease-in-out;
+
+ &:hover {
+ opacity: 1;
+ filter: none;
+ }
+`;
diff --git a/src/design-system/molecules/footer/Footer.tsx b/src/design-system/molecules/footer/Footer.tsx
new file mode 100644
index 0000000..a5eac1e
--- /dev/null
+++ b/src/design-system/molecules/footer/Footer.tsx
@@ -0,0 +1,27 @@
+import * as React from 'react';
+import { FooterWrapper, HoverColor } from './Footer.styled';
+import { AmbientLarge } from 'atoms/typography';
+import { FaHeart } from 'react-icons/fa';
+import { Flags } from './Flags';
+
+const year = new Date().getFullYear();
+
+export const Footer = () => (
+
+
+
+ © {year} Roleypoly – Made with{' '}
+
+ in Raleigh, NC
+
+
+
+
+
+
+
+);
diff --git a/src/design-system/molecules/footer/index.ts b/src/design-system/molecules/footer/index.ts
new file mode 100644
index 0000000..ddcc5a9
--- /dev/null
+++ b/src/design-system/molecules/footer/index.ts
@@ -0,0 +1 @@
+export * from './Footer';
diff --git a/src/design-system/molecules/guild-nav/GuildNav.story.tsx b/src/design-system/molecules/guild-nav/GuildNav.story.tsx
new file mode 100644
index 0000000..067df25
--- /dev/null
+++ b/src/design-system/molecules/guild-nav/GuildNav.story.tsx
@@ -0,0 +1,18 @@
+import { moleculeStories } from 'molecules/molecules.story';
+import * as React from 'react';
+import { GuildNav } from './GuildNav';
+import { guildEnum } from 'hack/fixtures/storyData';
+import { PopoverBase } from 'atoms/popover/Popover.styled';
+
+const story = moleculeStories('Guild Nav', module);
+
+story.add('Has Guilds', () => (
+
+
+
+));
+story.add('No Guilds', () => (
+
+
+
+));
diff --git a/src/design-system/molecules/guild-nav/GuildNav.styled.ts b/src/design-system/molecules/guild-nav/GuildNav.styled.ts
new file mode 100644
index 0000000..be3202d
--- /dev/null
+++ b/src/design-system/molecules/guild-nav/GuildNav.styled.ts
@@ -0,0 +1,21 @@
+import styled from 'styled-components';
+import { transitions } from 'atoms/timings';
+import { palette } from 'atoms/colors';
+
+export const GuildNavItem = styled.a`
+ display: flex;
+ align-items: center;
+ transition: border ${transitions.in2in}s ease-in-out;
+ border: 1px solid transparent;
+ border-radius: 3px;
+ box-sizing: border-box;
+ margin: 5px;
+ user-select: none;
+ color: unset;
+ text-decoration: none;
+
+ &:hover {
+ border-color: ${palette.taupe300};
+ cursor: pointer;
+ }
+`;
diff --git a/src/design-system/molecules/guild-nav/GuildNav.tsx b/src/design-system/molecules/guild-nav/GuildNav.tsx
new file mode 100644
index 0000000..9e1eccc
--- /dev/null
+++ b/src/design-system/molecules/guild-nav/GuildNav.tsx
@@ -0,0 +1,64 @@
+import * as React from 'react';
+import { NavSlug } from 'molecules/nav-slug';
+import { sortBy } from 'utils/sortBy';
+import { GuildEnumeration, PresentableGuild } from '@roleypoly/rpc/platform';
+import { hasPermission, permissions } from 'utils/hasPermission';
+import { GoZap, GoStar } from 'react-icons/go';
+import { Role } from '@roleypoly/rpc/shared';
+import { GuildNavItem } from './GuildNav.styled';
+import ReactTooltip from 'react-tooltip';
+import Link from 'next/link';
+
+type Props = {
+ guildEnumeration: GuildEnumeration.AsObject;
+};
+
+const tooltipId = 'guildnav';
+
+const Badges = (props: { guild: PresentableGuild.AsObject }) => {
+ return React.useMemo(() => {
+ if (!props.guild.member) {
+ return null;
+ }
+
+ const roles = props.guild.member.rolesList
+ .map((id) => {
+ if (!props.guild.roles) {
+ return undefined;
+ }
+
+ return props.guild.roles.rolesList.find((role) => role.id === id);
+ })
+ .filter((x) => !!x) as Role.AsObject[];
+
+ if (hasPermission(roles, permissions.ADMINISTRATOR)) {
+ return ;
+ }
+
+ if (hasPermission(roles, permissions.MANAGE_ROLES)) {
+ return ;
+ }
+
+ return null;
+ }, [props.guild]);
+};
+
+export const GuildNav = (props: Props) => (
+
+ {sortBy(
+ props.guildEnumeration.guildsList.map((g) => ({
+ ...g,
+ nameLower: g.guild?.name.toLowerCase(),
+ })),
+ 'nameLower'
+ ).map(({ nameLower, ...guild }) => (
+
+
+
+
+
+
+ ))}
+
+
+);
diff --git a/src/design-system/molecules/guild-nav/index.ts b/src/design-system/molecules/guild-nav/index.ts
new file mode 100644
index 0000000..9034be6
--- /dev/null
+++ b/src/design-system/molecules/guild-nav/index.ts
@@ -0,0 +1 @@
+export * from './GuildNav';
diff --git a/src/design-system/molecules/molecules.story.tsx b/src/design-system/molecules/molecules.story.tsx
new file mode 100644
index 0000000..eabe220
--- /dev/null
+++ b/src/design-system/molecules/molecules.story.tsx
@@ -0,0 +1,2 @@
+import { makeFactory } from '../.storybook/storyHelper';
+export const moleculeStories = makeFactory('Molecules');
diff --git a/src/design-system/molecules/nav-slug/NavSlug.story.tsx b/src/design-system/molecules/nav-slug/NavSlug.story.tsx
new file mode 100644
index 0000000..b19cc06
--- /dev/null
+++ b/src/design-system/molecules/nav-slug/NavSlug.story.tsx
@@ -0,0 +1,9 @@
+import * as React from 'react';
+import { moleculeStories } from 'molecules/molecules.story';
+import { NavSlug } from './NavSlug';
+import { guild } from 'hack/fixtures/storyData';
+
+const story = moleculeStories('Server Slug', module);
+
+story.add('Empty', () => );
+story.add('Example', () => );
diff --git a/src/design-system/molecules/nav-slug/NavSlug.styled.ts b/src/design-system/molecules/nav-slug/NavSlug.styled.ts
new file mode 100644
index 0000000..d1908c0
--- /dev/null
+++ b/src/design-system/molecules/nav-slug/NavSlug.styled.ts
@@ -0,0 +1,16 @@
+import styled from 'styled-components';
+
+export const SlugContainer = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ padding: 5px;
+`;
+
+export const SlugName = styled.div`
+ padding: 0 10px;
+ position: relative;
+ top: -1px;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+`;
diff --git a/src/design-system/molecules/nav-slug/NavSlug.tsx b/src/design-system/molecules/nav-slug/NavSlug.tsx
new file mode 100644
index 0000000..c006702
--- /dev/null
+++ b/src/design-system/molecules/nav-slug/NavSlug.tsx
@@ -0,0 +1,18 @@
+import * as React from 'react';
+import { Guild } from '@roleypoly/rpc/shared';
+import { Avatar, utils } from 'atoms/avatar';
+import { SlugContainer, SlugName } from './NavSlug.styled';
+import { GoOrganization } from 'react-icons/go';
+
+type Props = {
+ guild: Guild.AsObject | null;
+};
+
+export const NavSlug = (props: Props) => (
+
+
+ {props.guild ? utils.initialsFromName(props.guild.name) : }
+
+ {props.guild?.name || <>Your Guilds>}
+
+);
diff --git a/src/design-system/molecules/nav-slug/index.ts b/src/design-system/molecules/nav-slug/index.ts
new file mode 100644
index 0000000..e8db90f
--- /dev/null
+++ b/src/design-system/molecules/nav-slug/index.ts
@@ -0,0 +1 @@
+export * from './NavSlug';
diff --git a/src/design-system/molecules/picker-category/PickerCategory.story.tsx b/src/design-system/molecules/picker-category/PickerCategory.story.tsx
new file mode 100644
index 0000000..4c8c4df
--- /dev/null
+++ b/src/design-system/molecules/picker-category/PickerCategory.story.tsx
@@ -0,0 +1,36 @@
+import * as React from 'react';
+import { moleculeStories } from 'molecules/molecules.story';
+import { PickerCategory, CategoryProps } from './PickerCategory';
+import { action } from '@storybook/addon-actions';
+import { text, optionsKnob } from '@storybook/addon-knobs';
+import { roleCategory, roleWikiData, mockCategory } from 'hack/fixtures/storyData';
+
+const stories = moleculeStories('Picker Category', module);
+
+const data: (mode?: 'single') => CategoryProps = (mode?: 'single') => ({
+ title: text('Title', 'Pronouns'),
+ type: 'multi',
+ roles: roleCategory,
+ wikiMode: false,
+ category: mockCategory,
+ onChange: () => action('onChange'),
+ selectedRoles: optionsKnob(
+ 'Selected Roles',
+ roleCategory.reduce((acc, x) => ({ ...acc, [x.name]: x.id }), {}),
+ [roleCategory[0].id],
+ { display: mode === 'single' ? 'select' : 'multi-select' }
+ ),
+});
+
+stories.add('Multi', () => {
+ const d = data();
+ return ;
+});
+stories.add('Single', () => {
+ const d = data('single');
+ return ;
+});
+stories.add('Wiki', () => {
+ const d = data();
+ return ;
+});
diff --git a/src/design-system/molecules/picker-category/PickerCategory.styled.tsx b/src/design-system/molecules/picker-category/PickerCategory.styled.tsx
new file mode 100644
index 0000000..6c006d5
--- /dev/null
+++ b/src/design-system/molecules/picker-category/PickerCategory.styled.tsx
@@ -0,0 +1,20 @@
+import styled from 'styled-components';
+
+export const Head = styled.div`
+ margin: 7px 5px;
+ line-height: 200%;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+`;
+
+export const HeadTitle = styled.div`
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+`;
+
+export const HeadSub = styled.div`
+ flex-shrink: 0;
+ margin-top: -4px;
+`;
diff --git a/src/design-system/molecules/picker-category/PickerCategory.tsx b/src/design-system/molecules/picker-category/PickerCategory.tsx
new file mode 100644
index 0000000..e65dfa7
--- /dev/null
+++ b/src/design-system/molecules/picker-category/PickerCategory.tsx
@@ -0,0 +1,64 @@
+import * as React from 'react';
+import { Role as RPCRole } from '@roleypoly/rpc/shared';
+import { Category as RPCCategory } from '@roleypoly/rpc/platform';
+import { LargeText, AmbientLarge } from 'atoms/typography';
+import { Role } from 'atoms/role';
+import styled from 'styled-components';
+import ReactTooltip from 'react-tooltip';
+import { Head, HeadTitle, HeadSub } from './PickerCategory.styled';
+
+export type CategoryProps = {
+ title: string;
+ roles: RPCRole.AsObject[];
+ category: RPCCategory.AsObject;
+ selectedRoles: string[];
+ onChange: (role: RPCRole.AsObject) => (newState: boolean) => void;
+ type: 'single' | 'multi';
+} & (
+ | {
+ wikiMode: true;
+ roleWikiData: { [roleId: string]: string };
+ }
+ | {
+ wikiMode: false;
+ }
+);
+
+const Category = styled.div`
+ display: flex;
+ flex-wrap: wrap;
+`;
+
+const Container = styled.div`
+ overflow: hidden;
+ padding: 5px;
+`;
+
+export const PickerCategory = (props: CategoryProps) => (
+
+
+
+ {props.title}
+
+ {props.type === 'single' && (
+
+ Pick one
+
+ )}
+
+
+ {props.roles.map((role, idx) => (
+
+
+
+ ))}
+
+
+
+);
diff --git a/src/design-system/molecules/picker-category/index.ts b/src/design-system/molecules/picker-category/index.ts
new file mode 100644
index 0000000..8d74439
--- /dev/null
+++ b/src/design-system/molecules/picker-category/index.ts
@@ -0,0 +1 @@
+export { PickerCategory } from './PickerCategory';
diff --git a/src/design-system/molecules/preauth-greeting/PreauthGreeting.story.tsx b/src/design-system/molecules/preauth-greeting/PreauthGreeting.story.tsx
new file mode 100644
index 0000000..54f3bb7
--- /dev/null
+++ b/src/design-system/molecules/preauth-greeting/PreauthGreeting.story.tsx
@@ -0,0 +1,8 @@
+import * as React from 'react';
+import { moleculeStories } from '../molecules.story';
+import { PreauthGreeting } from './PreauthGreeting';
+import { guild } from 'hack/fixtures/storyData';
+
+const story = moleculeStories('Preauth', module);
+
+story.add('Greeting', () => );
diff --git a/src/design-system/molecules/preauth-greeting/PreauthGreeting.tsx b/src/design-system/molecules/preauth-greeting/PreauthGreeting.tsx
new file mode 100644
index 0000000..1599ef6
--- /dev/null
+++ b/src/design-system/molecules/preauth-greeting/PreauthGreeting.tsx
@@ -0,0 +1,31 @@
+import * as React from 'react';
+import { Avatar, utils as avatarUtils } from 'atoms/avatar';
+import { Guild } from '@roleypoly/rpc/shared';
+import { AccentTitle } from 'atoms/typography';
+import { Space } from 'atoms/space';
+import styled from 'styled-components';
+
+type GreetingProps = {
+ guildSlug: Guild.AsObject;
+};
+
+const Center = styled.div`
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ flex-direction: column;
+`;
+
+export const PreauthGreeting = (props: GreetingProps) => (
+
+
+ {avatarUtils.initialsFromName(props.guildSlug.name)}
+
+
+ Hi there. {props.guildSlug.name} uses Roleypoly to help assign you
+ roles.
+
+
+
+
+);
diff --git a/src/design-system/molecules/preauth-greeting/index.ts b/src/design-system/molecules/preauth-greeting/index.ts
new file mode 100644
index 0000000..7f1a2a7
--- /dev/null
+++ b/src/design-system/molecules/preauth-greeting/index.ts
@@ -0,0 +1 @@
+export * from './PreauthGreeting';
diff --git a/src/design-system/molecules/preauth-secret-code/PreauthSecretCode.spec.tsx b/src/design-system/molecules/preauth-secret-code/PreauthSecretCode.spec.tsx
new file mode 100644
index 0000000..1881507
--- /dev/null
+++ b/src/design-system/molecules/preauth-secret-code/PreauthSecretCode.spec.tsx
@@ -0,0 +1,37 @@
+jest.unmock('atoms/text-input');
+jest.unmock('./PreauthSecretCode');
+
+import { Button } from 'atoms/button';
+import { TextInputWithIcon } from 'atoms/text-input';
+import { shallow } from 'enzyme';
+import * as React from 'react';
+import { PreauthSecretCode } from './PreauthSecretCode';
+import { FaderOpacity } from 'atoms/fader';
+
+const value = 'unfathomable fishy sticks';
+const onSubmit = jest.fn();
+
+it('sends the secret code when submitted', () => {
+ const view = shallow();
+
+ view.find(TextInputWithIcon).simulate('change', { target: { value } });
+
+ view.find(Button).simulate('click');
+ expect(onSubmit).toBeCalledWith(value);
+});
+
+it('shows the submit button when secret code is not empty', () => {
+ const view = shallow();
+
+ view.find(TextInputWithIcon).simulate('change', { target: { value } });
+
+ expect(view.find(FaderOpacity).props().isVisible).toBe(true);
+});
+
+it('hides the submit button when secret code is empty', () => {
+ const view = shallow();
+
+ view.find(TextInputWithIcon).simulate('change', { target: { value: '' } });
+
+ expect(view.find(FaderOpacity).props().isVisible).toBe(false);
+});
diff --git a/src/design-system/molecules/preauth-secret-code/PreauthSecretCode.story.tsx b/src/design-system/molecules/preauth-secret-code/PreauthSecretCode.story.tsx
new file mode 100644
index 0000000..60f2dbe
--- /dev/null
+++ b/src/design-system/molecules/preauth-secret-code/PreauthSecretCode.story.tsx
@@ -0,0 +1,8 @@
+import * as React from 'react';
+import { moleculeStories } from '../molecules.story';
+import { PreauthSecretCode } from './PreauthSecretCode';
+import { action } from '@storybook/addon-actions';
+
+const story = moleculeStories('Preauth', module);
+
+story.add('Secret Code', () => );
diff --git a/src/design-system/molecules/preauth-secret-code/PreauthSecretCode.tsx b/src/design-system/molecules/preauth-secret-code/PreauthSecretCode.tsx
new file mode 100644
index 0000000..9553cce
--- /dev/null
+++ b/src/design-system/molecules/preauth-secret-code/PreauthSecretCode.tsx
@@ -0,0 +1,46 @@
+import * as React from 'react';
+import { TextInputWithIcon } from 'atoms/text-input';
+import { FiKey } from 'react-icons/fi';
+import { FaderOpacity } from 'atoms/fader';
+import { Button } from 'atoms/button';
+import { Space } from 'atoms/space';
+
+type PreauthProps = {
+ onSubmit: (code: string) => void;
+};
+
+export const PreauthSecretCode = (props: PreauthProps) => {
+ const [secretCode, setSecretCode] = React.useState('');
+
+ const handleChange = (event: React.ChangeEvent) => {
+ setSecretCode(event.target.value);
+ };
+
+ const handleKeyPress = (event: React.KeyboardEvent) => {
+ if (event.key === 'Enter') {
+ props.onSubmit(secretCode);
+ }
+ };
+
+ const handleSubmit = () => {
+ props.onSubmit(secretCode);
+ };
+
+ return (
+
+ }
+ value={secretCode}
+ placeholder="Super secret code..."
+ onChange={handleChange}
+ onKeyDown={handleKeyPress}
+ />
+
+ 0}>
+
+
+
+ );
+};
diff --git a/src/design-system/molecules/preauth-secret-code/index.ts b/src/design-system/molecules/preauth-secret-code/index.ts
new file mode 100644
index 0000000..faee937
--- /dev/null
+++ b/src/design-system/molecules/preauth-secret-code/index.ts
@@ -0,0 +1 @@
+export * from './PreauthSecretCode';
diff --git a/src/design-system/molecules/reset-submit/ResetSubmit.spec.tsx b/src/design-system/molecules/reset-submit/ResetSubmit.spec.tsx
new file mode 100644
index 0000000..8c6328f
--- /dev/null
+++ b/src/design-system/molecules/reset-submit/ResetSubmit.spec.tsx
@@ -0,0 +1,23 @@
+import { Button } from 'atoms/button';
+import { shallow } from 'enzyme';
+import * as React from 'react';
+import { ResetSubmit } from './ResetSubmit';
+
+const onReset = jest.fn();
+const onSubmit = jest.fn();
+
+it('calls onReset when reset is clicked', () => {
+ const view = shallow();
+
+ view.find(Button).at(0).simulate('click');
+
+ expect(onReset).toBeCalled();
+});
+
+it('calls onSubmit when submit is clicked', () => {
+ const view = shallow();
+
+ view.find(Button).at(1).simulate('click');
+
+ expect(onSubmit).toBeCalled();
+});
diff --git a/src/design-system/molecules/reset-submit/ResetSubmit.story.tsx b/src/design-system/molecules/reset-submit/ResetSubmit.story.tsx
new file mode 100644
index 0000000..489d9f4
--- /dev/null
+++ b/src/design-system/molecules/reset-submit/ResetSubmit.story.tsx
@@ -0,0 +1,9 @@
+import * as React from 'react';
+import { moleculeStories } from 'molecules/molecules.story';
+import { action } from '@storybook/addon-actions';
+import { ResetSubmit } from './ResetSubmit';
+const story = moleculeStories('Reset & Submit', module);
+
+story.add('Reset & Submit', () => (
+
+));
diff --git a/src/design-system/molecules/reset-submit/ResetSubmit.styled.ts b/src/design-system/molecules/reset-submit/ResetSubmit.styled.ts
new file mode 100644
index 0000000..dedf37d
--- /dev/null
+++ b/src/design-system/molecules/reset-submit/ResetSubmit.styled.ts
@@ -0,0 +1,19 @@
+import styled from 'styled-components';
+import { onSmallScreen } from 'atoms/breakpoints';
+
+export const Buttons = styled.div`
+ display: flex;
+ flex-wrap: wrap;
+`;
+
+export const Left = styled.div`
+ flex: 0;
+ ${onSmallScreen`
+ flex: 1 1 100%;
+ order: 2;
+ `}
+`;
+
+export const Right = styled.div`
+ flex: 1;
+`;
diff --git a/src/design-system/molecules/reset-submit/ResetSubmit.tsx b/src/design-system/molecules/reset-submit/ResetSubmit.tsx
new file mode 100644
index 0000000..2ae9db3
--- /dev/null
+++ b/src/design-system/molecules/reset-submit/ResetSubmit.tsx
@@ -0,0 +1,42 @@
+import { onSmallScreen } from 'atoms/breakpoints';
+import { Button } from 'atoms/button';
+import * as React from 'react';
+import { MdRestore } from 'react-icons/md';
+import styled from 'styled-components';
+
+type Props = {
+ onSubmit: () => void;
+ onReset: () => void;
+};
+
+const Buttons = styled.div`
+ display: flex;
+ flex-wrap: wrap;
+`;
+
+const Left = styled.div`
+ flex: 0;
+ ${onSmallScreen`
+ flex: 1 1 100%;
+ order: 2;
+ `}
+`;
+
+const Right = styled.div`
+ flex: 1;
+`;
+
+export const ResetSubmit = (props: Props) => {
+ return (
+
+
+ } onClick={props.onReset}>
+ Reset
+
+
+
+
+
+
+ );
+};
diff --git a/src/design-system/molecules/reset-submit/index.ts b/src/design-system/molecules/reset-submit/index.ts
new file mode 100644
index 0000000..d175a14
--- /dev/null
+++ b/src/design-system/molecules/reset-submit/index.ts
@@ -0,0 +1 @@
+export * from './ResetSubmit';
diff --git a/src/design-system/molecules/server-masthead/ServerMasthead.spec.tsx b/src/design-system/molecules/server-masthead/ServerMasthead.spec.tsx
new file mode 100644
index 0000000..96c1de2
--- /dev/null
+++ b/src/design-system/molecules/server-masthead/ServerMasthead.spec.tsx
@@ -0,0 +1,19 @@
+jest.unmock('./ServerMasthead');
+
+import * as React from 'react';
+import { shallow } from 'enzyme';
+import { ServerMasthead } from './ServerMasthead';
+import { guild } from 'hack/fixtures/storyData';
+import { Editable } from './ServerMasthead.styled';
+
+it('shows Edit Server when editable is true', () => {
+ const view = shallow();
+
+ expect(view.find(Editable).length).not.toBe(0);
+});
+
+it('hides Edit Server when editable is true', () => {
+ const view = shallow();
+
+ expect(view.find(Editable).length).toBe(0);
+});
diff --git a/src/design-system/molecules/server-masthead/ServerMasthead.story.tsx b/src/design-system/molecules/server-masthead/ServerMasthead.story.tsx
new file mode 100644
index 0000000..59c53d6
--- /dev/null
+++ b/src/design-system/molecules/server-masthead/ServerMasthead.story.tsx
@@ -0,0 +1,9 @@
+import * as React from 'react';
+import { moleculeStories } from 'molecules/molecules.story';
+import { ServerMasthead } from './ServerMasthead';
+import { guild } from 'hack/fixtures/storyData';
+
+const story = moleculeStories('Server Masthead', module);
+
+story.add('Default', () => );
+story.add('Editable', () => );
diff --git a/src/design-system/molecules/server-masthead/ServerMasthead.styled.ts b/src/design-system/molecules/server-masthead/ServerMasthead.styled.ts
new file mode 100644
index 0000000..f907271
--- /dev/null
+++ b/src/design-system/molecules/server-masthead/ServerMasthead.styled.ts
@@ -0,0 +1,36 @@
+import styled from 'styled-components';
+import { palette } from 'atoms/colors';
+import { transitions } from 'atoms/timings';
+
+export const Wrapper = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+`;
+
+export const Name = styled.div`
+ margin: 0 10px;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ display: flex;
+ flex-wrap: wrap;
+ flex-direction: column;
+ justify-content: flex-start;
+`;
+
+export const Icon = styled.div`
+ flex-shrink: 0;
+`;
+
+export const Editable = styled.div`
+ color: ${palette.taupe500};
+ display: flex;
+ align-items: center;
+ user-select: none;
+ transition: color ${transitions.actionable}s ease-in-out;
+ cursor: pointer;
+ &:hover {
+ color: ${palette.taupe600};
+ }
+`;
diff --git a/src/design-system/molecules/server-masthead/ServerMasthead.tsx b/src/design-system/molecules/server-masthead/ServerMasthead.tsx
new file mode 100644
index 0000000..ddef1f0
--- /dev/null
+++ b/src/design-system/molecules/server-masthead/ServerMasthead.tsx
@@ -0,0 +1,36 @@
+import { Guild } from '@roleypoly/rpc/shared';
+import { Avatar, utils } from 'atoms/avatar';
+import { AccentTitle, AmbientLarge } from 'atoms/typography';
+import Link from 'next/link';
+import { guild } from 'hack/fixtures/storyData';
+import * as React from 'react';
+import { GoPencil } from 'react-icons/go';
+import { Editable, Icon, Name, Wrapper } from './ServerMasthead.styled';
+
+export type ServerMastheadProps = {
+ guild: Guild.AsObject;
+ editable: boolean;
+};
+
+export const ServerMasthead = (props: ServerMastheadProps) => {
+ return (
+
+
+
+ {utils.initialsFromName(props.guild.name)}
+
+
+
+ {props.guild.name}
+ {props.editable && (
+
+
+
+ Edit Server
+
+
+ )}
+
+
+ );
+};
diff --git a/src/design-system/molecules/server-masthead/index.ts b/src/design-system/molecules/server-masthead/index.ts
new file mode 100644
index 0000000..a206ba4
--- /dev/null
+++ b/src/design-system/molecules/server-masthead/index.ts
@@ -0,0 +1 @@
+export * from './ServerMasthead';
diff --git a/src/design-system/molecules/user-avatar-group/UserAvatarGroup.story.tsx b/src/design-system/molecules/user-avatar-group/UserAvatarGroup.story.tsx
new file mode 100644
index 0000000..ddeb6cb
--- /dev/null
+++ b/src/design-system/molecules/user-avatar-group/UserAvatarGroup.story.tsx
@@ -0,0 +1,13 @@
+import * as React from 'react';
+import { moleculeStories } from 'molecules/molecules.story';
+import { UserAvatarGroup } from './UserAvatarGroup';
+import { user } from 'hack/fixtures/storyData';
+import { Hero } from 'atoms/hero';
+
+const story = moleculeStories('User Avatar Group', module);
+
+story.add('Default', () => (
+
+
+
+));
diff --git a/src/design-system/molecules/user-avatar-group/UserAvatarGroup.styled.ts b/src/design-system/molecules/user-avatar-group/UserAvatarGroup.styled.ts
new file mode 100644
index 0000000..8e76051
--- /dev/null
+++ b/src/design-system/molecules/user-avatar-group/UserAvatarGroup.styled.ts
@@ -0,0 +1,29 @@
+import styled, { css } from 'styled-components';
+import { onSmallScreen } from 'atoms/breakpoints';
+import { palette } from 'atoms/colors';
+
+export const Collapse = styled.div<{ preventCollapse: boolean }>`
+ ${(props) =>
+ !props.preventCollapse &&
+ onSmallScreen(css`
+ display: none;
+ `)}
+`;
+
+export const Group = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ white-space: nowrap;
+`;
+
+export const Discriminator = styled.span`
+ color: ${palette.taupe500};
+ font-size: 75%;
+ padding: 0 5px;
+`;
+
+export const GroupText = styled.span`
+ position: relative;
+ top: -2px;
+`;
diff --git a/src/design-system/molecules/user-avatar-group/UserAvatarGroup.tsx b/src/design-system/molecules/user-avatar-group/UserAvatarGroup.tsx
new file mode 100644
index 0000000..ac562d7
--- /dev/null
+++ b/src/design-system/molecules/user-avatar-group/UserAvatarGroup.tsx
@@ -0,0 +1,24 @@
+import * as React from 'react';
+import { DiscordUser } from '@roleypoly/rpc/shared';
+import { utils, Avatar } from 'atoms/avatar';
+import { Group, Collapse, Discriminator, GroupText } from './UserAvatarGroup.styled';
+
+type Props = {
+ user: DiscordUser.AsObject;
+ preventCollapse?: boolean;
+};
+
+export const UserAvatarGroup = (props: Props) => (
+
+
+
+ {props.user.username}
+ #{props.user.discriminator}
+
+
+
+
+ {utils.initialsFromName(props.user.username)}
+
+
+);
diff --git a/src/design-system/molecules/user-avatar-group/index.ts b/src/design-system/molecules/user-avatar-group/index.ts
new file mode 100644
index 0000000..52cf06c
--- /dev/null
+++ b/src/design-system/molecules/user-avatar-group/index.ts
@@ -0,0 +1 @@
+export * from './UserAvatarGroup';
diff --git a/src/design-system/molecules/user-popover/UserPopover.story.tsx b/src/design-system/molecules/user-popover/UserPopover.story.tsx
new file mode 100644
index 0000000..f276845
--- /dev/null
+++ b/src/design-system/molecules/user-popover/UserPopover.story.tsx
@@ -0,0 +1,13 @@
+import { user } from 'hack/fixtures/storyData';
+import { moleculeStories } from 'molecules/molecules.story';
+import * as React from 'react';
+import { UserPopover } from './UserPopover';
+import { PopoverBase } from 'atoms/popover/Popover.styled';
+
+const story = moleculeStories('User Popover', module);
+
+story.add('User Popover', () => (
+
+
+
+));
diff --git a/src/design-system/molecules/user-popover/UserPopover.styled.ts b/src/design-system/molecules/user-popover/UserPopover.styled.ts
new file mode 100644
index 0000000..e46e8c0
--- /dev/null
+++ b/src/design-system/molecules/user-popover/UserPopover.styled.ts
@@ -0,0 +1,33 @@
+import styled from 'styled-components';
+import { palette } from 'atoms/colors';
+import { transitions } from 'atoms/timings';
+
+export const Base = styled.div`
+ text-align: right;
+ display: flex;
+ flex-direction: column;
+ user-select: none;
+`;
+
+export const NavAction = styled.div`
+ height: 2.25em;
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ transition: color ${transitions.actionable}s ease-in-out;
+ color: ${palette.taupe500};
+ box-sizing: border-box;
+
+ &:hover {
+ cursor: pointer;
+ color: ${palette.taupe600};
+ }
+
+ svg {
+ font-size: 120%;
+ box-sizing: content-box;
+ padding: 5px 8px;
+ position: relative;
+ top: 0.1em;
+ }
+`;
diff --git a/src/design-system/molecules/user-popover/UserPopover.tsx b/src/design-system/molecules/user-popover/UserPopover.tsx
new file mode 100644
index 0000000..dde1cc2
--- /dev/null
+++ b/src/design-system/molecules/user-popover/UserPopover.tsx
@@ -0,0 +1,30 @@
+import * as React from 'react';
+import { DiscordUser } from '@roleypoly/rpc/shared';
+import { UserAvatarGroup } from 'molecules/user-avatar-group';
+import { Base, NavAction } from './UserPopover.styled';
+import { GoGear, GoSignOut } from 'react-icons/go';
+import Link from 'next/link';
+
+type UserPopoverProps = {
+ user: DiscordUser.AsObject;
+};
+
+export const UserPopover = (props: UserPopoverProps) => (
+
+
+
+
+ <>
+ Settings
+ >
+
+
+
+
+ <>
+ Log Out
+ >
+
+
+
+);
diff --git a/src/design-system/molecules/user-popover/index.ts b/src/design-system/molecules/user-popover/index.ts
new file mode 100644
index 0000000..2cf58d5
--- /dev/null
+++ b/src/design-system/molecules/user-popover/index.ts
@@ -0,0 +1 @@
+export * from './UserPopover';
diff --git a/src/design-system/organisms/app-shell/AppShell.story.tsx b/src/design-system/organisms/app-shell/AppShell.story.tsx
new file mode 100644
index 0000000..bb7cebf
--- /dev/null
+++ b/src/design-system/organisms/app-shell/AppShell.story.tsx
@@ -0,0 +1,18 @@
+import * as React from 'react';
+import { organismStories } from 'organisms/organisms.story';
+import { AppShell } from './AppShell';
+import { rpUser, guildEnum } from 'hack/fixtures/storyData';
+
+const story = organismStories('App Shell', module);
+
+story.add('Guest', () => (
+
+ Hello World
+
+));
+
+story.add('Logged In', () => (
+
+ Hello World
+
+));
diff --git a/src/design-system/organisms/app-shell/AppShell.styled.tsx b/src/design-system/organisms/app-shell/AppShell.styled.tsx
new file mode 100644
index 0000000..bf579d6
--- /dev/null
+++ b/src/design-system/organisms/app-shell/AppShell.styled.tsx
@@ -0,0 +1,22 @@
+import styled, { createGlobalStyle } from 'styled-components';
+import { palette } from 'atoms/colors';
+
+export const Content = styled.div<{ small?: boolean }>`
+ margin: 0 auto;
+ margin-top: 50px;
+ width: ${(props) => (props.small ? '960px' : '1024px')};
+ max-width: 98vw;
+ max-height: calc(100vh - 50px);
+`;
+
+export const GlobalStyles = createGlobalStyle`
+ body {
+ background-color: ${palette.taupe200};
+ color: ${palette.grey600};
+ overflow-y: hidden;
+ scroll-behavior: smooth;
+ }
+ * {
+ box-sizing: border-box;
+ }
+`;
diff --git a/src/design-system/organisms/app-shell/AppShell.tsx b/src/design-system/organisms/app-shell/AppShell.tsx
new file mode 100644
index 0000000..336e98e
--- /dev/null
+++ b/src/design-system/organisms/app-shell/AppShell.tsx
@@ -0,0 +1,41 @@
+import * as React from 'react';
+import * as Masthead from 'organisms/masthead';
+import { RoleypolyUser } from '@roleypoly/rpc/shared';
+import { Footer } from 'molecules/footer';
+import { Content, GlobalStyles } from './AppShell.styled';
+import { GlobalStyleColors } from 'atoms/colors';
+import { GuildEnumeration } from '@roleypoly/rpc/platform';
+import { Scrollbars } from 'react-custom-scrollbars';
+
+type AppShellProps = {
+ children: React.ReactNode;
+ user: RoleypolyUser.AsObject | null;
+ showFooter?: boolean;
+ small?: boolean;
+ activeGuildId?: string | null;
+ guildEnumeration?: GuildEnumeration.AsObject;
+};
+
+export const AppShell = (props: AppShellProps) => (
+ <>
+
+
+ {props.user !== null ? (
+
+ ) : (
+
+ )}
+
+ {props.children}
+ {props.showFooter && }
+
+ >
+);
diff --git a/src/design-system/organisms/app-shell/index.ts b/src/design-system/organisms/app-shell/index.ts
new file mode 100644
index 0000000..5af1348
--- /dev/null
+++ b/src/design-system/organisms/app-shell/index.ts
@@ -0,0 +1 @@
+export * from './AppShell';
diff --git a/src/design-system/organisms/error-banner/ErrorBanner.story.tsx b/src/design-system/organisms/error-banner/ErrorBanner.story.tsx
new file mode 100644
index 0000000..b31dcc2
--- /dev/null
+++ b/src/design-system/organisms/error-banner/ErrorBanner.story.tsx
@@ -0,0 +1,16 @@
+import * as React from 'react';
+import { organismStories } from 'organisms/organisms.story';
+import { ErrorBanner } from './ErrorBanner';
+import { text } from '@storybook/addon-knobs';
+
+const story = organismStories('Error Banner', module);
+
+story.add('Error Banner', () => (
+
+));
diff --git a/src/design-system/organisms/error-banner/ErrorBanner.styled.ts b/src/design-system/organisms/error-banner/ErrorBanner.styled.ts
new file mode 100644
index 0000000..41ce040
--- /dev/null
+++ b/src/design-system/organisms/error-banner/ErrorBanner.styled.ts
@@ -0,0 +1,40 @@
+import { onSmallScreen } from 'atoms/breakpoints';
+import { palette } from 'atoms/colors';
+import { text300, text500, text700 } from 'atoms/typography';
+import styled, { css } from 'styled-components';
+
+export const ErrorWrapper = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ ${onSmallScreen(css`
+ display: block;
+ text-align: center;
+ `)}
+`;
+
+export const ErrorDivider = styled.div`
+ width: 1px;
+ height: 3em;
+ background: ${palette.grey600};
+ margin: 0 1em;
+ ${onSmallScreen(css`
+ display: none;
+ `)}
+`;
+
+export const ErrorSideCode = styled.div`
+ ${text700}
+ ${onSmallScreen(css`
+ margin-bottom: 0.4em;
+ `)}
+`;
+
+export const ErrorText = styled.div`
+ ${text500}
+`;
+
+export const ErrorTextLower = styled.div`
+ ${text300}
+ color: ${palette.taupe500};
+`;
diff --git a/src/design-system/organisms/error-banner/ErrorBanner.tsx b/src/design-system/organisms/error-banner/ErrorBanner.tsx
new file mode 100644
index 0000000..f7cda59
--- /dev/null
+++ b/src/design-system/organisms/error-banner/ErrorBanner.tsx
@@ -0,0 +1,24 @@
+import * as React from 'react';
+import {
+ ErrorWrapper,
+ ErrorDivider,
+ ErrorSideCode,
+ ErrorText,
+ ErrorTextLower,
+} from './ErrorBanner.styled';
+import { ErrorMessage } from 'templates/errors/errorStrings';
+
+type ErrorBannerProps = {
+ message: Required;
+};
+
+export const ErrorBanner = (props: ErrorBannerProps) => (
+
+ {props.message.friendlyCode}
+
+
+ {props.message.english}
+ {props.message.japanese}
+
+
+);
diff --git a/src/design-system/organisms/error-banner/index.ts b/src/design-system/organisms/error-banner/index.ts
new file mode 100644
index 0000000..cd2e864
--- /dev/null
+++ b/src/design-system/organisms/error-banner/index.ts
@@ -0,0 +1 @@
+export * from './ErrorBanner';
diff --git a/src/design-system/organisms/help-page-base/HelpPageBase.story.tsx b/src/design-system/organisms/help-page-base/HelpPageBase.story.tsx
new file mode 100644
index 0000000..545ee72
--- /dev/null
+++ b/src/design-system/organisms/help-page-base/HelpPageBase.story.tsx
@@ -0,0 +1,19 @@
+import * as React from 'react';
+import { organismStories } from 'organisms/organisms.story';
+import { HelpPageBase } from './HelpPageBase';
+import { Content } from 'organisms/app-shell/AppShell.styled';
+
+const baseStory = organismStories('Help Pages', module);
+
+export const HelpStoryWrapper = (props: { children: React.ReactNode }) => (
+
+ {props.children}
+
+);
+
+baseStory.add('Base', () => (
+
+ What is the world but vibrations?
+ Vibrations that synchronize and tie it together, running free forever.
+
+));
diff --git a/src/design-system/organisms/help-page-base/HelpPageBase.tsx b/src/design-system/organisms/help-page-base/HelpPageBase.tsx
new file mode 100644
index 0000000..0f9724b
--- /dev/null
+++ b/src/design-system/organisms/help-page-base/HelpPageBase.tsx
@@ -0,0 +1,21 @@
+import * as React from 'react';
+import styled from 'styled-components';
+import { palette } from 'atoms/colors';
+
+export type HelpPageProps = {
+ children: React.ReactNode;
+};
+
+const Container = styled.div`
+ background: ${palette.taupe300};
+ padding: 2em 3em;
+ width: 1024px;
+ max-width: 98vw;
+ margin: 0 auto;
+ margin-top: 75px;
+ box-sizing: border-box;
+`;
+
+export const HelpPageBase = (props: HelpPageProps) => (
+ {props.children}
+);
diff --git a/src/design-system/organisms/help-page-base/index.ts b/src/design-system/organisms/help-page-base/index.ts
new file mode 100644
index 0000000..82ad770
--- /dev/null
+++ b/src/design-system/organisms/help-page-base/index.ts
@@ -0,0 +1 @@
+export * from './HelpPageBase';
diff --git a/src/design-system/organisms/help-why-no-roles/WhyNoRoles.story.tsx b/src/design-system/organisms/help-why-no-roles/WhyNoRoles.story.tsx
new file mode 100644
index 0000000..c553dc5
--- /dev/null
+++ b/src/design-system/organisms/help-why-no-roles/WhyNoRoles.story.tsx
@@ -0,0 +1,10 @@
+import { WhyNoRoles } from './WhyNoRoles';
+import * as React from 'react';
+import { organismStories } from 'organisms/organisms.story';
+import { HelpStoryWrapper } from 'organisms/help-page-base/HelpPageBase.story';
+
+organismStories('Help Pages/Pages', module).add('Why No Roles', () => (
+
+
+
+));
diff --git a/src/design-system/organisms/help-why-no-roles/WhyNoRoles.styled.ts b/src/design-system/organisms/help-why-no-roles/WhyNoRoles.styled.ts
new file mode 100644
index 0000000..046bcc1
--- /dev/null
+++ b/src/design-system/organisms/help-why-no-roles/WhyNoRoles.styled.ts
@@ -0,0 +1,35 @@
+import styled, { css } from 'styled-components';
+import { palette, numberToChroma } from 'atoms/colors';
+import { Role } from '@roleypoly/rpc/shared';
+
+export const DiscordBase = styled.div`
+ background-color: ${palette.discord100};
+ border: solid 1px rgba(0, 0, 0, 0.15);
+ border-radius: 3px;
+ padding: 10px;
+ user-select: none;
+ width: 175px;
+`;
+
+const hover = (roleColor: string) => css`
+ color: #efefef;
+ background-color: ${roleColor};
+ cursor: pointer;
+`;
+
+export const DiscordRole = styled.div<{
+ discordRole: Role.AsObject;
+ isRoleypoly: boolean;
+}>`
+ padding: 6px 10px;
+ color: ${(props) => numberToChroma(props.discordRole.color).css()};
+ border-radius: 3px;
+
+ :hover {
+ ${(props) => hover(numberToChroma(props.discordRole.color).alpha(0.5).css())}
+ }
+
+ ${(props) =>
+ props.isRoleypoly &&
+ hover(numberToChroma(props.discordRole.color).alpha(0.5).css())}
+`;
diff --git a/src/design-system/organisms/help-why-no-roles/WhyNoRoles.tsx b/src/design-system/organisms/help-why-no-roles/WhyNoRoles.tsx
new file mode 100644
index 0000000..58c1c5f
--- /dev/null
+++ b/src/design-system/organisms/help-why-no-roles/WhyNoRoles.tsx
@@ -0,0 +1,83 @@
+import * as React from 'react';
+import { HalfsiesContainer, HalfsiesItem } from 'atoms/halfsies';
+import { FaCheck, FaTimes } from 'react-icons/fa';
+import { DiscordBase, DiscordRole } from './WhyNoRoles.styled';
+import { demoData } from 'hack/fixtures/demoData';
+import { Role } from '@roleypoly/rpc/shared';
+import { palette } from 'atoms/colors';
+import chroma from 'chroma-js';
+import { SparkleOverlay } from 'atoms/sparkle';
+
+const adminRoles: Role.AsObject[] = [
+ {
+ id: 'roley2',
+ name: 'Admin',
+ permissions: 0,
+ color: chroma('hotpink').num(),
+ position: -1,
+ managed: true,
+ safety: 0,
+ },
+ {
+ id: 'roley3',
+ name: 'Moderator',
+ permissions: 0,
+ color: chroma('lime').num(),
+ position: -1,
+ managed: true,
+ safety: 0,
+ },
+];
+
+const roleypolyRole: Role.AsObject = {
+ id: 'roley',
+ name: 'Roleypoly',
+ permissions: 0,
+ color: chroma(palette.taupe500).num(),
+ position: -1,
+ managed: true,
+ safety: 0,
+};
+
+const goodRoles = [...adminRoles, roleypolyRole, ...demoData];
+
+const badRoles = [...adminRoles, ...demoData, roleypolyRole];
+
+const MaybeWithOverlay = (props: { children: React.ReactNode; withOverlay: boolean }) => {
+ if (props.withOverlay) {
+ return (
+
+ {props.children}
+
+ );
+ } else {
+ return <>{props.children}>;
+ }
+};
+
+const Example = (props: { roles: Role.AsObject[]; isGood: boolean }) => (
+
+
+ {props.roles.map((r) => (
+
+
+ {r.name}
+
+
+ ))}
+
+
+);
+
+export const WhyNoRoles = () => (
+
+
+ Good
+
+
+
+ Baddd
+
+
+
+);
diff --git a/src/design-system/organisms/help-why-no-roles/index.ts b/src/design-system/organisms/help-why-no-roles/index.ts
new file mode 100644
index 0000000..eb026b8
--- /dev/null
+++ b/src/design-system/organisms/help-why-no-roles/index.ts
@@ -0,0 +1 @@
+export * from './WhyNoRoles';
diff --git a/src/design-system/organisms/landing/Landing.story.tsx b/src/design-system/organisms/landing/Landing.story.tsx
new file mode 100644
index 0000000..57c68eb
--- /dev/null
+++ b/src/design-system/organisms/landing/Landing.story.tsx
@@ -0,0 +1,7 @@
+import * as React from 'react';
+import { organismStories } from 'organisms/organisms.story';
+import { Landing } from './Landing';
+
+const story = organismStories('Landing', module);
+
+story.add('Landing', () => );
diff --git a/src/design-system/organisms/landing/Landing.styled.ts b/src/design-system/organisms/landing/Landing.styled.ts
new file mode 100644
index 0000000..4c61cb5
--- /dev/null
+++ b/src/design-system/organisms/landing/Landing.styled.ts
@@ -0,0 +1,31 @@
+import { onTablet } from 'atoms/breakpoints';
+import { text400 } from 'atoms/typography';
+import styled, { css } from 'styled-components';
+
+export const HeroText = styled.div`
+ ${onTablet(css`
+ text-align: center;
+ `)}
+`;
+
+export const DemoSubtitle = styled.p`
+ ${text400}
+ text-align: center;
+ margin-top: 0.4em;
+`;
+
+export const DemoAlignment = styled.div`
+ min-height: 125px;
+ ${onTablet(css`
+ min-height: 95px;
+ `)}
+`;
+
+export const HeroCentering = styled.div`
+ display: flex;
+ flex-direction: column;
+ min-height: calc(100vh - 200px);
+ align-items: center;
+ justify-content: center;
+ margin-bottom: 2em;
+`;
diff --git a/src/design-system/organisms/landing/Landing.tsx b/src/design-system/organisms/landing/Landing.tsx
new file mode 100644
index 0000000..482b995
--- /dev/null
+++ b/src/design-system/organisms/landing/Landing.tsx
@@ -0,0 +1,38 @@
+import { palette } from 'atoms/colors';
+import { Space } from 'atoms/space';
+import { LargeText, LargeTitle } from 'atoms/typography';
+import { DemoDiscord } from 'molecules/demo-discord';
+import { DemoPicker } from 'molecules/demo-picker';
+import * as React from 'react';
+import { DemoAlignment, DemoSubtitle, HeroCentering, HeroText } from './Landing.styled';
+import { HalfsiesContainer, HalfsiesItem } from 'atoms/halfsies';
+
+export const Landing = () => (
+
+
+
+ Discord roles for humans.
+
+
+
+ Ditch the bot commands. It's {new Date().getFullYear()}.
+
+
+
+
+
+
+
+
+
+ Why are you okay with antiques?
+
+
+
+
+
+ Just click or tap.
+
+
+
+);
diff --git a/src/design-system/organisms/landing/index.ts b/src/design-system/organisms/landing/index.ts
new file mode 100644
index 0000000..f743289
--- /dev/null
+++ b/src/design-system/organisms/landing/index.ts
@@ -0,0 +1 @@
+export * from './Landing';
diff --git a/src/design-system/organisms/masthead/Authed.tsx b/src/design-system/organisms/masthead/Authed.tsx
new file mode 100644
index 0000000..4c3347e
--- /dev/null
+++ b/src/design-system/organisms/masthead/Authed.tsx
@@ -0,0 +1,99 @@
+import { GuildEnumeration } from '@roleypoly/rpc/platform';
+import { RoleypolyUser } from '@roleypoly/rpc/shared';
+import { Logomark } from 'atoms/branding';
+import { Popover } from 'atoms/popover';
+import { guildEnum } from 'hack/fixtures/storyData';
+import { GuildNav } from 'molecules/guild-nav';
+import { NavSlug } from 'molecules/nav-slug';
+import { UserAvatarGroup } from 'molecules/user-avatar-group';
+import { UserPopover } from 'molecules/user-popover';
+import Link from 'next/link';
+import * as React from 'react';
+import { GoOrganization } from 'react-icons/go';
+import {
+ GuildPopoverHead,
+ InteractionBase,
+ MastheadA,
+ MastheadAlignment,
+ MastheadBase,
+ MastheadLeft,
+ MastheadRight,
+} from './Masthead.styled';
+
+type Props = {
+ user: RoleypolyUser.AsObject;
+ activeGuildId: string | null;
+ guildEnumeration: GuildEnumeration.AsObject;
+};
+
+export const Authed = (props: Props) => {
+ const [userPopoverState, setUserPopoverState] = React.useState(false);
+ const [serverPopoverState, setServerPopoverState] = React.useState(false);
+
+ return (
+
+
+
+
+
+
+
+
+ {
+ setServerPopoverState(true);
+ setUserPopoverState(false);
+ }}
+ hide={!serverPopoverState}
+ >
+ g.id === props.activeGuildId
+ )?.guild || null
+ }
+ />
+
+
+
+ My Guilds
+
+ }
+ canDefocus
+ position="bottom left"
+ active={serverPopoverState}
+ onExit={() => setServerPopoverState(false)}
+ >
+
+
+
+
+ {
+ setUserPopoverState(true);
+ setServerPopoverState(false);
+ }}
+ hide={!userPopoverState}
+ >
+ {props.user.discorduser && (
+
+ )}
+
+ >}
+ canDefocus
+ position="top right"
+ active={userPopoverState}
+ onExit={() => setUserPopoverState(false)}
+ >
+ {props.user.discorduser && (
+
+ )}
+
+
+
+
+ );
+};
diff --git a/src/design-system/organisms/masthead/Guest.tsx b/src/design-system/organisms/masthead/Guest.tsx
new file mode 100644
index 0000000..6e723e9
--- /dev/null
+++ b/src/design-system/organisms/masthead/Guest.tsx
@@ -0,0 +1,39 @@
+import { Logotype } from 'atoms/branding';
+import { Button } from 'atoms/button';
+import Link from 'next/link';
+import * as React from 'react';
+import { FaSignInAlt } from 'react-icons/fa';
+import {
+ MastheadBase,
+ MastheadLeft,
+ MastheadRight,
+ MastheadAlignment,
+ MastheadA,
+} from './Masthead.styled';
+
+export const Guest = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
diff --git a/src/design-system/organisms/masthead/Masthead.story.tsx b/src/design-system/organisms/masthead/Masthead.story.tsx
new file mode 100644
index 0000000..9cbd6c4
--- /dev/null
+++ b/src/design-system/organisms/masthead/Masthead.story.tsx
@@ -0,0 +1,17 @@
+import { rpUser, guild, guildEnum } from 'hack/fixtures/storyData';
+import { organismStories } from 'organisms/organisms.story';
+import * as React from 'react';
+import { Authed } from './Authed';
+import { Guest } from './Guest';
+
+const rootStory = organismStories('Masthead', module);
+const userStory = organismStories('Masthead/User', module);
+
+userStory.add('Has Guilds', () => (
+
+));
+userStory.add('No Guilds (New User)', () => (
+
+));
+
+rootStory.add('Guest', () => );
diff --git a/src/design-system/organisms/masthead/Masthead.styled.tsx b/src/design-system/organisms/masthead/Masthead.styled.tsx
new file mode 100644
index 0000000..194f4af
--- /dev/null
+++ b/src/design-system/organisms/masthead/Masthead.styled.tsx
@@ -0,0 +1,91 @@
+import { onSmallScreen } from 'atoms/breakpoints';
+import { palette } from 'atoms/colors';
+import { transitions } from 'atoms/timings';
+import styled, { css } from 'styled-components';
+
+export const MastheadBase = styled.div`
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 50px;
+ background-color: ${palette.taupe100};
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0 3px;
+ z-index: 100;
+`;
+
+export const MastheadAlignment = styled.div`
+ max-width: 98vw;
+ width: 1024px;
+ display: flex;
+ align-items: center;
+`;
+
+const sideBase = styled.div`
+ flex: 1;
+ display: flex;
+`;
+
+export const MastheadLeft = styled(sideBase)``;
+
+export const MastheadRight = styled(sideBase)`
+ flex: 0;
+ justify-content: flex-end;
+`;
+
+export const MastheadCollapse = styled.div`
+ ${onSmallScreen(css`
+ display: none;
+ `)}
+`;
+
+export const MastheadInner = styled.div`
+ /* height: 30px; */
+ display: flex;
+ align-items: center;
+`;
+
+type InteractionBaseProps = {
+ hide: boolean;
+};
+export const InteractionBase = styled.div`
+ display: flex;
+ align-items: center;
+ height: 50px;
+ padding: 0 5px;
+ transition: opacity ${transitions.actionable}s ease-in-out,
+ background-color ${transitions.actionable}s ease-in-out;
+ opacity: ${(props) => (props.hide ? 1 : 0)};
+
+ :hover {
+ background-color: rgba(0, 0, 0, 0.15);
+ cursor: pointer;
+ }
+`;
+
+export const MastheadA = styled.a`
+ display: flex;
+ align-items: center;
+ color: unset;
+ text-decoration: unset;
+`;
+
+export const GuildPopoverHead = styled.div`
+ display: flex;
+ align-items: center;
+
+ svg {
+ color: ${palette.taupe500};
+ padding: 5px;
+ height: 1.4em;
+ font-size: 2em;
+ margin-right: 10px;
+ margin-left: 16px;
+ ${onSmallScreen(css`
+ margin-left: 0;
+ `)}
+ }
+`;
diff --git a/src/design-system/organisms/masthead/index.ts b/src/design-system/organisms/masthead/index.ts
new file mode 100644
index 0000000..fd88034
--- /dev/null
+++ b/src/design-system/organisms/masthead/index.ts
@@ -0,0 +1,2 @@
+export * from './Guest';
+export * from './Authed';
diff --git a/src/design-system/organisms/organisms.story.tsx b/src/design-system/organisms/organisms.story.tsx
new file mode 100644
index 0000000..b93779a
--- /dev/null
+++ b/src/design-system/organisms/organisms.story.tsx
@@ -0,0 +1,2 @@
+import { makeFactory } from '../.storybook/storyHelper';
+export const organismStories = makeFactory('Organisms');
diff --git a/src/design-system/organisms/preauth/Preauth.story.tsx b/src/design-system/organisms/preauth/Preauth.story.tsx
new file mode 100644
index 0000000..636fd38
--- /dev/null
+++ b/src/design-system/organisms/preauth/Preauth.story.tsx
@@ -0,0 +1,32 @@
+import * as React from 'react';
+import { Preauth } from './Preauth';
+import { organismStories } from 'organisms/organisms.story';
+import { guild } from 'hack/fixtures/storyData';
+import { action } from '@storybook/addon-actions';
+import styled from 'styled-components';
+
+const story = organismStories('Preauth', module);
+
+const Center = styled.div`
+ margin: 0 auto;
+`;
+
+story.add('No Slug', () => {
+ return (
+
+
+
+ );
+});
+
+story.add('With Slug', () => {
+ return (
+
+
+
+ );
+});
diff --git a/src/design-system/organisms/preauth/Preauth.tsx b/src/design-system/organisms/preauth/Preauth.tsx
new file mode 100644
index 0000000..cdadad4
--- /dev/null
+++ b/src/design-system/organisms/preauth/Preauth.tsx
@@ -0,0 +1,57 @@
+import { Guild } from '@roleypoly/rpc/shared';
+import { Button } from 'atoms/button';
+import { Space } from 'atoms/space';
+import { PreauthGreeting } from 'molecules/preauth-greeting';
+import { PreauthSecretCode } from 'molecules/preauth-secret-code';
+import * as React from 'react';
+import { FaDiscord } from 'react-icons/fa';
+import styled from 'styled-components';
+
+export type PreauthProps = {
+ guildSlug?: Guild.AsObject;
+ onSendSecretCode: (code: string) => void;
+ botName?: string;
+};
+
+const Centered = styled.div`
+ text-align: center;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ max-width: 90vw;
+ margin: 0 auto;
+`;
+
+const WidthContainer = styled.div`
+ width: 20em;
+ max-width: 90vw;
+`;
+
+export const Preauth = (props: PreauthProps) => {
+ return (
+
+ {props.guildSlug && }
+
+
+
+
+
+
+ Or, send a message saying "login" to{' '}
+ {props.botName || 'roleypoly'}
+
+
+
+
+ );
+};
diff --git a/src/design-system/organisms/role-picker/RolePicker.spec.tsx b/src/design-system/organisms/role-picker/RolePicker.spec.tsx
new file mode 100644
index 0000000..c175395
--- /dev/null
+++ b/src/design-system/organisms/role-picker/RolePicker.spec.tsx
@@ -0,0 +1,43 @@
+jest.unmock('atoms/role')
+ .unmock('atoms/button')
+ .unmock('molecules/picker-category')
+ .unmock('organisms/role-picker');
+
+import { Role } from 'atoms/role';
+import { shallow } from 'enzyme';
+import {
+ guild,
+ guildData,
+ guildRoles,
+ member,
+ mockCategorySingle,
+} from 'hack/fixtures/storyData';
+import { ResetSubmit } from 'molecules/reset-submit';
+import { PickerCategory } from 'molecules/picker-category';
+import * as React from 'react';
+import { RolePicker, RolePickerProps } from './RolePicker';
+
+it('unselects the rest of a category in single mode', () => {
+ const props: RolePickerProps = {
+ guildData: { ...guildData, categoriesList: [mockCategorySingle] },
+ member: { ...member, rolesList: [] },
+ roles: guildRoles,
+ guild: guild,
+ onSubmit: jest.fn(),
+ editable: false,
+ };
+
+ const view = shallow();
+
+ const roles = view.find(PickerCategory).dive().find(Role);
+
+ roles.first().props().onClick?.(true);
+
+ view.find(ResetSubmit).props().onSubmit();
+ expect(props.onSubmit).toBeCalledWith([mockCategorySingle.rolesList[0]]);
+
+ roles.last().props().onClick?.(true);
+
+ view.find(ResetSubmit).props().onSubmit();
+ expect(props.onSubmit).toBeCalledWith([mockCategorySingle.rolesList[1]]);
+});
diff --git a/src/design-system/organisms/role-picker/RolePicker.story.tsx b/src/design-system/organisms/role-picker/RolePicker.story.tsx
new file mode 100644
index 0000000..b216624
--- /dev/null
+++ b/src/design-system/organisms/role-picker/RolePicker.story.tsx
@@ -0,0 +1,45 @@
+import * as React from 'react';
+import { RolePicker, RolePickerProps } from './RolePicker';
+import { organismStories } from 'organisms/organisms.story';
+import { guildData, member, guildRoles, guild } from 'hack/fixtures/storyData';
+import { action } from '@storybook/addon-actions';
+
+const storyPublic = organismStories('Role Picker/Public', module);
+const storyEditable = organismStories('Role Picker/Editable', module);
+
+const props: RolePickerProps = {
+ guildData: guildData,
+ member: member,
+ guild: guild,
+ roles: guildRoles,
+ onSubmit: action('onSubmit'),
+ editable: false,
+};
+
+const storyBuilder = (
+ story: typeof storyPublic,
+ mixinProps: Partial
+) => {
+ story.add('Full', () => );
+ story.add('No Message', () => (
+
+ ));
+ story.add('No Categories', () => (
+
+ ));
+};
+
+storyBuilder(storyPublic, {});
+storyBuilder(storyEditable, { editable: true });
diff --git a/src/design-system/organisms/role-picker/RolePicker.styled.tsx b/src/design-system/organisms/role-picker/RolePicker.styled.tsx
new file mode 100644
index 0000000..0e3a475
--- /dev/null
+++ b/src/design-system/organisms/role-picker/RolePicker.styled.tsx
@@ -0,0 +1,34 @@
+import styled from 'styled-components';
+import { palette } from 'atoms/colors';
+
+export const Container = styled.div``;
+
+export const Box = styled.div`
+ background-color: ${palette.taupe300};
+ padding: 5px;
+ margin: 5px 0;
+`;
+
+export const MessageBox = styled(Box)`
+ padding: 10px;
+`;
+
+export const CategoryContainer = styled(Box)``;
+
+export const InfoBox = styled(MessageBox)`
+ display: flex;
+ align-items: center;
+`;
+
+export const InfoIcon = styled.div`
+ flex-shrink: 0;
+ font-size: 1.75em;
+ margin-right: 10px;
+ display: flex;
+ align-items: center;
+ color: ${palette.taupe500};
+`;
+
+export const Buttons = styled.div`
+ display: flex;
+`;
diff --git a/src/design-system/organisms/role-picker/RolePicker.tsx b/src/design-system/organisms/role-picker/RolePicker.tsx
new file mode 100644
index 0000000..ce44199
--- /dev/null
+++ b/src/design-system/organisms/role-picker/RolePicker.tsx
@@ -0,0 +1,135 @@
+import { Member } from '@roleypoly/rpc/discord';
+import { Category, GuildData } from '@roleypoly/rpc/platform';
+import { Guild, GuildRoles, Role } from '@roleypoly/rpc/shared';
+import { FaderOpacity } from 'atoms/fader';
+import { Space } from 'atoms/space';
+import { ResetSubmit } from 'molecules/reset-submit';
+import { ServerMasthead } from 'molecules/server-masthead';
+import { PickerCategory } from 'molecules/picker-category';
+import * as React from 'react';
+import { GoInfo } from 'react-icons/go';
+import {
+ CategoryContainer,
+ Container,
+ InfoBox,
+ InfoIcon,
+ MessageBox,
+} from './RolePicker.styled';
+import { ReactifyNewlines } from 'utils/ReactifyNewlines';
+
+export type RolePickerProps = {
+ guild: Guild.AsObject;
+ guildData: GuildData.AsObject;
+ member: Member.AsObject;
+ roles: GuildRoles.AsObject;
+ onSubmit: (selectedRoles: string[]) => void;
+ editable: boolean;
+};
+
+const arrayMatches = (a: any[], b: any[]) => {
+ return (
+ a === b ||
+ (a.length === b.length &&
+ a.every((x) => b.includes(x)) &&
+ b.every((x) => a.includes(x)))
+ );
+};
+
+export const RolePicker = (props: RolePickerProps) => {
+ const [selectedRoles, updateSelectedRoles] = React.useState(
+ props.member.rolesList
+ );
+
+ const handleChange = (category: Category.AsObject) => (role: Role.AsObject) => (
+ newState: boolean
+ ) => {
+ if (category.type === Category.CategoryType.SINGLE) {
+ updateSelectedRoles(
+ newState === true
+ ? [
+ ...selectedRoles.filter((x) => !category.rolesList.includes(x)),
+ role.id,
+ ]
+ : selectedRoles.filter((x) => x !== role.id)
+ );
+ } else {
+ updateSelectedRoles(
+ newState === true
+ ? [...selectedRoles, role.id]
+ : selectedRoles.filter((x) => x !== role.id)
+ );
+ }
+ };
+
+ return (
+
+
+
+
+ {props.guildData.message && (
+ <>
+
+ {props.guildData.message}
+
+
+ >
+ )}
+
+ {props.guildData.categoriesList.length !== 0 ? (
+ <>
+
+ {props.guildData.categoriesList.map((category, idx) => (
+
+
+ category.rolesList.includes(roleId)
+ )}
+ roles={
+ category.rolesList
+ .map((role) =>
+ props.roles.rolesList.find(
+ (r) => r.id === role
+ )
+ )
+ .filter(
+ (r) => r !== undefined
+ ) as Role.AsObject[]
+ }
+ onChange={handleChange(category)}
+ wikiMode={false}
+ type={
+ category.type === Category.CategoryType.SINGLE
+ ? 'single'
+ : 'multi'
+ }
+ />
+
+ ))}
+
+
+ props.onSubmit(selectedRoles)}
+ onReset={() => {
+ updateSelectedRoles(props.member.rolesList);
+ }}
+ />
+
+ >
+ ) : (
+
+
+
+
+
+ There are currently no roles available for you to choose from.
+
+
+ )}
+
+ );
+};
diff --git a/src/design-system/organisms/role-picker/index.ts b/src/design-system/organisms/role-picker/index.ts
new file mode 100644
index 0000000..f66adb0
--- /dev/null
+++ b/src/design-system/organisms/role-picker/index.ts
@@ -0,0 +1 @@
+export * from './RolePicker';
diff --git a/src/design-system/templates/auth-login/AuthLogin.story.tsx b/src/design-system/templates/auth-login/AuthLogin.story.tsx
new file mode 100644
index 0000000..13536a7
--- /dev/null
+++ b/src/design-system/templates/auth-login/AuthLogin.story.tsx
@@ -0,0 +1,18 @@
+import * as React from 'react';
+import { templateStories } from 'templates/templates.story';
+import { AuthLogin } from './AuthLogin';
+import { action } from '@storybook/addon-actions';
+import { guild } from 'hack/fixtures/storyData';
+
+const story = templateStories('Login', module);
+
+story.add('No Slug', () => (
+
+));
+story.add('With Slug', () => (
+
+));
diff --git a/src/design-system/templates/auth-login/AuthLogin.tsx b/src/design-system/templates/auth-login/AuthLogin.tsx
new file mode 100644
index 0000000..7cc0fd1
--- /dev/null
+++ b/src/design-system/templates/auth-login/AuthLogin.tsx
@@ -0,0 +1,14 @@
+import { Hero } from 'atoms/hero';
+import { AppShell } from 'organisms/app-shell';
+import { Preauth, PreauthProps } from 'organisms/preauth/Preauth';
+import * as React from 'react';
+
+export type AuthLoginProps = PreauthProps;
+
+export const AuthLogin = (props: AuthLoginProps) => (
+
+
+
+
+
+);
diff --git a/src/design-system/templates/auth-login/index.ts b/src/design-system/templates/auth-login/index.ts
new file mode 100644
index 0000000..a7155f3
--- /dev/null
+++ b/src/design-system/templates/auth-login/index.ts
@@ -0,0 +1 @@
+export * from './AuthLogin';
diff --git a/src/design-system/templates/errors/Errors.story.tsx b/src/design-system/templates/errors/Errors.story.tsx
new file mode 100644
index 0000000..8edd547
--- /dev/null
+++ b/src/design-system/templates/errors/Errors.story.tsx
@@ -0,0 +1,10 @@
+import * as React from 'react';
+import { templateStories } from 'templates/templates.story';
+import { Error } from './Errors';
+import { errorMessages } from './errorStrings';
+
+const messages = templateStories('Errors/Messages', module);
+
+for (let message in errorMessages) {
+ messages.add(`${message}`, () => );
+}
diff --git a/src/design-system/templates/errors/Errors.tsx b/src/design-system/templates/errors/Errors.tsx
new file mode 100644
index 0000000..9d5af4f
--- /dev/null
+++ b/src/design-system/templates/errors/Errors.tsx
@@ -0,0 +1,26 @@
+import { DotOverlay } from 'atoms/dot-overlay';
+import { Hero } from 'atoms/hero';
+import { AppShell } from 'organisms/app-shell';
+import * as React from 'react';
+import { ErrorMessage, getMessageFromCode } from './errorStrings';
+import { ErrorBanner } from 'organisms/error-banner';
+import { RoleypolyUser } from '@roleypoly/rpc/shared';
+
+export type ErrorProps = {
+ code: string | number;
+ messageOverride?: ErrorMessage;
+ user?: RoleypolyUser.AsObject | null;
+};
+
+export const Error = (props: ErrorProps) => {
+ const messageFromCode = getMessageFromCode(props.code);
+
+ return (
+
+
+
+
+
+
+ );
+};
diff --git a/src/design-system/templates/errors/errorStrings.ts b/src/design-system/templates/errors/errorStrings.ts
new file mode 100644
index 0000000..57e3b89
--- /dev/null
+++ b/src/design-system/templates/errors/errorStrings.ts
@@ -0,0 +1,66 @@
+export type ErrorMessage = {
+ english: string;
+ japanese?: string;
+ friendlyCode?: string;
+};
+
+const defaultMessage: Required = {
+ english: `Something went bad. How could this happen?`,
+ japanese: `わかりません...`,
+ friendlyCode: 'Oops.',
+};
+
+export const errorMessages: { [code: string]: ErrorMessage } = {
+ default: defaultMessage,
+ '400': {
+ english: 'Your client sent me something weird...',
+ japanese: '((((;゜Д゜)))',
+ },
+ '403': {
+ english: `You weren't allowed to access this.`,
+ japanese: 'あなたはこの点に合格しないかもしれません',
+ },
+ '404': {
+ english: `This page is in another castle.`,
+ japanese: 'お探しのページは見つかりませんでした',
+ },
+ '419': {
+ english: 'Something went too slowly...',
+ japanese: 'おやすみなさい〜',
+ },
+ '500': {
+ english: `The server doesn't like you right now. Feed it a cookie.`,
+ japanese: 'クッキーを送ってください〜 クッキーを送ってください〜',
+ },
+ serverFailure: {
+ english: `Server is super unhappy with you today...`,
+ japanese: 'クッキーを送ってください〜',
+ friendlyCode: `Oh no!`,
+ },
+ magicExpired: {
+ english: 'That magic login link was expired.',
+ friendlyCode: 'Woah.',
+ },
+ authFailure: {
+ english: `I tried to tell the server who you were...`,
+ japanese: `...but it didn't believe me. :( ごめんなさい`,
+ friendlyCode: 'Yo.',
+ },
+};
+
+export const getMessageFromCode = (
+ code: keyof typeof errorMessages
+): Required => {
+ const codeStr = String(code);
+ const baseMessage = errorMessages[codeStr];
+
+ const message: Required = {
+ english: baseMessage?.english || defaultMessage.english,
+ japanese: baseMessage?.japanese || defaultMessage.japanese,
+ friendlyCode: baseMessage
+ ? baseMessage?.friendlyCode || codeStr
+ : defaultMessage.friendlyCode,
+ };
+
+ return message;
+};
diff --git a/src/design-system/templates/errors/index.ts b/src/design-system/templates/errors/index.ts
new file mode 100644
index 0000000..2eb99ca
--- /dev/null
+++ b/src/design-system/templates/errors/index.ts
@@ -0,0 +1 @@
+export * from './Errors';
diff --git a/src/design-system/templates/help-page/HelpPage.story.tsx b/src/design-system/templates/help-page/HelpPage.story.tsx
new file mode 100644
index 0000000..d69c7fd
--- /dev/null
+++ b/src/design-system/templates/help-page/HelpPage.story.tsx
@@ -0,0 +1,12 @@
+import * as React from 'react';
+import { templateStories } from 'templates/templates.story';
+import { HelpPageTemplate } from './HelpPage';
+
+const story = templateStories('Help Page', module);
+
+story.add('Base', () => (
+
+ What is the world but vibrations?
+ Vibrations that synchronize and tie it together, running free forever.
+
+));
diff --git a/src/design-system/templates/help-page/HelpPage.tsx b/src/design-system/templates/help-page/HelpPage.tsx
new file mode 100644
index 0000000..3938100
--- /dev/null
+++ b/src/design-system/templates/help-page/HelpPage.tsx
@@ -0,0 +1,15 @@
+import * as React from 'react';
+import { AppShell } from 'organisms/app-shell';
+import { HelpPageBase } from 'organisms/help-page-base';
+import { RoleypolyUser } from '@roleypoly/rpc/shared';
+
+type HelpPageProps = {
+ user: RoleypolyUser.AsObject | null;
+ children: React.ReactNode;
+};
+
+export const HelpPageTemplate = (props: HelpPageProps) => (
+
+ {props.children}
+
+);
diff --git a/src/design-system/templates/landing/Landing.story.tsx b/src/design-system/templates/landing/Landing.story.tsx
new file mode 100644
index 0000000..c0e6e98
--- /dev/null
+++ b/src/design-system/templates/landing/Landing.story.tsx
@@ -0,0 +1,7 @@
+import * as React from 'react';
+import { templateStories } from 'templates/templates.story';
+import { LandingTemplate } from './Landing';
+
+const story = templateStories('Landing', module);
+
+story.add('Landing', () => );
diff --git a/src/design-system/templates/landing/Landing.tsx b/src/design-system/templates/landing/Landing.tsx
new file mode 100644
index 0000000..a011e28
--- /dev/null
+++ b/src/design-system/templates/landing/Landing.tsx
@@ -0,0 +1,9 @@
+import * as React from 'react';
+import { AppShell } from 'organisms/app-shell';
+import { Landing } from 'organisms/landing';
+
+export const LandingTemplate = () => (
+
+
+
+);
diff --git a/src/design-system/templates/landing/index.ts b/src/design-system/templates/landing/index.ts
new file mode 100644
index 0000000..f743289
--- /dev/null
+++ b/src/design-system/templates/landing/index.ts
@@ -0,0 +1 @@
+export * from './Landing';
diff --git a/src/design-system/templates/role-picker/RolePicker.story.tsx b/src/design-system/templates/role-picker/RolePicker.story.tsx
new file mode 100644
index 0000000..7e8af2b
--- /dev/null
+++ b/src/design-system/templates/role-picker/RolePicker.story.tsx
@@ -0,0 +1,35 @@
+import * as React from 'react';
+import { templateStories } from 'templates/templates.story';
+import { RolePickerTemplate, RolePickerTemplateProps } from './RolePicker';
+import {
+ guildData,
+ member,
+ guildRoles,
+ guild,
+ rpUser,
+ guildEnum,
+} from 'hack/fixtures/storyData';
+import { action } from '@storybook/addon-actions';
+import { boolean } from '@storybook/addon-knobs';
+
+const story = templateStories('Role Picker', module);
+
+const props: RolePickerTemplateProps = {
+ guildData: {
+ ...guildData,
+ message:
+ 'Hey, this is kind of a demo setup so features/use cases can be shown off.\n\nThanks for using Roleypoly <3',
+ },
+ member: member,
+ guild: guild,
+ roles: guildRoles,
+ onSubmit: action('onSubmit'),
+ editable: false,
+ user: rpUser,
+ guildEnumeration: guildEnum,
+ activeGuildId: guild.id,
+};
+
+story.add('Role Picker', () => {
+ return ;
+});
diff --git a/src/design-system/templates/role-picker/RolePicker.tsx b/src/design-system/templates/role-picker/RolePicker.tsx
new file mode 100644
index 0000000..012f3b8
--- /dev/null
+++ b/src/design-system/templates/role-picker/RolePicker.tsx
@@ -0,0 +1,25 @@
+import * as React from 'react';
+import { AppShell } from 'organisms/app-shell';
+import { RolePicker, RolePickerProps } from 'organisms/role-picker';
+import { RoleypolyUser } from '@roleypoly/rpc/shared';
+import { GuildEnumeration } from '@roleypoly/rpc/platform';
+
+export type RolePickerTemplateProps = RolePickerProps & {
+ user: RoleypolyUser.AsObject;
+ guildEnumeration?: GuildEnumeration.AsObject;
+ activeGuildId?: string;
+};
+
+export const RolePickerTemplate = (props: RolePickerTemplateProps) => {
+ const { user, ...pickerProps } = props;
+ return (
+
+
+
+ );
+};
diff --git a/src/design-system/templates/role-picker/index.ts b/src/design-system/templates/role-picker/index.ts
new file mode 100644
index 0000000..f66adb0
--- /dev/null
+++ b/src/design-system/templates/role-picker/index.ts
@@ -0,0 +1 @@
+export * from './RolePicker';