Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 23 additions & 18 deletions __tests__/Speednote.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,13 +120,8 @@ test("renders properly", async () => {
await assertEditor();
assertHeader();

// Sanity checks for markups, check footer and header.
// Sanity checks for header.
expect(screen.getByText(/About/i)).toBeInTheDocument();
expect(
screen.getByText(
"Thank you so much for using Speednote! Made with ♥ in Tokyo, Japan",
),
).toBeInTheDocument();

// Should also render a link to GitHub.
const linkToSource = screen.getByRole("link", { name: /About/ });
Expand Down Expand Up @@ -178,8 +173,8 @@ test("able to edit title and content", async () => {
"Today I spent 1000 JPY for lunch at a fish shop",
);

// There should be a date that shows the last updated date as well.
expect(screen.getByText(/Last updated at/i)).toBeInTheDocument();
// There should be a timestamp showing when the note was last updated.
expect(screen.getByRole("note")).toBeInTheDocument();
});

test("able to clear content and undo clear", async () => {
Expand All @@ -195,14 +190,21 @@ test("able to clear content and undo clear", async () => {
});
expect(clearContentButton).toBeEnabled();
await user.click(clearContentButton);
expect(content).toHaveValue("");

// Query the `undoClearButton` again here.
const undoClearButton = screen.getByRole("button", { name: "Undo clear" });
expect(undoClearButton).toBeInTheDocument();
// findByRole waits for the re-render — both the empty content and the "Undo clear"
// button appearing are results of the same async click handler (handleClear).
const undoClearButton = await screen.findByRole("button", {
name: "Undo clear",
});
expect(content).toHaveValue("");
expect(undoClearButton).toBeEnabled();

await user.click(undoClearButton);
expect(content).toHaveValue("Tears Don't Fall, Enchanted, Beautiful Trauma");

// findByDisplayValue retries until handleUndo's state update has re-rendered the content.
await screen.findByDisplayValue(
"Tears Don't Fall, Enchanted, Beautiful Trauma",
);
});

test("able to switch color mode", async () => {
Expand All @@ -228,8 +230,11 @@ test("able to freeze notes and unfreeze them", async () => {
expect(freezeNoteButton).toBeEnabled();
await user.click(freezeNoteButton);

// Should have `readOnly` attribute.
expect(freezeNoteButton).toHaveAccessibleName("Unfreeze note");
// findByRole waits for the re-render, preventing a flake where the assertion
// runs before React processes the state update from the async click handler.
const unfreezeButton = await screen.findByRole("button", {
name: "Unfreeze note",
});
expect(title).toHaveAttribute("readOnly");
expect(content).toHaveAttribute("readOnly");

Expand All @@ -247,9 +252,9 @@ test("able to freeze notes and unfreeze them", async () => {
expect(clearContentButton).toBeDisabled();

// Unfreeze the note.
expect(freezeNoteButton).toBeEnabled();
await user.click(freezeNoteButton);
expect(freezeNoteButton).toHaveAccessibleName("Freeze note");
expect(unfreezeButton).toBeEnabled();
await user.click(unfreezeButton);
await screen.findByRole("button", { name: "Freeze note" });

// `Clear content` should not be disabled.
expect(clearContentButton).toBeEnabled();
Expand Down
11 changes: 5 additions & 6 deletions app/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,20 @@ import { Header } from "~/header";
* The GPU attempts to optimize by skipping the paint for the empty stretched area,
* resulting in dead, uninitialized tiles that render as solid black horizontal bands.
*
* By explicitly declaring the background colors (`bg-gray-50 dark:bg-slate-950`)
* By explicitly declaring the background colors (`bg-gray-50 dark:bg-stone-900`)
* on this flex container, we force the hardware compositor to paint the tiles,
* bypassing the optimization glitch.
*/
export const App = () => (
<div className="mx-auto flex min-h-screen max-w-7xl flex-col bg-gray-50 p-5 transition-colors duration-300 dark:bg-gray-950">
<div className="mx-auto flex min-h-screen max-w-7xl flex-col bg-gray-50 p-5 transition-colors duration-300 dark:bg-stone-900">
<Header />

<main className="flex flex-1 flex-col px-0 py-8 sm:px-2 md:px-6 lg:px-12 xl:px-20">
<main className="flex flex-1 flex-col px-0 py-8 sm:px-4 md:px-10 lg:px-20 xl:px-32">
<Editor />
</main>

<footer className="mt-auto text-center text-gray-500 text-xs transition-colors duration-300 dark:text-gray-600">
<p>Thank you so much for using Speednote! Made with ♥ in Tokyo, Japan</p>
<p className="mt-1.5 text-[0.5rem] opacity-60">
<footer className="mt-auto text-center text-[0.5rem] text-gray-400 opacity-60 transition-colors duration-300 dark:text-gray-600">
<p>
{import.meta.env.VITE_APP_VERSION} (
{import.meta.env.VITE_GIT_SHORT_SHA ?? "local"})
</p>
Expand Down
17 changes: 5 additions & 12 deletions app/editor/note-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,26 +21,19 @@ const Metadata = () => {
}

const formattedTimestamp = Intl.DateTimeFormat(undefined, {
dateStyle: "full",
dateStyle: "long",
hourCycle: "h23",
timeStyle: "full",
timeStyle: "medium",
}).format(lastUpdated);

return (
<section>
<time
className="font-semibold text-gray-400 text-xs transition-colors duration-300 sm:text-sm dark:text-gray-600"
className="text-gray-400 text-xs transition-colors duration-300 sm:text-sm dark:text-gray-600"
role="note"
>
Last updated at {formattedTimestamp}.
{save === "saving" ? "Saving..." : formattedTimestamp}
</time>

{save !== "idle" && (
<output className="font-semibold text-gray-400 text-xs transition-colors duration-300 before:content-['_'] sm:text-sm dark:text-gray-600">
{save === "saving" && "Saving..."}
{save === "saved" && "Saved."}
</output>
)}
</section>
);
};
Expand Down Expand Up @@ -74,7 +67,7 @@ const ContentEditor = () => {
onChange={({ currentTarget: { value } }) =>
setContent(value, Date.now())
}
placeholder="Start writing, your progress will be automatically stored in your machine's local storage"
placeholder="Start writing. Progress saves automatically."
readOnly={isFrozen}
type="content"
value={content}
Expand Down
14 changes: 3 additions & 11 deletions app/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import "@fontsource-variable/inter";
import "@fontsource-variable/inter/wght.css";

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { Toaster } from "sonner";
import { App } from "~/app";
import { ThemedToaster } from "~/toaster";

const root = document.getElementById("root");
if (!root) {
Expand All @@ -12,15 +12,7 @@ if (!root) {

createRoot(root).render(
<StrictMode>
<Toaster
closeButton
position="bottom-right"
toastOptions={{
className:
"bg-stone-50 dark:bg-stone-900 border-stone-200 dark:border-stone-800 text-stone-900 dark:text-stone-100",
duration: 2000,
}}
/>
<ThemedToaster />
<App />
</StrictMode>,
);
19 changes: 19 additions & 0 deletions app/toaster.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Toaster } from "sonner";
import { useIsDark } from "./use-dark-mode.ts";

export const ThemedToaster = () => {
const isDark = useIsDark();

return (
<Toaster
closeButton
position="bottom-right"
theme={isDark ? "dark" : "light"}
toastOptions={{
className:
"bg-stone-50 dark:bg-stone-900 border-stone-200 dark:border-stone-800 text-stone-900 dark:text-stone-100",
duration: 2000,
}}
/>
);
};
9 changes: 8 additions & 1 deletion app/use-dark-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,19 @@ DarkThemeStore.subscribe((isDark) => {
document.documentElement.classList.remove("dark");
});

/**
* Lightweight hook that only reads the dark theme state.
* Does not register any effects — safe to call from multiple components
* without duplicating listeners.
*/
export const useIsDark = () => useStore(DarkThemeStore, (state) => state);

/**
* Hook to use the dark theme. May be upgraded to include other
* themes in the future.
*/
export const useDarkTheme = () => {
const isDark = useStore(DarkThemeStore, (isDark) => isDark);
const isDark = useIsDark();

// Sync the theme with system preference.
useEffect(() => {
Expand Down
17 changes: 6 additions & 11 deletions e2e/Speednote.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,6 @@ test("renders properly", async ({ page }) => {

// Sanity checks for markups, check footer and header.
await expect(page.getByText("About")).toBeVisible();
await expect(
page.getByText(
"Thank you so much for using Speednote! Made with ♥ in Tokyo, Japan",
),
).toBeVisible();

// Should also render a link to GitHub.
const linkToSource = page.getByRole("link", { name: "About" });
Expand Down Expand Up @@ -176,8 +171,8 @@ test("able to edit title and content", async ({ page }) => {
"Today I spent 1000 JPY for lunch at a fish shop",
);

// There should be a date that shows the last updated date as well.
await expect(page.getByText(/Last updated at/i)).toBeVisible();
// There should be a timestamp showing when the note was last updated.
await expect(page.getByRole("note")).toBeVisible();
});

test("able to clear content and undo clear", async ({ page }) => {
Expand Down Expand Up @@ -640,8 +635,8 @@ test("able to restore autosave after hiding the app", async ({ page }) => {
document.dispatchEvent(new Event("visibilitychange"));
});

// Verify that the state is still `Saved.`
await expect(page.getByRole("status")).toContainText("Saved.");
// Verify that the save indicator is no longer in saving state.
await expect(page.getByRole("note")).not.toContainText("Saving...");

// Make the page visible again, and then try again.
await page.evaluate(() => {
Expand All @@ -656,8 +651,8 @@ test("able to restore autosave after hiding the app", async ({ page }) => {
// Fill it out again, and then check if the Autosave still works.
await title.fill("Autosave");
await content.fill("Autosave");
await expect(page.getByRole("status")).toContainText("Saving...");
await expect(page.getByRole("status")).toContainText("Saved.");
await expect(page.getByRole("note")).toContainText("Saving...");
await expect(page.getByRole("note")).not.toContainText("Saving...");

// Reload the page.
await page.reload();
Expand Down
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
<noscript>Please enable JavaScript to use Speednote.</noscript>
<div
id="root"
class="selection:bg-selection selection:text-black dark:bg-linear-to-b bg-gray-50 text-gray-800 transition-colors duration-300 dark:bg-gray-950 dark:text-gray-200"
class="selection:bg-selection selection:text-black bg-gray-50 text-gray-800 transition-colors duration-300 dark:bg-stone-900 dark:text-gray-200"
></div>
<script src="/app/index.tsx" type="module"></script>
</body>
Expand Down
1 change: 0 additions & 1 deletion tsconfig.app.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@

/* Custom */
"types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"],
"baseUrl": "./",
"paths": {
"~/*": ["./app/*"]
}
Expand Down
2 changes: 1 addition & 1 deletion vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export default defineConfig({
devOptions: {
enabled: true,
},
filename: "sw.ts",
filename: "sw.js",
manifest: {
background_color: "#f1c40f",
description: "Speednote, the fastest way to put your quick thoughts!",
Expand Down