浏览代码

feat: add command palette

Ahmad Kholid 2 年之前
父节点
当前提交
5564202cd8

+ 1 - 1
src/assets/css/tailwind.css

@@ -32,7 +32,7 @@
   @apply dark:border-gray-700;
 }
 
-body {
+body, :host {
   font-family: 'Inter var';
   font-size: 16px;
   font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';

+ 29 - 32
src/background/index.js

@@ -107,40 +107,36 @@ const workflow = {
       states: this.states,
     });
 
-    if (options?.resume) {
-      engine.resume(options.state);
-    } else {
-      engine.init();
-      engine.on('destroyed', ({ id, status }) => {
-        if (status === 'stopped') return;
-
-        browser.permissions
-          .contains({ permissions: ['notifications'] })
-          .then((hasPermission) => {
-            if (!hasPermission || !workflowData.settings.notification) return;
-
-            const name = workflowData.name.slice(0, 32);
-
-            browser.notifications.create(`logs:${id}`, {
-              type: 'basic',
-              iconUrl: browser.runtime.getURL('icon-128.png'),
-              title: status === 'success' ? 'Success' : 'Error',
-              message: `${
-                status === 'success' ? 'Successfully' : 'Failed'
-              } to run the "${name}" workflow`,
-            });
+    engine.init();
+    engine.on('destroyed', ({ id, status }) => {
+      if (status === 'stopped') return;
+
+      browser.permissions
+        .contains({ permissions: ['notifications'] })
+        .then((hasPermission) => {
+          if (!hasPermission || !workflowData.settings.notification) return;
+
+          const name = workflowData.name.slice(0, 32);
+
+          browser.notifications.create(`logs:${id}`, {
+            type: 'basic',
+            iconUrl: browser.runtime.getURL('icon-128.png'),
+            title: status === 'success' ? 'Success' : 'Error',
+            message: `${
+              status === 'success' ? 'Successfully' : 'Failed'
+            } to run the "${name}" workflow`,
           });
-      });
+        });
+    });
 
-      const lastCheckStatus = localStorage.getItem('check-status');
-      const isSameDay = dayjs().isSame(lastCheckStatus, 'day');
-      if (!isSameDay) {
-        fetchApi('/status')
-          .then((response) => response.json())
-          .then(() => {
-            localStorage.setItem('check-status', new Date());
-          });
-      }
+    const lastCheckStatus = localStorage.getItem('check-status');
+    const isSameDay = dayjs().isSame(lastCheckStatus, 'day');
+    if (!isSameDay) {
+      fetchApi('/status')
+        .then((response) => response.json())
+        .then(() => {
+          localStorage.setItem('check-status', new Date());
+        });
     }
 
     return engine;
@@ -556,6 +552,7 @@ message.on('workflow:execute', (workflowData, sender) => {
 
     workflowData.options.tabId = sender.tab.id;
   }
+  console.log(workflowData, 'anu');
 
   workflow.execute(workflowData, workflowData?.options || {});
 });

+ 74 - 2
src/components/newtab/workflow/edit/EditTrigger.vue

@@ -26,10 +26,58 @@
         />
       </keep-alive>
     </transition-expand>
+    <ui-button class="mt-4" @click="state.showModal = true">
+      <v-remixicon name="riCommandLine" class="mr-2 -ml-1" />
+      <span>Parameters</span>
+    </ui-button>
+    <ui-modal
+      v-model="state.showModal"
+      title="Parameters"
+      content-class="max-w-2xl"
+    >
+      <p class="leading-tight">
+        These parameters will be used when the workflow is executed from the
+        command palette
+      </p>
+      <ul
+        class="space-y-2 mt-2 overflow-auto scroll"
+        style="max-height: calc(100vh - 15rem)"
+      >
+        <li
+          v-for="(param, index) in state.parameters"
+          :key="index"
+          class="flex items-end space-x-2"
+        >
+          <ui-input
+            v-model="param.name"
+            label="Name"
+            placeholder="Parameter name"
+          />
+          <ui-select v-model="param.type" label="Type">
+            <option v-for="type in paramTypes" :key="type.id" :value="type.id">
+              {{ type.name }}
+            </option>
+          </ui-select>
+          <ui-input
+            v-model="param.placeholder"
+            label="Placeholder (optional)"
+            placeholder="A parameter"
+          />
+          <ui-button icon @click="state.parameters.splice(index, 1)">
+            <v-remixicon name="riDeleteBin7Line" />
+          </ui-button>
+        </li>
+      </ul>
+      <ui-button variant="accent" class="mt-4" @click="addParameter">
+        Add parameter
+      </ui-button>
+    </ui-modal>
   </div>
 </template>
 <script setup>
+import { reactive, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
+import cloneDeep from 'lodash.clonedeep';
 import TriggerDate from './Trigger/TriggerDate.vue';
 import TriggerInterval from './Trigger/TriggerInterval.vue';
 import TriggerVisitWeb from './Trigger/TriggerVisitWeb.vue';
@@ -46,8 +94,6 @@ const props = defineProps({
 });
 const emit = defineEmits(['update:data']);
 
-const { t } = useI18n();
-
 const triggers = {
   manual: null,
   interval: TriggerInterval,
@@ -59,8 +105,34 @@ const triggers = {
   'visit-web': TriggerVisitWeb,
   'keyboard-shortcut': TriggerKeyboardShortcut,
 };
+const paramTypes = [
+  { id: 'string', name: 'String' },
+  { id: 'number', name: 'Number' },
+];
+
+const { t } = useI18n();
+
+const state = reactive({
+  showModal: false,
+  parameters: cloneDeep(props.data.parameters || []),
+});
 
 function updateData(value) {
   emit('update:data', { ...props.data, ...value });
 }
+function addParameter() {
+  state.parameters.push({
+    name: 'Param',
+    type: 'string',
+    placeholder: 'Text',
+  });
+}
+
+watch(
+  () => state.parameters,
+  (parameters) => {
+    updateData({ parameters });
+  },
+  { deep: true }
+);
 </script>

+ 1 - 1
src/components/newtab/workflow/editor/EditorLocalActions.vue

@@ -764,7 +764,7 @@ const moreActions = [
     name: t('common.delete'),
     icon: 'riDeleteBin7Line',
     attrs: {
-      class: 'text-red-400 dark:text-gray-500',
+      class: 'text-red-400 dark:text-red-500',
     },
   },
 ].filter((item) => item.hasAccess);

+ 379 - 0
src/content/commandPalette/App.vue

@@ -0,0 +1,379 @@
+<template>
+  <div
+    v-if="state.active"
+    class="bg-black bg-opacity-50 fixed text-black h-full w-full top-0 left-0 p-4"
+    style="z-index: 99999999"
+    @click.self="state.active = false"
+  >
+    <ui-card
+      id="workflows-container"
+      class="absolute w-full max-w-2xl"
+      padding="p-0"
+      style="left: 50%; top: 70px; transform: translateX(-50%)"
+    >
+      <div class="p-4">
+        <label
+          class="flex items-center bg-input rounded-lg h-12 transition focus-within:ring-2 ring-accent px-2"
+        >
+          <img :src="logoUrl" class="h-8 w-8" />
+          <input
+            ref="inputRef"
+            type="text"
+            class="h-full flex-1 focus:ring-0 rounded-lg px-2 bg-transparent"
+            :placeholder="
+              paramsState.active
+                ? paramsState.workflow.name
+                : 'Search workflows...'
+            "
+            @input="onInput"
+            @keydown="onInputKeydown"
+          />
+          <template v-for="key in shortcutKeys" :key="key">
+            <span
+              class="rounded-md bg-box-transparent p-1 text-gray-600 ml-1 text-xs text-center inline-block border-2 border-gray-300 font-semibold"
+              style="min-width: 29px; font-family: inherit"
+            >
+              {{ key }}
+            </span>
+          </template>
+        </label>
+      </div>
+      <div
+        class="px-4 pb-4 overflow-auto scroll workflows-list"
+        style="max-height: calc(100vh - 200px)"
+      >
+        <div v-if="!state.retrieved" class="text-center mb-2">
+          <ui-spinner color="text-accent" />
+        </div>
+        <template v-else>
+          <div v-if="paramsState.active">
+            <div class="p-2 rounded-lg bg-box-transparent">
+              <p class="text-sm text-gray-500">Workflow parameters</p>
+              <div>
+                <span
+                  v-for="(item, index) in paramsState.items"
+                  :key="item.name"
+                  :class="{
+                    'font-semibold': paramsState.activeIndex === index,
+                  }"
+                >
+                  {{ item.name }};
+                </span>
+              </div>
+            </div>
+            <div class="pl-2 text-gray-500">
+              <p class="mt-2">
+                Example:
+                <span v-for="item in paramsState.items" :key="item.name">
+                  {{ item.placeholder || defaultPlaceholders[item.type] }};
+                </span>
+              </p>
+              <div class="flex items-center mt-4">
+                <p class="flex-1 mr-4">
+                  {{ paramsState.workflow.description }}
+                </p>
+                <p>
+                  Press
+                  <span
+                    class="rounded-md bg-box-transparent p-1 text-gray-600 ml-1 text-xs text-center inline-block border-2 border-gray-300 font-semibold"
+                  >
+                    Escape
+                  </span>
+                  to cancel
+                </p>
+              </div>
+            </div>
+          </div>
+          <template v-else>
+            <p
+              v-if="state.query && workflows.length === 0"
+              class="text-gray-600 text-center"
+            >
+              Can't find workflows
+            </p>
+            <ui-list v-else class="space-y-1">
+              <ui-list-item
+                v-for="(workflow, index) in workflows"
+                :id="`list-item-${index}`"
+                :key="workflow.id"
+                :active="index === state.selectedIndex"
+                small
+                color="bg-box-transparent list-item-active"
+                class="group cursor-pointer"
+                @mouseenter="state.selectedIndex = index"
+                @click="executeWorkflow(workflow)"
+              >
+                <div class="w-8">
+                  <img
+                    v-if="workflow.icon?.startsWith('http')"
+                    :src="workflow.icon"
+                    class="overflow-hidden rounded-lg"
+                    style="height: 26px; width: 26px"
+                    alt="Can not display"
+                  />
+                  <v-remixicon
+                    v-else
+                    :name="workflow.icon || 'riGlobalLine'"
+                    size="26"
+                  />
+                </div>
+                <div class="flex-1 overflow-hidden mx-2">
+                  <p class="text-overflow">
+                    {{ workflow.name }}
+                  </p>
+                  <p class="text-overflow text-gray-500 leading-tight">
+                    {{ workflow.description }}
+                  </p>
+                </div>
+                <v-remixicon
+                  name="riArrowGoForwardLine"
+                  class="text-gray-600 invisible group-hover:visible"
+                  size="20"
+                  rotate="180"
+                />
+              </ui-list-item>
+            </ui-list>
+          </template>
+        </template>
+      </div>
+    </ui-card>
+  </div>
+</template>
+<script setup>
+import {
+  onMounted,
+  onBeforeUnmount,
+  shallowReactive,
+  watch,
+  ref,
+  computed,
+  inject,
+} from 'vue';
+import browser from 'webextension-polyfill';
+import { sendMessage } from '@/utils/message';
+import { debounce } from '@/utils/helper';
+
+const defaultPlaceholders = {
+  string: 'Text',
+  number: '123123',
+};
+const isMac = navigator.appVersion.indexOf('Mac') !== -1;
+const logoUrl = browser.runtime.getURL('/icon-128.png');
+const shortcutKeys = [isMac ? '⌘' : 'Ctrl', 'Shift', 'A'];
+
+const inputRef = ref(null);
+const state = shallowReactive({
+  query: '',
+  active: false,
+  workflows: [],
+  selectedIndex: -1,
+});
+const paramsState = shallowReactive({
+  items: [],
+  workflow: {},
+  active: false,
+  paramNames: [],
+  activeIndex: 0,
+  inputtedVal: '',
+});
+
+const rootElement = inject('rootElement');
+
+const workflows = computed(() =>
+  state.workflows.filter((workflow) =>
+    workflow.name.toLocaleLowerCase().includes(state.query.toLocaleLowerCase())
+  )
+);
+
+function clearParamsState() {
+  Object.assign(paramsState, {
+    items: [],
+    workflow: {},
+    active: false,
+    activeIndex: 0,
+    inputtedVal: '',
+  });
+}
+function sendExecuteCommand(workflow, options = {}) {
+  const workflowData = {
+    ...workflow,
+    options,
+    includeTabId: true,
+  };
+
+  sendMessage('workflow:execute', workflowData, 'background');
+  state.active = false;
+}
+function executeWorkflow(workflow) {
+  if (!workflow) return;
+
+  let triggerData = workflow.trigger;
+  if (!triggerData) {
+    const triggerNode = workflow.drawflow?.nodes?.find(
+      (node) => node.label === 'trigger'
+    );
+    triggerData = triggerNode?.data;
+  }
+
+  if (triggerData?.parameters?.length > 0) {
+    const keys = new Set();
+    const params = [];
+    triggerData.parameters.forEach((param) => {
+      if (keys.has(param.name)) return;
+
+      params.push(param);
+      keys.add(param.name);
+    });
+
+    paramsState.workflow = workflow;
+    paramsState.items = triggerData.parameters;
+    paramsState.active = true;
+  } else {
+    sendExecuteCommand(workflow);
+  }
+
+  inputRef.value.value = '';
+  state.query = '';
+  paramsState.inputtedVal = '';
+}
+function onKeydown(event) {
+  const { ctrlKey, shiftKey, metaKey, key } = event;
+
+  if (key === 'Escape') {
+    if (paramsState.active) {
+      clearParamsState();
+    } else {
+      state.active = false;
+    }
+    return;
+  }
+
+  if (!(ctrlKey || metaKey) || !shiftKey || key !== 'A') return;
+
+  event.preventDefault();
+  state.active = true;
+}
+function onInputKeydown(event) {
+  const { key } = event;
+
+  if (key !== 'Escape') {
+    event.stopPropagation();
+  }
+
+  if (['ArrowDown', 'ArrowUp'].includes(key)) {
+    let nextIndex = state.selectedIndex;
+    const maxIndex = workflows.value.length - 1;
+
+    if (key === 'ArrowDown') {
+      nextIndex += 1;
+      if (nextIndex > maxIndex) nextIndex = 0;
+    } else if (key === 'ArrowUp') {
+      nextIndex -= 1;
+      if (nextIndex < 0) nextIndex = maxIndex;
+    }
+
+    state.selectedIndex = nextIndex;
+    return;
+  }
+
+  if (key === 'Enter') {
+    if (paramsState.active) {
+      const variables = {};
+      const values = paramsState.inputtedVal.split(';');
+
+      paramsState.items.forEach((item, index) => {
+        let value = values[index] ?? '';
+        if (item.type === 'number') value = +value ?? '';
+
+        variables[item.name] = value;
+      });
+
+      sendExecuteCommand(paramsState.workflow, { data: { variables } });
+
+      return;
+    }
+
+    executeWorkflow(workflows.value[state.selectedIndex]);
+  }
+}
+function checkInView(container, element, partial = false) {
+  const cTop = container.scrollTop;
+  const cBottom = cTop + container.clientHeight;
+
+  const eTop = element.offsetTop;
+  const eBottom = eTop + element.clientHeight;
+
+  const isTotal = eTop >= cTop && eBottom <= cBottom;
+  const isPartial =
+    partial &&
+    ((eTop < cTop && eBottom > cTop) || (eBottom > cBottom && eTop < cBottom));
+
+  return isTotal || isPartial;
+}
+function onInput(event) {
+  const { value } = event.target;
+
+  if (paramsState.active) {
+    paramsState.inputtedVal = value;
+    paramsState.activeIndex = value.split(';').length - 1;
+  } else {
+    state.query = value;
+  }
+}
+
+watch(inputRef, () => {
+  if (!inputRef.value) return;
+
+  inputRef.value.focus();
+});
+watch(
+  () => state.active,
+  async () => {
+    if (!state.retrieved && state.active) {
+      const {
+        workflows: localWorkflows,
+        workflowHosts,
+        teamWorkflows,
+      } = await browser.storage.local.get([
+        'workflows',
+        'workflowHosts',
+        'teamWorkflows',
+      ]);
+      state.workflows = [
+        ...Object.values(workflowHosts || {}),
+        ...Object.values(localWorkflows || {}),
+        ...Object.values(Object.values(teamWorkflows || {})[0] || {}),
+      ];
+
+      state.retrieved = true;
+    } else if (!state.active) {
+      clearParamsState();
+    }
+  }
+);
+watch(
+  () => state.selectedIndex,
+  debounce((activeIndex) => {
+    const container = rootElement.shadowRoot.querySelector(
+      '#workflows-container .workflows-list'
+    );
+    const element = rootElement.shadowRoot.querySelector(
+      `#list-item-${activeIndex}`
+    );
+
+    if (element && !checkInView(container, element)) {
+      element.scrollIntoView({
+        block: 'nearest',
+        behavior: 'smooth',
+      });
+    }
+  }, 100)
+);
+
+onMounted(() => {
+  window.addEventListener('keydown', onKeydown);
+});
+onBeforeUnmount(() => {
+  window.removeEventListener('keydown', onKeydown);
+});
+</script>

+ 24 - 0
src/content/commandPalette/compsUi.js

@@ -0,0 +1,24 @@
+import VAutofocus from '@/directives/VAutofocus';
+import UiCard from '@/components/ui/UiCard.vue';
+import UiInput from '@/components/ui/UiInput.vue';
+import UiList from '@/components/ui/UiList.vue';
+import UiListItem from '@/components/ui/UiListItem.vue';
+import UiButton from '@/components/ui/UiButton.vue';
+import UiSelect from '@/components/ui/UiSelect.vue';
+import UiSpinner from '@/components/ui/UiSpinner.vue';
+import UiTextarea from '@/components/ui/UiTextarea.vue';
+import TransitionExpand from '@/components/transitions/TransitionExpand.vue';
+
+export default function (app) {
+  app.component('UiCard', UiCard);
+  app.component('UiList', UiList);
+  app.component('UiListItem', UiListItem);
+  app.component('UiInput', UiInput);
+  app.component('UiButton', UiButton);
+  app.component('UiSelect', UiSelect);
+  app.component('UiSpinner', UiSpinner);
+  app.component('UiTextarea', UiTextarea);
+  app.component('TransitionExpand', TransitionExpand);
+
+  app.directive('autofocus', VAutofocus);
+}

+ 31 - 0
src/content/commandPalette/icons.js

@@ -0,0 +1,31 @@
+import {
+  riArrowGoForwardLine,
+  riGlobalLine,
+  riFileTextLine,
+  riEqualizerLine,
+  riTimerLine,
+  riCalendarLine,
+  riFlashlightLine,
+  riLightbulbFlashLine,
+  riDatabase2Line,
+  riWindowLine,
+  riCursorLine,
+  riDownloadLine,
+  riCommandLine,
+} from 'v-remixicon/icons';
+
+export default {
+  riArrowGoForwardLine,
+  riGlobalLine,
+  riFileTextLine,
+  riEqualizerLine,
+  riTimerLine,
+  riCalendarLine,
+  riFlashlightLine,
+  riLightbulbFlashLine,
+  riDatabase2Line,
+  riWindowLine,
+  riCursorLine,
+  riDownloadLine,
+  riCommandLine,
+};

+ 40 - 0
src/content/commandPalette/index.js

@@ -0,0 +1,40 @@
+import initApp from './main';
+import injectAppStyles from '../injectAppStyles';
+
+function pageLoaded() {
+  return new Promise((resolve) => {
+    const checkDocState = () => {
+      if (document.readyState === 'loading') {
+        setTimeout(checkDocState, 1000);
+        return;
+      }
+
+      resolve();
+    };
+
+    checkDocState();
+  });
+}
+
+export default async function () {
+  try {
+    const isMainFrame = window.self === window.top;
+    if (!isMainFrame) return;
+
+    await pageLoaded();
+
+    const instanceExist = document.querySelector('automa-palette');
+    if (instanceExist) return;
+
+    const element = document.createElement('div');
+    element.attachShadow({ mode: 'open' });
+    element.id = 'automa-palette';
+
+    await injectAppStyles(element.shadowRoot);
+    initApp(element);
+
+    document.body.appendChild(element);
+  } catch (error) {
+    console.error(error);
+  }
+}

+ 24 - 0
src/content/commandPalette/main.js

@@ -0,0 +1,24 @@
+import { createApp } from 'vue';
+import vRemixicon from 'v-remixicon';
+import App from './App.vue';
+import compsUi from './compsUi';
+import icons from './icons';
+
+const additionalStyle = `.list-item-active svg { visibility: visible }`;
+
+export default function (rootElement) {
+  const appRoot = document.createElement('div');
+  appRoot.setAttribute('id', 'app');
+
+  const style = document.createElement('style');
+  style.textContent = additionalStyle;
+
+  rootElement.shadowRoot.appendChild(style);
+  rootElement.shadowRoot.appendChild(appRoot);
+
+  createApp(App)
+    .use(compsUi)
+    .use(vRemixicon, icons)
+    .provide('rootElement', rootElement)
+    .mount(appRoot);
+}

+ 0 - 6
src/content/elementSelector/App.vue

@@ -287,12 +287,6 @@ onMounted(() => {
     cardRect.y = 20;
     cardRect.width = width;
     cardRect.height = height;
-
-    document.documentElement.style.setProperty(
-      'font-size',
-      '16px',
-      'important'
-    );
   }, 500);
 
   attachListeners();

+ 3 - 0
src/content/index.js

@@ -5,6 +5,7 @@ import { nanoid } from 'nanoid';
 import blocksHandler from './blocksHandler';
 import showExecutedBlock from './showExecutedBlock';
 import shortcutListener from './services/shortcutListener';
+import initCommandPalette from './commandPalette';
 // import elementObserver from './elementObserver';
 import { elementSelectorInstance } from './utils';
 
@@ -131,6 +132,8 @@ function messageListener({ data, source }) {
 (() => {
   if (window.isAutomaInjected) return;
 
+  initCommandPalette();
+
   window.isAutomaInjected = true;
   window.addEventListener('message', messageListener);
 

+ 0 - 5
src/content/utils.js

@@ -5,11 +5,6 @@ export function elementSelectorInstance() {
 
   if (rootElementExist) {
     rootElementExist.style.display = 'block';
-    document.documentElement.style.setProperty(
-      'font-size',
-      '16px',
-      'important'
-    );
 
     return true;
   }

+ 1 - 0
src/manifest.chrome.json

@@ -58,6 +58,7 @@
   "web_accessible_resources": [
     "/elementSelector.css",
     "/Inter-roman-latin.var.woff2",
+    "/icon-128.png",
     "/locales/*",
     "elementSelector.bundle.js"
   ]

+ 1 - 0
src/manifest.firefox.json

@@ -53,6 +53,7 @@
   ],
   "web_accessible_resources": [
     "/elementSelector.css",
+    "/icon-128.png",
     "/Inter-roman-latin.var.woff2",
     "/locales/*",
     "elementSelector.bundle.js"

+ 1 - 0
src/utils/shared.js

@@ -26,6 +26,7 @@ export const tasks = {
       days: [],
       contextMenuName: '',
       contextTypes: [],
+      parameters: [],
       observeElement: {
         selector: '',
         baseSelector: '',

+ 35 - 2
tailwind.config.js

@@ -1,5 +1,5 @@
-/* eslint-disable global-require */
-
+/* eslint-disable */
+const defaultTheme = require('tailwindcss/defaultTheme');
 const colors = require('tailwindcss/colors');
 
 function withOpacityValue(variable) {
@@ -10,11 +10,44 @@ function withOpacityValue(variable) {
     return `rgb(var(${variable}) / ${opacityValue})`;
   };
 }
+function rem2px(input, fontSize = 16) {
+  if (input == null) {
+    return input;
+  }
+
+  switch (typeof input) {
+    case 'object':
+      if (Array.isArray(input)) {
+        return input.map((val) => rem2px(val, fontSize));
+      }
+      const ret = {};
+      for (const key in input) {
+        ret[key] = rem2px(input[key]);
+      }
+      return ret;
+
+    case 'string':
+      return input.replace(
+        /(\d*\.?\d+)rem$/,
+        (_, val) => `${parseFloat(val) * fontSize}px`
+      );
+    default:
+      return input;
+  }
+}
 
 module.exports = {
   content: ['./src/**/*.{js,jsx,ts,tsx,vue}'],
   darkMode: 'class', // or 'media' or 'class'
   theme: {
+    borderRadius: rem2px(defaultTheme.borderRadius),
+    columns: rem2px(defaultTheme.columns),
+    fontSize: rem2px(defaultTheme.fontSize),
+    lineHeight: rem2px(defaultTheme.lineHeight),
+    maxWidth: ({ theme, breakpoints }) => ({
+      ...rem2px(defaultTheme.maxWidth({ theme, breakpoints })),
+    }),
+    spacing: rem2px(defaultTheme.spacing),
     extend: {
       colors: {
         primary: withOpacityValue('--color-primary'),

+ 18 - 0
testing.html

@@ -0,0 +1,18 @@
+<!DOCTYPE html>
+<html>
+<head>
+	<meta charset="utf-8">
+	<meta name="viewport" content="width=device-width, initial-scale=1">
+	<title></title>
+	<style type="text/css">
+		html {
+			height: 100vh;
+			width: 100vw;
+			background-color: #0f172a;
+		}
+	</style>
+</head>
+<body>
+	<p>hello</p>
+</body>
+</html>