diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..bb98c83 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +# http://editorconfig.org +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true +max_line_length = 100 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c30d42c --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +DRIZZLE_DATABASE_URL=YOUR_DATABASE_URL \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index bffb357..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": "next/core-web-vitals" -} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..957b532 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + pull_request: + push: + branches: [main, next] + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.4.0 + + - run: bun install --frozen-lockfile + - run: bunx next typegen + - run: bun run lint + - run: bun run typecheck diff --git a/.gitignore b/.gitignore index fd3dbb5..bad4605 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,9 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# content collections +.content-collections + +# claude +.claude \ No newline at end of file diff --git a/.prettierrc b/.prettierrc index f351876..b0fa65e 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,7 +1,11 @@ { + "printWidth": 120, "semi": false, "trailingComma": "all", "singleQuote": true, "tabWidth": 2, - "useTabs": false -} + "useTabs": false, + "plugins": [ + "prettier-plugin-tailwindcss" + ] +} \ No newline at end of file diff --git a/README.md b/README.md index 3468c4a..39b0fc5 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,55 @@ # evowizz.dev -This repository hosts the source code for my personal website. +The source for [evowizz.dev](https://evowizz.dev), my personal website and writing archive. It is built with Next.js, React, Tailwind CSS, Content Collections, Drizzle ORM, and Neon Postgres. -## Getting Started +## Local development -To get a local copy up and running, follow these simple steps: +You need [Bun](https://bun.sh) and Node.js 24. -1. Clone the repository: -```bash -git clone https://github.com/evowizz/evowizz.dev.git -``` +1. Clone the repository and enter it: + + ```bash + git clone https://github.com/evowizz/evowizz.dev.git + cd evowizz.dev + ``` 2. Install the dependencies: -```bash -bun install -``` -3. Run the development server: + ```bash + bun install + ``` + +3. Create the local environment file: + + ```bash + cp .env.example .env.local + ``` + +4. Set `DRIZZLE_DATABASE_URL` in `.env.local` to a Postgres connection string, then apply the schema: + + ```bash + bun run db:migrate + ``` + +5. Start the development server on [localhost:3000](http://localhost:3000): + + ```bash + bun run dev + ``` + +## Quality checks + +Run the same checks as CI before opening a pull request: + ```bash -bun dev +bun run lint +bun run typecheck ``` ## Contributing -At the moment, no new features are being accepted. However, if you find a bug or have a suggestion, feel free to open an issue. +New features are not currently being accepted, but bug reports and focused fixes are welcome. Please open an issue before larger changes. ## License -```text -Copyright 2024 Dylan Roussel - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -``` \ No newline at end of file +Licensed under the [Apache License 2.0](LICENSE). diff --git a/bun.lockb b/bun.lockb index 13a73c8..6be925f 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/content-collections.ts b/content-collections.ts new file mode 100644 index 0000000..a8f34c4 --- /dev/null +++ b/content-collections.ts @@ -0,0 +1,127 @@ +import { Context, defineCollection, defineConfig, Meta } from '@content-collections/core' +import { z } from 'zod' +import { compileMDX } from '@content-collections/mdx' +import rehypePrettyCode from 'rehype-pretty-code' +import rehypeSlug from 'rehype-slug' +import remarkGfm from 'remark-gfm' +import { getColor } from 'colorthief' +import path from 'path' +import { argbFromRgb, hexFromArgb, Variant } from '@evowizz/material-color-utilities-canary' + +const VARIANT_MAP: Record = { + monochrome: Variant.MONOCHROME, + neutral: Variant.NEUTRAL, + tonal_spot: Variant.TONAL_SPOT, + vibrant: Variant.VIBRANT, + expressive: Variant.EXPRESSIVE, + fidelity: Variant.FIDELITY, + content: Variant.CONTENT, + rainbow: Variant.RAINBOW, + fruit_salad: Variant.FRUIT_SALAD, +} + +type VariantName = keyof typeof VARIANT_MAP +const VARIANT_NAMES = Object.keys(VARIANT_MAP) as [VariantName, ...VariantName[]] + +type TransformInput = { + _meta: Meta + content: string + themeColor?: string + themeVariant?: VariantName + image?: string +} + +async function transform(document: T, context: Context) { + const mdx = await compileMDX(context, document, { + remarkPlugins: [remarkGfm], + rehypePlugins: [ + rehypeSlug, + [ + rehypePrettyCode, + { + theme: { + light: 'github-light', + dark: 'dark-plus', + }, + keepBackground: false, + }, + ], + ], + }) + + let themeColor = document.themeColor + if (!themeColor && document.image) { + themeColor = (await extractColorFromImage(document.image)) ?? undefined + } + + // Without a color, ThemeOverride keeps the site theme, so the variant stays unset too. + let themeVariant = document.themeVariant ? VARIANT_MAP[document.themeVariant] : undefined + if (themeVariant === undefined && themeColor) { + themeVariant = Variant.TONAL_SPOT + } + + return { + ...document, + slug: document._meta.path, + themeColor, + themeVariant, + mdx, + } +} + +const posts = defineCollection({ + name: 'posts', + directory: 'content/posts', + include: '**/*.mdx', + schema: z.object({ + title: z.string(), + publishedAt: z.string(), + summary: z.string(), + content: z.string(), + image: z.string().optional(), + themeColor: z.string().optional(), + themeVariant: z.enum(VARIANT_NAMES).optional(), + hidden: z.boolean().default(false), + }), + transform, +}) + +const caseStudies = defineCollection({ + name: 'caseStudies', + directory: 'content/case-studies', + include: '**/*.mdx', + schema: z.object({ + title: z.string(), + overview: z.string(), + stack: z.array(z.string()), + role: z.string().optional(), + image: z.string(), + themeColor: z.string().optional(), + themeVariant: z.enum(VARIANT_NAMES).optional(), + content: z.string(), + hidden: z.boolean().default(false), + }), + transform, +}) + +export default defineConfig({ + content: [posts, caseStudies], +}) + +async function extractColorFromImage(imagePath: string): Promise { + if (imagePath.startsWith('http') || imagePath.startsWith('/api/')) { + return null + } + + try { + const fullPath = path.join(process.cwd(), 'public', imagePath) + // colorthief v3 quantizes in OKLCH by default, so pin to rgb to match prior output + const color = await getColor(fullPath, { colorSpace: 'rgb' }) + if (!color) return null + const { r, g, b } = color.rgb() + return hexFromArgb(argbFromRgb(r, g, b)) + } catch (error) { + console.warn(`Failed to extract color from ${imagePath}:`, error) + return null + } +} diff --git a/content/case-studies/inware.mdx b/content/case-studies/inware.mdx new file mode 100644 index 0000000..0e2321f --- /dev/null +++ b/content/case-studies/inware.mdx @@ -0,0 +1,89 @@ +--- +title: 'Inware 7' +overview: 'Designing a clearer way to explore everything Android can tell you about a device.' +stack: ['Material 3', 'Kotlin', 'Jetpack Compose', 'Navigation 3'] +role: 'Design and development' +image: '/content/case-studies/inware/hero.png' +themeColor: '#33CC7A' +themeVariant: 'rainbow' +--- + +Inware 7 is a complete overhaul of my Android device information app. It shipped in April 2026 and has kept moving since. It's the completion of the Jetpack Compose transition, and a complete Material 3 Expressive redesign. I had no specific design in mind, only certain constraints. Every decision was implemented and tested live on a device so the motion could be judged for real, whether by me or by testers. That is how I made sure Inware felt right. + +## Jetpack Compose + +I started fiddling with Jetpack Compose in 2019, and tracked its progress ever since. In 2020, before Compose was even stable, I published [compose-to-edge](https://github.com/evowizz/compose-to-edge), a small library for going edge-to-edge in Compose apps. In 2022, I started implementing Compose in Inware: I knew Google was betting on it. It was showing up in Android itself, in Settings and SystemUI, and Google had built Now in Android, a reference app written with it. So while Android Views were still used at the time, Compose was a modern toolkit that was actively being used and developed. By the last 6.x release, various components of Inware were already Compose, but it was still a hybrid app. + +Inware 7 is the first version fully written in Compose, a decision Google confirmed earlier in 2026, when [Android UI officially went Compose first](https://android-developers.googleblog.com/2026/05/android-ui-development-is-compose-first.html). + +## From 6 to 7 + +For years, Inware relied on Overpass Mono as its brand font. The font, along with the cards of the home screen, made Inware easy to recognize up to version 6. With Inware 7, things changed. While the brand color remains `#33CC7A` and dynamic colors are still supported, Google Sans Flex now replaces Overpass Mono. It is a variable font with many axes to tune, it is visually balanced, and it is the font Pixel devices use, so it feels at home on Android. Users who would rather not have it can switch to the system font in the settings. The cards are also gone. The refresh went all the way down to the app icon and the splash screen. + +![The Inware 6 and Inware 7 app icons side by side](/content/case-studies/inware/inware-logos.png 'The Inware 6 icon on the left, the Inware 7 icon on the right.') + +Inware is represented by a chip as its icon, and the new logo introduced with version 7 keeps it. The old logo was a good one, but it felt too generic, too simple. And as the app evolves, so should the logo. We're entering an era where modern logos use gradients: Android app icons are getting redesigned all over the place, and as Inware is built to fit Android, its logo should follow. The changes remain small: a grid as the background, smoothed corners, tweaked colors, etc. So Inware remains easy to identify, but feels modern. + +Inware is made to embrace Android: it follows Material closely, and it should feel like it belongs on the device it describes. This is why dynamic colors are enabled by default. Like the system, the app can take the colors of the wallpaper. Dynamic colors are only as good as the device's implementation, though. On a Pixel, they behave the way Material intends. On Samsung's One UI, they are more of an adventure. Fixing that is on Samsung, not on apps, so Inware simply lets users turn dynamic colors off. + +![The Inware 6 and Inware 7 home screens side by side](/content/case-studies/inware/home.png 'Inware 6 on the left, Inware 7 on the right') + +The home screen of Inware 6 was a grid of large cards, each showing an icon and a name. The cards did not describe the pages behind them, so the only way to know what a page contained was to open it. + +In Inware 7, every destination is still there, but each one now has a description. And above the list, a dashboard shows a few live values, through tiles that users can pick and arrange themselves. + +## Navigation + +![The floating toolbar from the Inware 7 beta](/content/case-studies/inware/toolbar.png 'The floating toolbar, as it appeared in the beta.') + +The home screen did not start out as a single screen. Initially, the dashboard, the explore page with the list of destinations, and the settings were three separate pages. The plan was to move between them with a Navigation Drawer, but I switched away from it before it was ever released: in a drawer, the dashboard would have been yet another page in a list, sitting somewhere between Device and Hardware. A floating toolbar at the bottom took its place. It worked well. + +A dedicated dashboard page had room to preview a lot more tiles as soon as the app opened. But testers highlighted how it became harder for them to quickly jump to a specific data page. + +One way to do that would have been to make the explore page the default, or to make the default page configurable. But in both cases, the dashboard page would have become less useful. Instead, I merged both pages into a single screen, and one of my favorite components made it possible: the Backdrop. + +The dashboard became a back layer of live data, the destinations became a front layer that you can drag over it, and the settings moved into a button at the top. The Backdrop was released in beta, and people quickly preferred it to the toolbar. + +## Dashboard + +![A close-up of the Inware 7 tiles](/content/case-studies/inware/tiles.png 'Four tiles on the first page of the dashboard.') + +The dashboard is made of tiles. Each of its pages contains four of them. When four tiles are not enough, additional pages are just a swipe away. Every tile has the same size, which allows the grid to adapt to any screen without the tiles having to change. However, not everyone cares about the same data, which is why users can also customize their dashboard. + +![The edit dashboard, with the tile catalog below the grid](/content/case-studies/inware/edit.png 'The edit screen, with the catalog of unused tiles below the grid.') + +In edit mode, users can reorder tiles, add them, or remove them. Tiles that are not in use sit in a catalog below the grid, grouped by destination. You can't really break anything while editing. An undo button shows up after your first change and reverts changes one by one, and the reset button stays disabled until the dashboard no longer matches the default. + +The editing system was also made to look like the editor of Android's own Quick Settings panel. Inware is built to stay close to how the system works, and this was a way to avoid asking users to learn a new system: they can edit their dashboard the same way they already edit their QS Tiles. + +## Inside a page + +![A tile and the page it opens](/content/case-studies/inware/tile-and-page.png 'The battery tile on the dashboard, and the page it opens.') + +Each tile is both a preview and an entry point. The battery tile only shows the level. The page it opens has the rest: health, cycle count, charge current, temperature, and whatever else the device can report. + +I also made every destination declare its parent. Back navigation and `iw://` deep links both rely on that declaration, so a page opened from a link behaves exactly like a page opened by hand. + +![A long press on a value opens copy and share](/content/case-studies/inware/long-press.png 'The copy and share menu, here on the battery cycle count.') + +The values are the reason people open Inware, so I made them easy to take out of it. A long press on any value opens a small menu to copy or share it, without going through a screenshot. It may sound like a detail, but values like a kernel version or a build number are long, and nobody should have to retype them by hand. + +Material 3 places menus relative to the component that opened them, and prefers extending to the right, which for a long press often puts the menu under the thumb of a right handed user. Inware handles the menu position differently. I believe a touch context menu should open away from the hand, so Inware uses a custom position provider that opens the menu on the opposite side of the press. You usually press on the side your thumb rests on, so a long press on the right side of the app opens the menu toward the left, and a long press on the left opens it toward the right. I [shared that view](https://issuetracker.google.com/issues/499330952) with the Material team. + +## Bottom sheets + +![A confirmation presented as a bottom sheet](/content/case-studies/inware/bottom-sheet.png 'A confirmation as a bottom sheet, here for enabling developer options.') + +A confirmation like this is usually a dialog: a card in the middle of the screen, two small buttons in a row. On a tall phone, that puts the decision away from the thumb, and it leaves little room for the warning itself. + +Inware uses a bottom sheet instead, which is what the Material 3 guidelines suggest: on mobile, a modal bottom sheet [is recommended](https://m3.material.io/components/bottom-sheets/guidelines#1cb775b6-6d2b-4d50-96ad-1862727e986b) as an alternative to simple dialogs and inline menus, especially when actions need icons or longer descriptions. The page stays visible behind a scrim, the sheet sits where the hand already is, and Proceed and Cancel are full-width rows rather than dialog buttons. This one cannot be dismissed by tapping outside or by going back, and since it cannot be swiped away either, it does not show a drag handle. Enabling developer options is a real choice, so the sheet waits for one. + +The same sheet is used for confirmations and for lists of actions, so a warning does not look like a different kind of UI from the rest of the app. + +## Big screens (in beta) + +![The split layout on an unfolded foldable](/content/case-studies/inware/split.png 'The split layout on an unfolded foldable. The divider follows the hinge.') + +On large screens, Inware uses a list detail layout: the destinations stay on the left, and the selected page opens next to them. The list could not simply transform into a Navigation Drawer or a Navigation Rail, because the selected page would then be alone on the screen, either quite large or centered with empty space around it, and the dashboard would have had nowhere to sit. Material 3 provides a scaffold for this layout, and while it animates the transition between the list and a page, it does not animate the move from one page to the next, which in Inware is most of the movement. So I wrapped the Material 3 scaffold into my own and added the missing transition. It ships in 7.1.0. + +Inware 7 did not stop at 7.0. It is still built the same way: every change is tested live on a device, then sent to the testers. The Backdrop came out of that, so did the dashboard and its editor, and so will the list detail layout. That's Inware for you. diff --git a/content/case-studies/template.mdx b/content/case-studies/template.mdx new file mode 100644 index 0000000..b43a0bb --- /dev/null +++ b/content/case-studies/template.mdx @@ -0,0 +1,20 @@ +--- +title: 'Template' +overview: 'This is a template case study for development purposes.' +stack: ['Template'] +role: 'Template Role' +image: '/api/placeholder/1200/600' +hidden: true +--- + +This is a placeholder case study used during development. + +![A wide placeholder screenshot](/api/placeholder/1600/900 'A landscape placeholder, standing in for a screenshot.') + +Sections further down get their own figures too, so spacing and caption alignment can be checked against real content. + +![A portrait placeholder screenshot](/api/placeholder/1200/1500 'A portrait placeholder, for tall screens or detail shots.') + +Full-bleed figures should escape the container to the viewport edges, while this text and the captions above stay inside the column. + +![A short, wide placeholder screenshot](/api/placeholder/1600/700 'A short, wide placeholder, for a workflow overview.') diff --git a/content/posts/ai-wont-replace-developers-yet.mdx b/content/posts/ai-wont-replace-developers-yet.mdx new file mode 100644 index 0000000..6785c47 --- /dev/null +++ b/content/posts/ai-wont-replace-developers-yet.mdx @@ -0,0 +1,58 @@ +--- +title: "AI won't replace developers... yet." +publishedAt: '2023-01-21' +summary: "Your job is safe, don't worry." +image: '/content/posts/img/ai-wont-replace-developers.png' +hidden: true +--- + +On November 30th, 2022, ChatGPT launched. As I write this piece, it has been less than 8 weeks since its launch, and yet I am not aware of any developer who has not tried interacting with ChatGPT at least once. + +While ChatGPT is still a research preview, many people are starting to rely on it to learn and understand various subjects, or just to do various tasks and assignments they have been given. Of course, I've been playing with ChatGPT too. + +# ChatGPT... That coworker + +I began rewriting my website from scratch before ChatGPT launched. A few days later, ChatGPT was released. I didn't know what to ask at first, but I quickly realized that it wasn't anything like Google Assistant or Siri. It's not pretending to be human by replying to personal questions such as “How are you?”. This is when I started asking questions related to development. Since then, whenever I need to solve a development issue, it’s often my first choice. + +ChatGPT is comparable to Google, and also StackOverflow. You provide instructions or send a question, and you get results in return. But the difference is, you can provide a very specific context with ChatGPT, and you can narrow it down if needed. Its training data includes information up until 2021… However, it’s also able to remember previous messages sent in the same conversation and receive instructions. So even if, for example, Next.js 13 wasn’t released until 2022, ChatGPT is able to provide a correct solution to the following question: + +> Next.js 13 is out, what should I change in my package.json? + +This is important because it means it’s no longer providing existing information, but rather, creating information. And this is why ChatGPT can be your best coworker yet. + +# Don’t trust AI + +People will sometimes say "Don't trust AI," and to an extent they're right. As mentioned before, ChatGPT works based on context you provide. This is the case for most AI out there. The more context you add to your query, the better the result provided by the AI will be. However, it also implies that if your query does not have enough context, the result the AI will send back may not match your expectations. This may be fine if you know what your doing, maybe you’re just looking for a part of the answer. But, what if you’re trying to learn something you don’t know anything about? You may end up learning something that is just not true. Artificial intelligences aren’t a source of truth. + +# AI isn’t engineering + +I had a very insightful discussion with Kyle Bradshaw earlier this month about this exact article. Notably, Kyle said the following: + +> Oftentimes, what an AI is doing for programming isn't engineering, it's the manual labor. +> +> An engineer/developer/whatever knows what they want, maybe describes it with a code comment (to give Copilot a hint), and starts writing to activate an autocomplete suggestion. +> It's the developer's job to read the output, decide if it's valid, and accept/reject it accordingly. + +If you’ve used Copilot, you know how it often makes suggestions that don’t correspond to what you’re looking for. Fortunately, Copilot can only be used by developers. Not anyone can fix parts of code that aren’t working. + +# The perfect AI Developer + +Let’s Imagine an AI — let’s call it Bob — that can generate an entire Android app based on an input. It only returns the compiled Android app and the source code for it when its job is finished. While it might sound appealing as is, the number of things to make our Bob work is just insane. Here are a few things Bob would need to know: + +1. App name, package name, logo, and other assets +2. Target languages/regions for translations +3. APIs the app should use +4. The architecture of an Android app (which, admittedly, does not change very often) +5. Latest Android development trends + +And this is just a drop in the ocean compared to the number of things it would actually need to know to be able to generate a fully working Android app. But even if it could create an Android app… Should it? + +# The danger of Bob + +The idea behind Bob is simple: Generate an app based on instructions. But as previously established, you should not blindly trust an AI for something you do not understand. In the case of Bob, while the end result may appear visually correct, under the hood it may contain various flaws. + +What if, internally, the app generated by Bob sends data to an API you don’t own? What if Bob learned a vulnerability and actively uses it to obtain data? What if the generated app just contains a vulnerability itself, allowing external attackers to obtain user data? Human Developers would be able to find all of those flaws and fix them, but it would require some time to learn the code base. Time that those developers could have spent on the app itself, and preventing this sort of incident. + +Of course, our example is about an Android application, but this is more or less valid for all kinds of software. And while I’m an Android Developer, I wonder what an AI would say about this… + +![](/content/posts/img/ai-wont-replace-developers-1.png) diff --git a/content/posts/android-haptics.mdx b/content/posts/android-haptics.mdx new file mode 100644 index 0000000..4e17d8d --- /dev/null +++ b/content/posts/android-haptics.mdx @@ -0,0 +1,159 @@ +--- +title: 'Android is evolving, and so are haptics!' +publishedAt: '2026-04-07' +summary: "Big changes are coming to Android haptics, and we're only seeing the tip of the iceberg." +image: '/content/posts/img/android-haptics.jpg' +themeColor: '#4285F4' +--- + +Over the past couple of months, I've been learning and keeping a close eye on Android haptics. Big changes are being added, and we're only seeing the tip of the iceberg. + +**Note:** This article is mostly about MSDL. I wrote about it a couple of months ago, but this article goes much deeper into it. See: + + + +## The system we grew up with + +For years, Android has offered a very simple system for haptic feedback. Developers select the feedback based on the action: if the user taps a button, the feedback is "CLICK." If the action is a long press, it's "LONG_PRESS." + +This may seem basic by today's standards, but this naming convention is what kept apps consistent. Many users have adapted to haptic feedback without even realizing it. If you type on your keyboard and a key doesn't vibrate, you notice something went wrong. It means you've adapted to haptic feedback. + +But haptics aren't the only form of feedback. Sound plays a role too, like the click you hear with each keystroke. Visual, haptic, and sound feedback all work together to confirm your actions, and that combination is also important for accessibility. The problem is that Android's systems for sound and haptic feedback have always been separate. + +But what if haptics and sound were unified? + +## Meet MSDL... + +MSDL stands for Multi-Sensory Design Language. + +Think of Material Design, but for your senses. MSDL defines how interactions are perceived. It combines multiple sensory feedback systems into one. + +Since Android 16, MSDL is part of SystemUI. Each time you toggle a QS Tile, unlock your device, drag your brightness slider... You feel a part of MSDL. + +MSDL isn't just about combining haptics and sound. It's a modern take on feedback. It not only combines them, but also improves them. There are quite a few new tokens (or actions), such as "START", "PAUSE", "STOP", "DRAG_INDICATOR_CONTINUOUS," etc. + +Not all tokens are currently being used. MSDL also defines 4 different feedback levels: "None" (no feedback), "Minimal" (Critical feedback, always play), "Default" (standard feedback), and "Expressive" (decorative feedback). We may see an option for these in Android settings in the future. + +**But wait, there's... no sound?**\ +That's true. Google created this entire system that combines haptics and sound, yet there's no sound. It's unclear why it was not finished. But it still appears to be a work in progress! + +## ... and Mechanics! + +As mentioned, MSDL is a modern take on feedback. Mechanics is a companion to MSDL. Mechanics is all of the physics. It translates gestures into feedback based on motion. + +As a companion to MSDL, it also is part of SystemUI since Android 16. Each time you've swiped a notification away and felt the magnetic pull on them, that was it! Those haptics are the results of Mechanics. + +In fact Google went pretty far into the physics, and the way Mechanics on its own works is absolutely worth studying. + +Mechanics is built on real world physics. The feedback feels natural because it's calculated using real physical laws. If you accelerate suddenly, you feel it. If you slow down, you feel that too. This is what makes effects like magnetism possible. + +[A study from 1969](https://link.springer.com/article/10.3758/BF03212793) found that vibration doesn't feel the way it physically vibrates. When you feel a vibration (any vibration), if the amplitude of this vibration is doubled, that vibration will not feel twice as strong because of how human skin works. This gap between the way it feels (perceived intensity) and the way it linearly changed (physical intensity) was in fact calculated by the researchers. And they found it follows a power law: perceived = physical^0.89. + +In Mechanics, Google is correcting skin non-linearity by correcting the output (physical = perceived^(1/0.89)) so that it feels perceptually linear to you! + +But Android reports touch input in pixels, and spring physics need meters and Newtons. So Google converts pixels into meters using the device's screen density. This has a nice side effect: the same gesture feels the same on any device, because the math is based on real world units, not screen dependent pixels. + +## Compose gets the tone + +While MSDL is evolving inside SystemUI, Compose has had no built-in sound infrastructure at all. You may not have noticed, but every Compose app has been silently skipping the click sounds that View-based apps have always played automatically. + +Fortunately, a new change currently in review adds sound infrastructure to Compose for the first time! For now, it only adds a single method: `playClickSound()`. It plays a click sound when you tap a clickable component, just like the View system has always done. + +But the foundation is extensible. The sound interface is abstract and swappable, which makes it exactly the kind of hook point where MSDL could plug in down the road. + +## And then there was Inware. + +For the past couple of months I've been working on haptics in Inware. It implements a system based on MSDL and inspired by Mechanics. + +Inware implements 18 different interactive tokens, each with a minimum feedback level. The same 4 levels exist: None, Minimal, Default and Expressive with the same logic. The level is not meant to change the feedback, but rather define which ones can be played or not. You can change this in Inware settings, and test all of them in Inware's developer options. + +Inware does not implement any sound token at the moment. I want Inware to feel like it belongs on Android. And adding custom sound would cause too many differences with AOSP, and other Android-based systems. + +```kotlin +package dev.evowizz.inware.core.haptics + +import dev.evowizz.inware.core.haptics.internal.HapticToken + +/** Semantic interaction tokens. Each maps to a HapticToken + FeedbackLevel. */ +enum class InteractionToken( + val hapticToken: HapticToken, + val minimumLevel: FeedbackLevel, +) { + // Taps + TAP(HapticToken.TAP_MEDIUM_EMPHASIS, FeedbackLevel.DEFAULT), + TAP_LOW(HapticToken.TAP_LOW_EMPHASIS, FeedbackLevel.EXPRESSIVE), + TAP_HIGH(HapticToken.TAP_HIGH_EMPHASIS, FeedbackLevel.EXPRESSIVE), + LONG_PRESS(HapticToken.LONG_PRESS, FeedbackLevel.MINIMAL), + DOUBLE_TAP(HapticToken.DOUBLE_TAP, FeedbackLevel.DEFAULT), + + // Toggles (both use same haptic pattern) + TOGGLE_ON(HapticToken.POSITIVE_CONFIRMATION_MEDIUM_EMPHASIS, FeedbackLevel.DEFAULT), + TOGGLE_OFF(HapticToken.POSITIVE_CONFIRMATION_MEDIUM_EMPHASIS, FeedbackLevel.DEFAULT), + + // Confirmations + CONFIRM(HapticToken.POSITIVE_CONFIRMATION_MEDIUM_EMPHASIS, FeedbackLevel.DEFAULT), + REJECT(HapticToken.NEGATIVE_CONFIRMATION_MEDIUM_EMPHASIS, FeedbackLevel.DEFAULT), + FAILURE_HIGH(HapticToken.NEGATIVE_CONFIRMATION_HIGH_EMPHASIS, FeedbackLevel.MINIMAL), + + // Alerts (risky user actions) + ALERT(HapticToken.ALERT_MEDIUM_EMPHASIS, FeedbackLevel.DEFAULT), + ALERT_LOW(HapticToken.ALERT_LOW_EMPHASIS, FeedbackLevel.EXPRESSIVE), + ALERT_HIGH(HapticToken.ALERT_HIGH_EMPHASIS, FeedbackLevel.MINIMAL), + + // Gestures + DRAG_THRESHOLD(HapticToken.DRAG_THRESHOLD_INDICATOR, FeedbackLevel.DEFAULT), + DRAG_CONTINUOUS(HapticToken.DRAG_INDICATOR_CONTINUOUS, FeedbackLevel.DEFAULT), + DRAG_DISCRETE(HapticToken.DRAG_INDICATOR_DISCRETE, FeedbackLevel.DEFAULT), + + // Navigation + NAVIGATION_CHANGE(HapticToken.NAVIGATION_TICK, FeedbackLevel.EXPRESSIVE), + NAVIGATE_BACK(HapticToken.TAP_MEDIUM_EMPHASIS, FeedbackLevel.DEFAULT), +} +``` + +## Conclusion + +MSDL and Mechanics are shaping the future of sound and haptic feedback on Android. They are currently limited to SystemUI, but they are preparing for much bigger changes. MSDL's sound tokens are there, they are simply waiting for their assets to be added, and the feedback level setting is ready to be implemented in Android's settings. + +I cannot wait to see how those will evolve in the future! And when they do, Inware will be ready :) + +## Key Resources + + + +_This article was initially published [on X](https://x.com/evowizz/article/2041541076922622392)._ diff --git a/content/posts/first-look-m3-compose-adaptive.mdx b/content/posts/first-look-m3-compose-adaptive.mdx new file mode 100644 index 0000000..edf5367 --- /dev/null +++ b/content/posts/first-look-m3-compose-adaptive.mdx @@ -0,0 +1,144 @@ +--- +title: 'Material 3 Adaptive: Making Responsive Layouts easily' +publishedAt: '2023-08-28' +summary: 'A glimpse at Google’s next Material 3 library for Jetpack Compose.' +--- + +Over the past few months, the Material team has been working on a new Jetpack Compose library: “Material 3 Adaptive.” It will allow you to create responsive layouts in a much easier way than is currently possible. While this new library is not yet formally released, I couldn't resist giving it a try and taking this opportunity to give you an early glimpse at it. + +Currently, Google's [recommended way](https://developer.android.com/guide/topics/large-screens/support-different-screen-sizes) to support different screen sizes when using Jetpack Compose is to use the Material 3 [Window Size Class library](https://developer.android.com/reference/kotlin/androidx/compose/material3/windowsizeclass/package-summary). The library allows you to automatically classify the width and height of the current screen into one of three classes: Compact, Medium, and Expanded. Based on the width and height classes, you can then choose which navigation component to use. Google’s “Now In Android” sample uses the Window Size Class library [to determine](https://github.com/android/nowinandroid/blob/d685f4630973226921a330647bd56477c41bd8a8/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt#L100-L104) if the app should display a Navigation Bar. It only displays it when the `widthSizeClass` is equal to `Compact`. Otherwise, it uses a Navigation Rail. + +This solution works well, but you may also want to display a Navigation Bar when a foldable is in tabletop mode. In this case, you would also need to use the [Jetpack WindowManager library](https://developer.android.com/jetpack/androidx/releases/window) or the [Accompanist Adaptive library](https://google.github.io/accompanist/adaptive/), which simplifies the process of using the WindowManager library by providing a `calculateDisplayFeatures` composable which returns a list of `DisplayFeature`s. + +The point is, there's currently no easy way to support different screen sizes. But what if there was a single composable that lets you define how to handle different screen sizes and device postures? Meet the Material 3 Adaptive library. + + + The Material 3 Adaptive library is still in development and is not yet released. The API is + subject to change. This post is based on changes added in and before + [`9d205bb`](https://android.googlesource.com/platform/frameworks/support/+/9d205bbf8ae5fc96ec4aec596e7d988e91ea8780). + + +# Meet the Navigation Suite Scaffold + +
+ +
Visual representation of a Navigation Suite Scaffold
+
+ +The Navigation Suite Scaffold allows you to use a Navigation Bar, Navigation Rail, or Navigation Drawer depending on the screen configuration without the need to calculate the screen size or the posture yourself. To use it, simply pass in a `NavigationSuite` composable and the main content. + +```kotlin title="MyAdaptiveApp.kt" +@Composable +fun MyAdaptiveApp() { + val selected = remember { mutableIntStateOf(0) } + + NavigationSuiteScaffold( + navigationSuite = { + NavigationSuite { + navigationItems.forEachIndexed { index, (title, icon) -> + this.item( + icon = { Icon(imageVector = icon, contentDescription = null) }, + label = { Text(text = title) }, + selected = selected.intValue == index, + onClick = { selected.intValue = index } + ) + } + } + } + ) { + Crossfade(targetState = selected.intValue, label = "CurrentPage") { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(text = navigationItems[it].first) + } + } + } +} +``` + +The above example makes use of the default values of `NavigationSuite`. This composable can take an optional `layoutType`, which is one of: + +- `NavigationSuiteType.NavigationBar` +- `NavigationSuiteType.NavigationRail` +- `NavigationSuiteType.NavigationDrawer` + +The magic all comes down to a single composable function: `calculateWindowAdaptiveInfo`. This simple function returns a `WindowAdaptiveInfo` object containing the window size classes and posture. + +By default, the `layoutType` parameter on the `NavigationSuite` uses `WindowAdaptiveInfo` to calculate the desired `NavigationSuiteType`. But we can also create our own! + +```kotlin {3-4,9} title="MyAdaptiveApp.kt" +@Composable +fun MyAdaptiveApp( + adaptiveInfo: WindowAdaptiveInfo = calculateWindowAdaptiveInfo(), + layoutType: NavigationSuiteType = calculateFromAdaptiveInfo(adaptiveInfo) +) { + NavigationSuiteScaffold( + navigationSuite = { + NavigationSuite( + layoutType = layoutType + ) { + // ... + } + } + ) { + // ... + } +} + +private fun calculateFromAdaptiveInfo(adaptiveInfo: WindowAdaptiveInfo): NavigationSuiteType { + return with(adaptiveInfo) { + if (posture.isTabletop || windowSizeClass.heightSizeClass == Compact) { + NavigationSuiteType.NavigationBar + } else if (windowSizeClass.widthSizeClass == Expanded) { + NavigationSuiteType.NavigationDrawer + } else { + NavigationSuiteType.NavigationBar + } + } +} +``` + +The `calculateFromAdaptiveInfo` function is nearly identical to the default one used in the library. If the device is in tabletop mode, or height size class is `Compact`, we use a Navigation Bar. If the width size class is `Extended`, a Navigation Drawer is used. The default function uses a Navigation Rail when the width size class is `Extended`. We use a Navigation Bar by default if none of the conditions aren’t met. + +And voilà! That’s all we need to create an app that supports different navigation components based on device configurations. + +
+ {''} +
+ NavigationSuiteScaffold with custom NavigationSuiteType on different screen sizes +
+
+ +# Going beyond + +The Material 3 Adaptive library contains two other important composables. The first one is `ThreePaneScaffold`. To quote the documentation: + +> A pane scaffold composable that can display up to three panes [...] + +The second one is `ListDetailPaneScaffold`, it’s a “Material opinionated implementation of `ThreePaneScaffold`”. However, while the library is a Compose Multiplatform project, `ListDetailPaneScaffold` is currently limited to Android. + +Both Composables consist of three panes, where the third pane is optional. So, `ListDetailPaneScaffold` accepts a `List` pane, a `Detail` pane, and an `Extra` pane. List-Detail layouts can be used in many different apps. For example, a social app could use the List pane to display a list of posts next to the Detail pane which can be used to display the selected post. + +With Jetpack Compose, it's already possible to create a List-Detail layout thanks to the `TwoPane` Composable which is part of the [Accompanist Adaptive library](https://google.github.io/accompanist/adaptive/). But it doesn't support the Extra pane. Taking back the social app example, the Extra pane could be used to display the profile of the author of the selected post, or analytics, etc. + +
+ +
+ List-Detail implementation. Source: [Android + Developers](https://developer.android.com/large-screens/gallery/social) +
+
+ +The video above only shows an implementation of the List and Detail panes, but not the Extra pane. It could still be implemented in this particular design. Considering that conversations in messaging apps can contain additional information (such as contact information, encryption keys, etc.), it's easy to imagine that tapping the "Info" icon at the top of the Detail pane could bring up the Extra pane containing this information. + +# Conclusion + +The Material 3 Adaptive library will be a great addition to the Material suite of libraries. It will allow you to easily support different screen sizes and device postures without having to do most of the work yourself. In fact, you could simply use the `calculateWindowAdaptiveInfo` function to easily make changes to your UI without using any of the provided composables. + +The Navigation Suite Scaffold is already mostly usable today and looks very promising. The `ListDetailPaneScaffold` (and by extension the `ThreePaneScaffold`) is still a work in progress, but I look forward to seeing how it evolves. + +Thanks to the Material team for their awesome work on this library! diff --git a/content/posts/hello-world.mdx b/content/posts/hello-world.mdx new file mode 100644 index 0000000..5dc9642 --- /dev/null +++ b/content/posts/hello-world.mdx @@ -0,0 +1,126 @@ +--- +title: 'Hello World' +publishedAt: '2019-01-01' +themeColor: '#4F46E5' +summary: 'A test post showcasing all typography and article features.' +hidden: true +--- + +This is a test article to showcase various HTML and Markdown features commonly used in blog posts. Use this to verify styling and functionality. + +## Headings + +# Heading 1 + +## Heading 2 + +### Heading 3 + +#### Heading 4 + +## Text Formatting + +This is a paragraph with **bold text**, _italic text_, and **_bold italic text_**. You can also use ~~strikethrough~~ and `inline code`. + +Here's a [link to Google](https://google.com) and here's an [internal link](#headings). + +## Lists + +### Unordered List + +- First item +- Second item + - Nested item + - Another nested item +- Third item + +### Ordered List + +1. First step +2. Second step + 1. Sub-step A + 2. Sub-step B +3. Third step + +## Blockquotes + +> This is a blockquote. It can contain multiple paragraphs and other elements. +> +> — Famous Person + +## Code Blocks + +Inline `code` looks like this. + +```kotlin title="Example.kt" +@Composable +fun Greeting(name: String) { + Text(text = "Hello, $name!") +} +``` + +```typescript +function greet(name: string): string { + return `Hello, ${name}!` +} +``` + +## Tables + +| Feature | Status | Notes | +| -------- | ------ | --------------------- | +| Headings | ✅ | All levels work | +| Lists | ✅ | Ordered and unordered | +| Code | ✅ | Syntax highlighting | +| Tables | ✅ | You're looking at one | + +## Images + +![Placeholder image](/content/posts/img/starting-over.png) + +## Horizontal Rule + +--- + +## Custom Components + + + This is a NoteCard component. It's useful for highlighting important information, warnings, or + tips in your articles. + + +Here's a Tooltip demo that appears on hover. + +## Long Form Content + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec a diam lectus. Sed sit amet ipsum mauris. Maecenas congue ligula ac quam viverra nec consectetur ante hendrerit. + +Donec et mollis dolor. Praesent et diam eget libero egestas mattis sit amet vitae augue. Nam tincidunt congue enim, ut porta lorem lacinia consectetur. + +## Keyboard Shortcuts + +Press Ctrl + C to copy and Ctrl + V to paste. + +## Abbreviations + +The HTML specification is maintained by the W3C. + +## Subscript and Superscript + +H2O is water. E = mc2 is Einstein's famous equation. + +## Task List + +- [x] Completed task +- [ ] Incomplete task +- [ ] Another todo item + +## Footnotes + +Here's a sentence with a footnote reference[^1]. + +[^1]: This is the footnote content. + +--- + +That's all the common article features! Use this post to test your styling. diff --git a/content/posts/huawei-appgallery-vulnerability.mdx b/content/posts/huawei-appgallery-vulnerability.mdx new file mode 100644 index 0000000..96b1d3e --- /dev/null +++ b/content/posts/huawei-appgallery-vulnerability.mdx @@ -0,0 +1,95 @@ +--- +title: "Vulnerability in Huawei's AppGallery can download paid apps for free" +publishedAt: '2022-05-18' +summary: "How I discovered the vulnerability in Huawei's AppGallery, the consequences and what happened" +image: '/content/posts/img/huawei-appgallery-vulnerability.jpg' +--- + +Since 2019, Huawei has been facing in the United States, but Huawei devices are still being used by millions of people elsewhere. Among the various effects that the ban has on Huawei, one of them is the lack of Google Play Services on their devices. Because of that, all devices released after May 2019 include a set of various Huawei apps named Huawei Mobile Services (or HMS) which includes their own app store, the Huawei AppGallery. + +**Note:** A simple timeline can be found at the bottom of this post. + +_**Update 05/19:** +After publication, Huawei reached out with a timeline to fix the AppGallery and apologized for the miscommunication and the late reply. Because the AppGallery works differently depending on the regions and due to various other factors, it's taking Huawei a few weeks to fix it. The vulnerability should be fixed for everyone by May 25th._ + +# How it started + +Back in Feburary 2022, a developer I know released an app on the AppGallery. While looking at the listing of the app, I started wondering how Huawei's API worked. After a few minutes, I finally figured out one API that took a package name as a parameter and returned a JSON object with the details of the app. At that point I didn't know what I would find later on, so I just tried the API with the package name of a known free app: Huawei's AppGallery itself. + +_Below is a partial example of a typical response_ + +```json title="response.json" +{ + "app": { + ... + "name": "AppGallery", + "openCount": 0, + "openCountDesc": "", + "openurl": "", + "permissions": [], + "pkgName": "com.huawei.appmarket", + "price": "0", + "productId": "", + "rateNum": "0", + "recommImg": "", + "releaseDate": "2022-04-20 17:03:53", + "sha256": "2e1a1ce4e86cbfc87f05411a2585e557af78b893f6be85f8f6cb93f889faee05", + "size": "50347219", + "tagName": "", + "updateDesc": "", + "url": "https://appdlc-dre.hispace.dbankcloud.com/dl/appdl/application/apk/40/4037feaa91cf453ca2dd1ebf444aedaa/com.huawei.appmarket.2204201539.apk?sign=mw@mw1651866832368&maple=0&distOpEntity=HWSW", + "version": "12.1.1.302", + "versionCode": 120101302 + }, + ... +} +``` + +Among the details returned by the API, various fields were expected: various IDs, app version, logo & images, description, permissions, release date, price, etc. However, one of the fields returned was not expected: URL. The download link was working, but that didn't come as a surprise as I was testing a free app. + +I remember thinking to myself that it would be wild if the field was also available for paid apps. And so, my next move was to try using the package name of a paid app. Surprisingly, this response included a similar download link to the paid app, with the same type of `sign` parameter at the end. + +At this point I'm starting to think of what comes next. Which security Huawei must have put in place to secure the apps. + +1. The url may need an additional parameter of some sort to be able to download the app? This was quite easy to verify. I tried the url in my browser, and it downloaded the right file, with the right SHA-256 hash. + +2. Huawei may have some sort of API built into the apps available on their AppGallery? So I decided to try installing the app I had downloaded earlier and opening it. I was able to use the app sucessfully. + +3. Maybe the app I tried had an issue and its license verification was mistakenly disabled by the developer? This time I tried 3 different apps. Or rather, 2 other apps, and 1 game. I was able to use the apps successfully. However, the game had a license verification on its own which failed to pass as I did not buy that game. + +So now I had one thing to do: contact Huawei directly to report the vulnerability. + +# The consequences + +When publishing an app on the AppGallery, developers expect a certain level of security. It shouldn't be possible to download paid apps for free without any verification or whatsoever. While I currently don't know if the vulnerability has been actively used, if it has, both developers and Huawei may be losing some of their revenue. + +Additionally, the fact that apps are easily downloadable by anyone means that they can easily be the target of pirates. In other words, attackers could use the API to download a large amount of paid apps in a relatively short amount of time without having to pay for them and without even needing to go through the AppGallery. + +# Contacting Huawei + +After searching for "Huawei vulnerability contact," I ended up on a page which advises you to use a PGP key to contact them. So, on February 17, using the PGP key I sent an email to Huawei explaining how I found the API, and what it returned, and why it was a vulnerability. They replied to my email just 5 hours later (in an unencrypted email, which also contains a copy of my original email) by saying they would investigate the issue, and by asking me not to disclose the issue before the analysis is complete. They also asked me to provide a disclosure plan in case I had any. I decided to give them 5 weeks, and also asked them to keep me up to date on the issue, to which they agreed. + +After 5 weeks, the issue was still not fixed. I sent them 2 emails: one a few days before the final day, and one a few days after. They didn't reply to either of them. At this point I could have posted the issue publicly, but I decided to keep it private and wait a few more weeks as I realized that 5 weeks may not have been enough. + +# 13 weeks later + +It's been 13 weeks (90 days) since I sent my first email to Huawei. I never received any update on the vulnerability during those 13 weeks. The vulnerability itself isn't fixed, and paid apps can still be freely downloaded. Developers using Huawei's services were also not made aware of this vulnerability nor if/how they may have been affected. Huawei has been informed about the disclosure of this vulnerability, and finally, developers of apps I have tested have been notified as soon as this post was published. + +After my last email — sent a day before this post was published — Huawei acknowledged the vulnerability and gave it an ID. They also offered a bounty, which I declined for personal reasons. + +# Timeline + +| Date | Event | +| ---------- | --------------------------------------------------------------- | +| 2022-02-17 | Vulnerability discovered in AppGallery Web API | +| 2022-02-17 | Vulnerability privately disclosed to Huawei | +| 2022-02-18 | Huawei says they will investigate the vulnerability | +| 2022-02-19 | A public disclosure date no sooner than March 25 is agreed upon | +| 2022-03-25 | Disclosure date passes with no sign of progress from Huawei | +| 2022-05-17 | Huawei is informed of the upcoming public disclosure | +| 2022-05-18 | Huawei acknowledged the vulnerability | +| 2022-05-18 | Vulnerability publicly disclosed | + +--- + +_Thanks [Damien Wilde](https://twitter.com/iamdamienwilde) for the header_ diff --git a/content/posts/starting-over.mdx b/content/posts/starting-over.mdx new file mode 100644 index 0000000..70259a5 --- /dev/null +++ b/content/posts/starting-over.mdx @@ -0,0 +1,42 @@ +--- +title: 'Starting over' +publishedAt: '2022-05-02' +summary: "Going through the details of how my new website works and what's coming next." +image: '/content/posts/img/starting-over.png' +--- + +I've had different personal websites made with PHP during the last few years. Ironically, I never really was a fan of PHP. And my lack of interest in it prevented me from doing anything the way I wanted to. + +During that time, [Justin Kruit](https://justinkruit.com/) helped me with anything related to my website. He also helped me with the hosting, development, and management of that older one. While Justin was a great help, I could barely do anything with PHP myself, which is what pushed me to try different approaches to building websites. + +# What's new? + +From the start, I knew I wanted to create a website that would evoke Material You — Google's latest iteration of Material Design. + +Thanks to [Patryk Michalik](https://patrykmichalik.com/), I decided to give Next.js a try. It also allowed me to discover the wild world of npm packages. I quite liked Next.js, so I decided to stick to it. And so, during the last year or so, I rewrote my website 3 times. The 1st one in JavaScript, the 2nd one with TypeScript, and finally, the 3rd one — which is the one you're reading this post on — is still with TypeScript, but I put together some knowledge I acquired during that year into it. + +During that time, I also tried various CSS libraries. After trying [Emotion](https://emotion.sh/) and [Styled Components](https://styled-components.com/), I went with Tailwind CSS, which I discovered thanks to [Jahir Fiquitiva](https://jahir.dev/). With his help, I found Contentlayer too, and [Lee Robinson](https://leerob.io/)'s website, which happens to be [open-source](https://github.com/leerob/leerob.io/). Lee uses various tools I came across during the last year, which I had also decided to use for my website. It helped me figure out how to fix some of my own issues. His website is really well made and enjoyable to look at. I'd suggest checking it out. + +I couldn't decide most of the fonts and icons I would use until recently when Google released a new font named Roboto Serif and Material Symbols icons. I believe they both go well together, so I decided to use them in addition to Roboto Flex. + +With all of that, I had all keys to create my Material You-ish website. Here's a list of most of the things that helped me build this website: + +- [Next.js](https://nextjs.org/) +- [TypeScript](https://www.typescriptlang.org/) +- [Tailwind CSS](https://tailwindcss.com/) +- [Contentlayer](https://www.contentlayer.dev/) +- [Roboto Serif](https://fonts.google.com/specimen/Roboto+Serif) +- [Roboto Flex](https://github.com/googlefonts/roboto-flex) +- [Material Symbols](https://fonts.google.com/icons?icon.set=Material+Symbols) +- [Community Material Icons](https://materialdesignicons.com/) + +# What's next? + +The website — in its current state — is functional. It means it works the way I want it to work. But this isn't over yet. I already have a few features in mind that I would like to add. For example, I was considering adding a page to share various code snippets and another one to share some of my work. I will also improve some existing features, such as the dark mode and the navigation. + +As for the blog itself, I already have 2 posts in mind — including one related to Material Design — that I can't wait to share. + +In conclusion, this is not over yet. But I would be glad to know your opinion about it on Twitter! + +_Thanks [Kyle Bradshaw](https://skylled.dev/) and Patryk Michalik for your help!_\ +_The header of this post was created using [Device Frames](https://deviceframes.com/)_ diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..bf93418 --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'drizzle-kit' +import { loadEnvConfig } from '@next/env' +import { z } from 'zod' + +loadEnvConfig(process.cwd()) + +const { DRIZZLE_DATABASE_URL } = z + .object({ DRIZZLE_DATABASE_URL: z.string().min(1, 'DRIZZLE_DATABASE_URL env is missing') }) + .parse(process.env) + +export default defineConfig({ + schema: './src/db/schema.ts', + out: './src/db/migrations', + dialect: 'postgresql', + dbCredentials: { + url: DRIZZLE_DATABASE_URL, + }, +}) diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..1c71c56 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,24 @@ +import { defineConfig, globalIgnores } from 'eslint/config' +import nextVitals from 'eslint-config-next/core-web-vitals' +import nextTs from 'eslint-config-next/typescript' + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + '.next/**', + '.content-collections/**', + 'out/**', + 'build/**', + 'next-env.d.ts', + ]), + { + rules: { + '@typescript-eslint/no-unused-vars': 'off', + }, + }, +]) + +export default eslintConfig diff --git a/next.config.mjs b/next.config.mjs deleted file mode 100644 index 4678774..0000000 --- a/next.config.mjs +++ /dev/null @@ -1,4 +0,0 @@ -/** @type {import('next').NextConfig} */ -const nextConfig = {}; - -export default nextConfig; diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..c0051a0 --- /dev/null +++ b/next.config.ts @@ -0,0 +1,140 @@ +import { withContentCollections } from '@content-collections/next' +import type { NextConfig } from 'next' + +const nextConfig: NextConfig = { + agentRules: false, + cacheComponents: true, + serverExternalPackages: ['shiki'], + + images: { + formats: ['image/avif', 'image/webp'], + localPatterns: [ + { + pathname: '/api/placeholder/**', + }, + { + pathname: '/content/**', + }, + ], + remotePatterns: [ + { + protocol: 'https', + hostname: 'pbs.twimg.com', + }, + { + protocol: 'https', + hostname: 'abs.twimg.com', + }, + ], + }, + + async redirects() { + return [ + redirect('/experiments/inware/privacy_policy', '/inware/privacy_policy'), + redirect('/inware/privacy_policy.html', '/inware/2022/privacy_policy', false), + redirect('/inware/privacy_policy', '/inware/2022/privacy_policy', false), + ] + }, + + async rewrites() { + return [ + rewrite('/inware/2022/privacy_policy', '/inware/2022/privacy_policy.html'), + rewrite('/inware/2026/privacy_policy', '/inware/2026/privacy_policy.html'), + ] + }, + + async headers() { + return [ + { + source: '/inware/:path*', + headers: [ + { + key: 'X-Robots-Tag', + value: 'noindex, nofollow', + }, + ], + }, + { + source: '/(.*)', + headers: securityHeaders, + }, + ] + }, +} + +function redirect(source: string, destination: string, permanent: boolean = true) { + return { source, destination, permanent } +} + +function rewrite(source: string, destination: string) { + return { source, destination } +} + +const isProduction = process.env.NODE_ENV === 'production' +const hasVercelToolbar = process.env.VERCEL_ENV === 'preview' + +const ContentSecurityPolicy = [ + "default-src 'self'", + "base-uri 'self'", + "form-action 'self'", + "frame-ancestors 'none'", + "object-src 'none'", + `script-src 'self' 'unsafe-inline'${isProduction ? '' : " 'unsafe-eval'"} https://cdn.vercel-insights.com https://va.vercel-scripts.com${hasVercelToolbar ? ' https://vercel.live' : ''}`, + `style-src 'self' 'unsafe-inline'${hasVercelToolbar ? ' https://vercel.live' : ''}`, + "img-src 'self' blob: data: https://pbs.twimg.com https://abs.twimg.com", + "media-src 'self'", + `connect-src 'self' https://*.vercel-insights.com${isProduction ? '' : ' ws://localhost:* http://localhost:*'}${hasVercelToolbar ? ' https://vercel.live wss://ws-us3.pusher.com' : ''}`, + "font-src 'self'", + `frame-src ${hasVercelToolbar ? 'https://vercel.live' : "'none'"}`, + "worker-src 'self' blob:", + "manifest-src 'self'", + ...(isProduction ? ['upgrade-insecure-requests'] : []), +].join('; ') + +const securityHeaders = [ + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy + { + key: 'Content-Security-Policy', + value: ContentSecurityPolicy, + }, + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy + { + key: 'Referrer-Policy', + value: 'strict-origin-when-cross-origin', + }, + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-DNS-Prefetch-Control + { + key: 'X-DNS-Prefetch-Control', + value: 'on', + }, + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options + { + key: 'X-Frame-Options', + value: 'DENY', + }, + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options + { + key: 'X-Content-Type-Options', + value: 'nosniff', + }, + { + key: 'Cross-Origin-Opener-Policy', + value: 'same-origin', + }, + { + key: 'Cross-Origin-Resource-Policy', + value: 'same-origin', + }, + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security + { + key: 'Strict-Transport-Security', + value: 'max-age=31536000; includeSubDomains; preload', + }, + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Permissions-Policy + { + key: 'Permissions-Policy', + value: 'camera=(), microphone=(), geolocation=()', + }, +] + +export default withContentCollections(nextConfig) diff --git a/package.json b/package.json index f4d4e4e..eb2b401 100644 --- a/package.json +++ b/package.json @@ -2,34 +2,68 @@ "name": "evowizz.dev", "version": "0.1.0", "private": true, + "license": "Apache-2.0", "engines": { - "node": "20.x" + "node": "24.x" }, "scripts": { - "dev": "next dev --turbo", - "build": "next build", + "dev": "next dev", "start": "next start", - "lint": "next lint" + "build": "next build", + "lint": "eslint", + "typecheck": "tsc --noEmit", + "format": "prettier --write .", + "db:generate": "drizzle-kit generate --config=drizzle.config.ts", + "db:migrate": "drizzle-kit migrate --config=drizzle.config.ts", + "db:push": "drizzle-kit push:pg --config=drizzle.config.ts" }, "dependencies": { - "clsx": "^2.1.0", - "dayjs": "^1.11.10", - "geist": "^1.2.2", - "million": "^3.0.6", - "next": "14.1.3", - "react": "^18", - "react-dom": "^18", + "@evowizz/material-color-utilities-canary": "0.4.0-canary.1", + "@gsap/react": "^2.1.2", + "@neondatabase/serverless": "^1.1.0", + "@tailwindcss/typography": "^0.5.20", + "@vercel/analytics": "^2.0.1", + "clsx": "^2.1.1", + "cva": "npm:class-variance-authority@^0.7.1", + "dayjs": "^1.11.23", + "drizzle-orm": "^0.45.2", + "gsap": "^3.15.0", + "material-symbols": "^0.47.0", + "motion": "^13.1.1", + "next": "^16.3.3", + "next-themes": "^0.4.6", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-tweet": "^3.3.1", + "rss": "^1.2.2", "server-only": "^0.0.1", - "tailwind-merge": "^2.2.2" + "tailwind-merge": "^3.6.0" }, "devDependencies": { - "typescript": "^5", - "@types/node": "^20", - "@types/react": "^18", - "@types/react-dom": "^18", - "postcss": "^8", - "tailwindcss": "^3.4.1", - "eslint": "^8", - "eslint-config-next": "14.1.3" + "@content-collections/core": "^0.15.2", + "@content-collections/mdx": "^0.2.2", + "@content-collections/next": "^0.2.11", + "@tailwindcss/postcss": "^4.3.3", + "@types/mdx": "^2.0.14", + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.5", + "@types/rss": "^0.0.32", + "colorthief": "^3.5.0", + "drizzle-kit": "^0.31.10", + "eslint": "^9.39.5", + "eslint-config-next": "16.3.3", + "html-to-image": "^1.11.13", + "postcss": "^8.5.26", + "prettier": "3.9.6", + "prettier-plugin-tailwindcss": "^0.8.1", + "react-scan": "^0.5.7", + "rehype-pretty-code": "^0.14.5", + "rehype-slug": "^6.0.0", + "remark-gfm": "^4.0.1", + "shiki": "^4.4.3", + "tailwindcss": "^4.3.3", + "typescript": "^5.9.3", + "zod": "^4.4.3" } -} \ No newline at end of file +} diff --git a/postcss.config.cjs b/postcss.config.cjs index ee5f90b..52b9b4b 100644 --- a/postcss.config.cjs +++ b/postcss.config.cjs @@ -1,5 +1,5 @@ module.exports = { plugins: { - tailwindcss: {}, + '@tailwindcss/postcss': {}, }, -}; +} diff --git a/public/content/case-studies/inware/bottom-sheet.png b/public/content/case-studies/inware/bottom-sheet.png new file mode 100644 index 0000000..77a5d3a Binary files /dev/null and b/public/content/case-studies/inware/bottom-sheet.png differ diff --git a/public/content/case-studies/inware/edit.png b/public/content/case-studies/inware/edit.png new file mode 100644 index 0000000..806abe8 Binary files /dev/null and b/public/content/case-studies/inware/edit.png differ diff --git a/public/content/case-studies/inware/hero.png b/public/content/case-studies/inware/hero.png new file mode 100644 index 0000000..637998c Binary files /dev/null and b/public/content/case-studies/inware/hero.png differ diff --git a/public/content/case-studies/inware/home.png b/public/content/case-studies/inware/home.png new file mode 100644 index 0000000..8acee77 Binary files /dev/null and b/public/content/case-studies/inware/home.png differ diff --git a/public/content/case-studies/inware/inware-logos.png b/public/content/case-studies/inware/inware-logos.png new file mode 100644 index 0000000..6db0a2e Binary files /dev/null and b/public/content/case-studies/inware/inware-logos.png differ diff --git a/public/content/case-studies/inware/long-press.png b/public/content/case-studies/inware/long-press.png new file mode 100644 index 0000000..29439a1 Binary files /dev/null and b/public/content/case-studies/inware/long-press.png differ diff --git a/public/content/case-studies/inware/split.png b/public/content/case-studies/inware/split.png new file mode 100644 index 0000000..997372c Binary files /dev/null and b/public/content/case-studies/inware/split.png differ diff --git a/public/content/case-studies/inware/tile-and-page.png b/public/content/case-studies/inware/tile-and-page.png new file mode 100644 index 0000000..ef46ac6 Binary files /dev/null and b/public/content/case-studies/inware/tile-and-page.png differ diff --git a/public/content/case-studies/inware/tiles.png b/public/content/case-studies/inware/tiles.png new file mode 100644 index 0000000..47d1a2d Binary files /dev/null and b/public/content/case-studies/inware/tiles.png differ diff --git a/public/content/case-studies/inware/toolbar.png b/public/content/case-studies/inware/toolbar.png new file mode 100644 index 0000000..16e5102 Binary files /dev/null and b/public/content/case-studies/inware/toolbar.png differ diff --git a/public/content/posts/img/ai-wont-replace-developers-1.png b/public/content/posts/img/ai-wont-replace-developers-1.png new file mode 100644 index 0000000..81f9b83 Binary files /dev/null and b/public/content/posts/img/ai-wont-replace-developers-1.png differ diff --git a/public/content/posts/img/ai-wont-replace-developers.png b/public/content/posts/img/ai-wont-replace-developers.png new file mode 100644 index 0000000..e3c63c1 Binary files /dev/null and b/public/content/posts/img/ai-wont-replace-developers.png differ diff --git a/public/content/posts/img/android-haptics.jpg b/public/content/posts/img/android-haptics.jpg new file mode 100644 index 0000000..cfbadab Binary files /dev/null and b/public/content/posts/img/android-haptics.jpg differ diff --git a/public/content/posts/img/first-look-m3-compose-adaptive-2.jpg b/public/content/posts/img/first-look-m3-compose-adaptive-2.jpg new file mode 100644 index 0000000..998ed31 Binary files /dev/null and b/public/content/posts/img/first-look-m3-compose-adaptive-2.jpg differ diff --git a/public/content/posts/img/huawei-appgallery-vulnerability.jpg b/public/content/posts/img/huawei-appgallery-vulnerability.jpg new file mode 100644 index 0000000..431ebed Binary files /dev/null and b/public/content/posts/img/huawei-appgallery-vulnerability.jpg differ diff --git a/public/content/posts/img/starting-over.png b/public/content/posts/img/starting-over.png new file mode 100644 index 0000000..c3859f7 Binary files /dev/null and b/public/content/posts/img/starting-over.png differ diff --git a/public/content/posts/videos/first-look-m3-compose-adaptive-1.mp4 b/public/content/posts/videos/first-look-m3-compose-adaptive-1.mp4 new file mode 100644 index 0000000..7310ac5 Binary files /dev/null and b/public/content/posts/videos/first-look-m3-compose-adaptive-1.mp4 differ diff --git a/public/content/posts/videos/first-look-m3-compose-adaptive-3.mp4 b/public/content/posts/videos/first-look-m3-compose-adaptive-3.mp4 new file mode 100644 index 0000000..5400461 Binary files /dev/null and b/public/content/posts/videos/first-look-m3-compose-adaptive-3.mp4 differ diff --git a/public/content/posts/videos/thumbnails/first-look-m3-compose-adaptive-1.jpg b/public/content/posts/videos/thumbnails/first-look-m3-compose-adaptive-1.jpg new file mode 100644 index 0000000..b56b2b6 Binary files /dev/null and b/public/content/posts/videos/thumbnails/first-look-m3-compose-adaptive-1.jpg differ diff --git a/public/content/posts/videos/thumbnails/first-look-m3-compose-adaptive-3.jpg b/public/content/posts/videos/thumbnails/first-look-m3-compose-adaptive-3.jpg new file mode 100644 index 0000000..fa9d068 Binary files /dev/null and b/public/content/posts/videos/thumbnails/first-look-m3-compose-adaptive-3.jpg differ diff --git a/public/content/projects/inware-promo.png b/public/content/projects/inware-promo.png new file mode 100644 index 0000000..5e096f9 Binary files /dev/null and b/public/content/projects/inware-promo.png differ diff --git a/public/inware/2022/privacy_policy.html b/public/inware/2022/privacy_policy.html new file mode 100644 index 0000000..88fa20e --- /dev/null +++ b/public/inware/2022/privacy_policy.html @@ -0,0 +1,140 @@ + + + + + + + Privacy Policy + + + + Privacy Policy +

+ Dylan Roussel built the Inware app as a Free app. This SERVICE is provided by Dylan Roussel at no cost and is + intended for use as is. +

+

+ This page is used to inform visitors regarding my policies with the collection, use, and disclosure of Personal + Information if anyone decided to use my Service. +

+

+ If you choose to use my Service, then you agree to the collection and use of information in relation to this + policy. The Personal Information that I collect is used for providing and improving the Service. I will not use or + share your information with anyone except as described in this Privacy Policy. +

+

+ The terms used in this Privacy Policy have the same meanings as in our Terms and Conditions, which are accessible + at Inware unless otherwise defined in this Privacy Policy. +

+

Information Collection and Use

+

+ For a better experience, while using our Service, I may require you to provide us with certain personally + identifiable information. The information that I request will be retained on your device and is not collected by + me in any way. +

+
+

The app does use third-party services that may collect information used to identify you.

+

Link to the privacy policy of third-party service providers used by the app

+ +
+

Log Data

+

+ I want to inform you that whenever you use my Service, in a case of an error in the app I collect data and + information (through third-party products) on your phone called Log Data. This Log Data may include information + such as your device Internet Protocol (“IP”) address, device name, operating system version, the configuration of + the app when utilizing my Service, the time and date of your use of the Service, and other statistics. +

+

Cookies

+

+ Cookies are files with a small amount of data that are commonly used as anonymous unique identifiers. These are + sent to your browser from the websites that you visit and are stored on your device's internal memory. +

+

+ This Service does not use these “cookies” explicitly. However, the app may use third-party code and libraries that + use “cookies” to collect information and improve their services. You have the option to either accept or refuse + these cookies and know when a cookie is being sent to your device. If you choose to refuse our cookies, you may + not be able to use some portions of this Service. +

+

Service Providers

+

I may employ third-party companies and individuals due to the following reasons:

+
    +
  • To facilitate our Service;
  • +
  • To provide the Service on our behalf;
  • +
  • To perform Service-related services; or
  • +
  • To assist us in analyzing how our Service is used.
  • +
+

+ I want to inform users of this Service that these third parties have access to their Personal Information. The + reason is to perform the tasks assigned to them on our behalf. However, they are obligated not to disclose or use + the information for any other purpose. +

+

Security

+

+ I value your trust in providing us your Personal Information, thus we are striving to use commercially acceptable + means of protecting it. But remember that no method of transmission over the internet, or method of electronic + storage is 100% secure and reliable, and I cannot guarantee its absolute security. +

+

Links to Other Sites

+

+ This Service may contain links to other sites. If you click on a third-party link, you will be directed to that + site. Note that these external sites are not operated by me. Therefore, I strongly advise you to review the + Privacy Policy of these websites. I have no control over and assume no responsibility for the content, privacy + policies, or practices of any third-party sites or services. +

+

Children’s Privacy

+
+

+ These Services do not address anyone under the age of 13. I do not knowingly collect personally identifiable + information from children under 13 years of age. In the case I discover that a child under 13 has provided me + with personal information, I immediately delete this from our servers. If you are a parent or guardian and you + are aware that your child has provided us with personal information, please contact me so that I will be able to + do the necessary actions. +

+
+ +

Changes to This Privacy Policy

+

+ I may update our Privacy Policy from time to time. Thus, you are advised to review this page periodically for any + changes. I will notify you of any changes by posting the new Privacy Policy on this page. +

+

This policy is effective as of 2022-05-02

+

Contact Us

+

+ If you have any questions or suggestions about my Privacy Policy, do not hesitate to contact me at + mail@evowizz.dev. +

+

+ This privacy policy page was created at + privacypolicytemplate.net + and modified/generated by + App Privacy Policy Generator +

+ + diff --git a/public/inware/2026/privacy_policy.html b/public/inware/2026/privacy_policy.html new file mode 100644 index 0000000..7813808 --- /dev/null +++ b/public/inware/2026/privacy_policy.html @@ -0,0 +1,263 @@ + + + + + + + Inware Privacy Policy + + + +

Privacy Policy

+ +

+ This Privacy Policy explains how Inware handles information when you use the Android app. Dylan Roussel is + responsible for Inware and the processing described here. +

+

+ Inware is a device-information app. Information read from your device to display its hardware, software, sensors, + and system status is processed locally and is not uploaded by Inware unless you explicitly share it. +

+ +

Information kept on your device

+

+ Inware stores your settings, including data-sharing choices, on your device. Android may include these settings in + cloud backup or device transfer according to your system backup settings. +

+

+ Inware also keeps a rolling technical log of up to 64 KB on your device. It leaves your device only if you export + or share it, or when eligible log entries are attached to a crash report you chose to send. +

+

+ Some device details require Android permissions, including nearby-device or location permissions. Information read + with these permissions is used to display device details locally. +

+ +

Android system integrations

+

+ On supported Android versions, Inware may expose selected public device facts through Android App Functions when + the feature is available and enabled. +

+

+ A compatible system service or app may request one of these facts. The requesting service's privacy policy governs + any processing after Inware returns the requested value. +

+ +

What changed in Inware 7.1.0

+

+ Starting with Inware 7.1.0, optional data sharing is divided into Usage analytics and Send crash reports. You can + choose either, both, or neither. +

+

+ These choices are off until a new user saves them. If you previously enabled analytics, Inware keeps that choice + for Usage analytics when you upgrade and asks you to review the new controls. +

+

+ Earlier versions did not provide a separate crash-reporting choice. Starting with Inware 7.1.0, crash report + uploads require a separate opt-in. +

+ +

Your data-sharing choices

+ +

Usage analytics

+

+ If enabled, Inware uses Google Analytics for Firebase to understand which screens and features are used and to + record app sessions and lifecycle events. +

+

+ Inware records screen names, changes to Developer options, and developer feature-flag overrides. It does not set a + Firebase user ID or attach your name, email address, or account details. +

+

+ Analytics assigns an app-instance ID and may process app and device information, session statistics, and + approximate location derived from a masked IP address. +

+

+ Inware removes advertising-ID permissions and always denies advertising storage, ad user data, and ad + personalization consent. +

+ +

Performance monitoring

+

+ Performance Monitoring is controlled by the Usage analytics choice. It helps identify slow startup, rendering, and + network operations and guide performance improvements. +

+

+ Firebase may process timings, CPU and memory use, app state, app and device metadata, network type, response + codes, payload sizes, and request URLs without query parameters or payload contents. +

+

+ Firebase also processes a Firebase installation ID and an IP address. The IP address is used to derive the country + where a performance event originated. +

+ +

Send crash reports

+

+ If enabled, Inware sends crash reports to Firebase Crashlytics so crashes can be identified, investigated, and + fixed. +

+

+ Reports may include stack traces, exception messages, timestamps, app and operating-system versions, device model + and architecture, memory and disk information, app state, and installation or session identifiers. +

+

+ Reports may also contain recent technical logs generated by Inware. These logs describe app state and navigation + and are not intentionally used to collect personal content. +

+

+ Crashlytics automatic upload is disabled. When crash reporting is off, Crashlytics may temporarily keep a report + on your device, but Inware asks it to delete unsent reports when these controls next initialize, usually on the + next launch. +

+ +

Essential Firebase services

+

+ Inware uses Firebase Remote Config even when optional data sharing is off. It supplies configuration needed to + control app features without requiring a new app release. +

+

+ Remote Config may process country, language, time zone, Android version, app version, package name, Firebase App + ID, and Remote Config SDK version. +

+

+ Remote Config uses Firebase Installations. Firebase creates a per-installation ID and receives basic app and + device metadata. The ID identifies an installation, not a person or the physical device itself. +

+ +

How the information is used

+

+ Essential configuration data is used to provide and safely control Inware features. Optional data is used only for + usage analysis, performance improvement, and crash investigation. +

+

+ I do not sell this information or use it for advertising. Google processes it as the provider of Firebase and + Google Analytics, including through subprocessors used to operate those services. +

+ +

Storage, retention, and deletion

+

+ Firebase processes data on Google's global infrastructure. Firebase states that data is encrypted in transit, and + Crashlytics and Performance Monitoring data is also encrypted at rest. +

+

+ Firebase states that Crashlytics generally keeps crash data and associated identifiers for 90 days before deletion + begins. +

+

+ Firebase states that Performance Monitoring keeps IP-associated events for 30 days and installation-associated or + de-identified performance data for 60 days before deletion begins. +

+

+ Google Analytics data is retained according to the retention settings and policies of the linked Analytics + property. +

+

+ You can disable either optional choice at any time in Data & privacy. Disabling a choice stops the + corresponding future collection from Inware, subject to data already queued or received by Google. +

+

+ Reset app identity clears Analytics data stored on your device, resets the Analytics app-instance ID, and requests + deletion of the current Firebase installation. +

+

+ Firebase states that data tied to the deleted installation is removed from live and backup systems within 180 + days. Remote Config will later create a new, unrelated installation ID. +

+

+ Reset app identity does not delete Analytics or crash data already stored on Google's servers. Inware has no + accounts and sets no user ID, so I generally cannot identify a specific person's historical records. +

+ +

Legal bases and your rights

+

+ Optional analytics, performance monitoring, and crash-report uploads are based on your consent. You may withdraw + consent at any time without affecting processing that occurred before withdrawal. +

+

+ Essential Remote Config and installation processing is based on the legitimate interest in operating, configuring, + and protecting Inware. +

+

+ Depending on where you live, you may have rights to access, correct, delete, restrict, or object to processing, + and to receive portable data. You may contact me to exercise these rights. +

+

+ Because Inware does not use accounts or set a user ID, I cannot identify Firebase records from your name or email + address alone. I can only provide or delete records if you supply an identifier that can be used to locate them. +

+

If no such identifier is provided, the relevant Firebase records cannot be located.

+

+ If you are in the European Economic Area, you may also lodge a complaint with your local data-protection authority + or with France's CNIL. +

+ +

Service providers

+ + +

Links to other sites

+

+ Inware may link to external sites that I do not operate. Their content and privacy practices are governed by their + own policies. +

+ +

When you contact me

+

+ If you contact me, I receive the address and contents you provide. I use them to respond, handle your request, and + maintain necessary records, and retain them only as long as reasonably needed. +

+ +

Security

+

+ I use reasonable measures to limit optional collection and protect data, but no transmission or storage method is + completely secure. +

+ +

Children's privacy

+

+ Inware is not directed to children under 13, and I do not knowingly collect personal information from children. + Contact me if you believe a child has provided personal information through Inware. +

+ +

Changes to this policy

+

+ I may update this policy when Inware's features or service providers change. The revised policy will be posted on + this page with a new effective date, and material changes may also be shown in the app. +

+ +

Effective for Inware 7.1.0 from XX August 2026.

+

+ Previous version: + Privacy Policy effective May 2, 2022. +

+ +

Contact

+

+ For privacy questions or requests, contact Dylan Roussel at + mail@evowizz.dev. +

+ + diff --git a/public/static/bg/noise.png b/public/static/bg/noise.png index 1364a0c..845b7f6 100644 Binary files a/public/static/bg/noise.png and b/public/static/bg/noise.png differ diff --git a/src/app/(home)/_components/count-up.tsx b/src/app/(home)/_components/count-up.tsx new file mode 100644 index 0000000..4b92ced --- /dev/null +++ b/src/app/(home)/_components/count-up.tsx @@ -0,0 +1,59 @@ +'use client' + +import { useRef } from 'react' +import gsap from 'gsap' +import { ScrollTrigger } from 'gsap/ScrollTrigger' +import { useGSAP } from '@gsap/react' +import { withMotionPreference } from '@/lib/motion-preference' + +gsap.registerPlugin(useGSAP, ScrollTrigger) + +const groupDigits = (value: number) => String(value).replace(/\B(?=(\d{3})+(?!\d))/g, ',') + +type CountUpProps = { + to: number + prefix?: string + suffix?: string + className?: string + /** Insert thousands separators, such as 630,000. */ + group?: boolean + duration?: number +} + +/** Counts from zero when the figure scrolls into view. */ +export function CountUp({ to, prefix = '', suffix = '', className, group = false, duration = 1.4 }: CountUpProps) { + const ref = useRef(null) + const render = (value: number) => prefix + (group ? groupDigits(value) : String(value)) + suffix + + useGSAP( + () => { + const element = ref.current + if (!element) return + const set = (value: number) => { + element.textContent = render(Math.round(value)) + } + + return withMotionPreference( + () => { + set(0) + const counter = { value: 0 } + gsap.to(counter, { + value: to, + duration, + ease: 'power2.out', + onUpdate: () => set(counter.value), + scrollTrigger: { trigger: element, start: 'top 90%', once: true }, + }) + }, + () => set(to), + ) + }, + { scope: ref }, + ) + + return ( + + {render(to)} + + ) +} diff --git a/src/app/(home)/_components/hero-motion.tsx b/src/app/(home)/_components/hero-motion.tsx new file mode 100644 index 0000000..1c93381 --- /dev/null +++ b/src/app/(home)/_components/hero-motion.tsx @@ -0,0 +1,71 @@ +'use client' + +import { type ReactNode, useRef } from 'react' +import gsap from 'gsap' +import { useGSAP } from '@gsap/react' +import { ScrollTrigger } from 'gsap/ScrollTrigger' +import { withMotionPreference } from '@/lib/motion-preference' + +gsap.registerPlugin(useGSAP, ScrollTrigger) + +export function HeroMotion({ children }: { children: ReactNode }) { + const heroRef = useRef(null) + const contentRef = useRef(null) + + useGSAP( + () => { + const el = heroRef.current + if (!el) return + + return withMotionPreference( + () => { + gsap.set(el, { filter: 'blur(20px)' }) + gsap.to(el, { + filter: 'blur(0px)', + duration: 0.9, + delay: 0.15, + ease: 'power3.out', + onComplete: () => gsap.set(el, { clearProps: 'filter' }), + }) + }, + () => gsap.set(el, { clearProps: 'filter' }), + ) + }, + { scope: heroRef }, + ) + + useGSAP( + () => { + const content = contentRef.current + const about = document.querySelector('#about') + if (!content || !about) return + + return withMotionPreference( + () => { + gsap.to(content, { + opacity: 0, + filter: 'blur(16px)', + ease: 'none', + scrollTrigger: { + trigger: about, + start: 'clamp(top 92%)', + end: 'top 40%', + scrub: true, + invalidateOnRefresh: true, + }, + }) + }, + () => gsap.set(content, { clearProps: 'filter,opacity' }), + ) + }, + { scope: contentRef }, + ) + + return ( +
+
+ {children} +
+
+ ) +} diff --git a/src/app/(home)/_components/local-time.tsx b/src/app/(home)/_components/local-time.tsx new file mode 100644 index 0000000..8bbbe25 --- /dev/null +++ b/src/app/(home)/_components/local-time.tsx @@ -0,0 +1,91 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useParisClock } from '@/app/(home)/_hooks/use-paris-clock' +import { cn } from '@/lib/utils' +import { MaterialSymbol } from '@/components/ui/material-symbol' + +/** Reserves the line height, so the clock arriving shifts nothing below it. */ +export const LocalTime = ({ timeClassName, zoneClassName }: { timeClassName?: string; zoneClassName?: string }) => { + const clock = useParisClock() + + return ( + <> + + {clock?.time} + +

{clock?.zone}

+ + ) +} + +export function SignatureTime() { + const clock = useParisClock() + const [localTime, setLocalTime] = useState(null) + + useEffect(() => { + const readLocalTime = () => + setLocalTime( + new Intl.DateTimeFormat('en-GB', { + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23', + }).format(new Date()), + ) + + readLocalTime() + const id = setInterval(readLocalTime, 30000) + return () => clearInterval(id) + }, []) + + const parisTime = clock?.time ?? '\u00a0' + const parisZone = clock?.zone ?? '' + const shortZone = parisZone.replace(/\s+\(.*\)$/, '') + const zoneOffset = parisZone.slice(shortZone.length) + const visitorTime = localTime ?? parisTime + const canRevealLocalTime = Boolean( + clock && localTime && (process.env.NODE_ENV !== 'production' || localTime !== clock.time), + ) + + const parisLabel = ( + <> + {parisTime} {shortZone} + {zoneOffset && {zoneOffset}} + + ) + + if (!canRevealLocalTime) { + return ( + + + {parisLabel} + + ) + } + + return ( + + ) +} diff --git a/src/app/(home)/_components/media-logos/9to5google.svg b/src/app/(home)/_components/media-logos/9to5google.svg new file mode 100644 index 0000000..b53143a --- /dev/null +++ b/src/app/(home)/_components/media-logos/9to5google.svg @@ -0,0 +1,10 @@ + + + + diff --git a/src/app/(home)/_components/media-logos/android_authority.svg b/src/app/(home)/_components/media-logos/android_authority.svg new file mode 100644 index 0000000..e9a329a --- /dev/null +++ b/src/app/(home)/_components/media-logos/android_authority.svg @@ -0,0 +1,14 @@ + + + + + diff --git a/src/app/(home)/_components/media-logos/android_central.svg b/src/app/(home)/_components/media-logos/android_central.svg new file mode 100644 index 0000000..9269da8 --- /dev/null +++ b/src/app/(home)/_components/media-logos/android_central.svg @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + diff --git a/src/app/(home)/_components/media-logos/android_headlines.svg b/src/app/(home)/_components/media-logos/android_headlines.svg new file mode 100644 index 0000000..b856cd8 --- /dev/null +++ b/src/app/(home)/_components/media-logos/android_headlines.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + diff --git a/src/app/(home)/_components/media-logos/android_police.svg b/src/app/(home)/_components/media-logos/android_police.svg new file mode 100644 index 0000000..b85b08d --- /dev/null +++ b/src/app/(home)/_components/media-logos/android_police.svg @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + diff --git a/src/app/(home)/_components/media-logos/bbc.svg b/src/app/(home)/_components/media-logos/bbc.svg new file mode 100644 index 0000000..ea3a69f --- /dev/null +++ b/src/app/(home)/_components/media-logos/bbc.svg @@ -0,0 +1,6 @@ + + + diff --git a/src/app/(home)/_components/media-logos/bgr.svg b/src/app/(home)/_components/media-logos/bgr.svg new file mode 100644 index 0000000..f6caf92 --- /dev/null +++ b/src/app/(home)/_components/media-logos/bgr.svg @@ -0,0 +1,8 @@ + + + diff --git a/src/app/(home)/_components/media-logos/engadget.svg b/src/app/(home)/_components/media-logos/engadget.svg new file mode 100644 index 0000000..5634418 --- /dev/null +++ b/src/app/(home)/_components/media-logos/engadget.svg @@ -0,0 +1,6 @@ + + + diff --git a/src/app/(home)/_components/media-logos/futurism.svg b/src/app/(home)/_components/media-logos/futurism.svg new file mode 100644 index 0000000..44582a3 --- /dev/null +++ b/src/app/(home)/_components/media-logos/futurism.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + diff --git a/src/app/(home)/_components/media-logos/mobilesyrup.svg b/src/app/(home)/_components/media-logos/mobilesyrup.svg new file mode 100644 index 0000000..77ff937 --- /dev/null +++ b/src/app/(home)/_components/media-logos/mobilesyrup.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/app/(home)/_components/media-logos/sammobile.svg b/src/app/(home)/_components/media-logos/sammobile.svg new file mode 100644 index 0000000..f69a46d --- /dev/null +++ b/src/app/(home)/_components/media-logos/sammobile.svg @@ -0,0 +1,18 @@ + + + + + + diff --git a/src/app/(home)/_components/media-logos/slashgear.svg b/src/app/(home)/_components/media-logos/slashgear.svg new file mode 100644 index 0000000..b837923 --- /dev/null +++ b/src/app/(home)/_components/media-logos/slashgear.svg @@ -0,0 +1,34 @@ + + + + + + + + + + diff --git a/src/app/(home)/_components/media-logos/techcrunch.svg b/src/app/(home)/_components/media-logos/techcrunch.svg new file mode 100644 index 0000000..011d095 --- /dev/null +++ b/src/app/(home)/_components/media-logos/techcrunch.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + diff --git a/src/app/(home)/_components/media-logos/techradar.svg b/src/app/(home)/_components/media-logos/techradar.svg new file mode 100644 index 0000000..3188e1b --- /dev/null +++ b/src/app/(home)/_components/media-logos/techradar.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/app/(home)/_components/media-logos/theverge.svg b/src/app/(home)/_components/media-logos/theverge.svg new file mode 100644 index 0000000..1c624d3 --- /dev/null +++ b/src/app/(home)/_components/media-logos/theverge.svg @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/src/app/(home)/_components/media-logos/toms_guide.svg b/src/app/(home)/_components/media-logos/toms_guide.svg new file mode 100644 index 0000000..3e35701 --- /dev/null +++ b/src/app/(home)/_components/media-logos/toms_guide.svg @@ -0,0 +1,15 @@ + + + + + + + + + + diff --git a/src/app/(home)/_components/media-logos/xda.svg b/src/app/(home)/_components/media-logos/xda.svg new file mode 100644 index 0000000..b367a34 --- /dev/null +++ b/src/app/(home)/_components/media-logos/xda.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + diff --git a/src/app/(home)/_components/press-ribbon.tsx b/src/app/(home)/_components/press-ribbon.tsx new file mode 100644 index 0000000..2adbf0f --- /dev/null +++ b/src/app/(home)/_components/press-ribbon.tsx @@ -0,0 +1,150 @@ +'use client' + +import { useState, type CSSProperties, type ReactNode } from 'react' +import type { StaticImageData } from 'next/image' +import { cn } from '@/lib/utils' +import { Container } from '@/components/ui/container' +import { MaterialSymbol } from '@/components/ui/material-symbol' +import nineToFiveGoogle from './media-logos/9to5google.svg' +import androidAuthority from './media-logos/android_authority.svg' +import androidCentral from './media-logos/android_central.svg' +import androidHeadlines from './media-logos/android_headlines.svg' +import androidPolice from './media-logos/android_police.svg' +import bbc from './media-logos/bbc.svg' +import bgr from './media-logos/bgr.svg' +import engadget from './media-logos/engadget.svg' +import futurism from './media-logos/futurism.svg' +import mobileSyrup from './media-logos/mobilesyrup.svg' +import samMobile from './media-logos/sammobile.svg' +import slashGear from './media-logos/slashgear.svg' +import techCrunch from './media-logos/techcrunch.svg' +import techRadar from './media-logos/techradar.svg' +import theVerge from './media-logos/theverge.svg' +import tomsGuide from './media-logos/toms_guide.svg' +import xda from './media-logos/xda.svg' + +type Outlet = { + name: string + url: string + logo: StaticImageData +} + +/** A curated selection, so nothing on the page states a count. */ +const OUTLETS: Outlet[] = [ + { name: '9to5Google', url: 'https://9to5google.com', logo: nineToFiveGoogle }, + { name: 'The Verge', url: 'https://www.theverge.com', logo: theVerge }, + { name: 'BBC', url: 'https://www.bbc.com', logo: bbc }, + { name: 'TechCrunch', url: 'https://techcrunch.com', logo: techCrunch }, + { name: 'Engadget', url: 'https://www.engadget.com', logo: engadget }, + { name: 'Android Police', url: 'https://www.androidpolice.com', logo: androidPolice }, + { name: "Tom's Guide", url: 'https://www.tomsguide.com', logo: tomsGuide }, + { name: 'Android Central', url: 'https://www.androidcentral.com', logo: androidCentral }, + { name: 'TechRadar', url: 'https://www.techradar.com', logo: techRadar }, + { name: 'XDA', url: 'https://www.xda-developers.com', logo: xda }, + { name: 'Android Headlines', url: 'https://www.androidheadlines.com', logo: androidHeadlines }, + { name: 'Android Authority', url: 'https://www.androidauthority.com', logo: androidAuthority }, + { name: 'SamMobile', url: 'https://www.sammobile.com', logo: samMobile }, + { name: 'SlashGear', url: 'https://www.slashgear.com', logo: slashGear }, + { name: 'Futurism', url: 'https://futurism.com', logo: futurism }, + { name: 'MobileSyrup', url: 'https://mobilesyrup.com', logo: mobileSyrup }, + { name: 'BGR', url: 'https://www.bgr.com', logo: bgr }, +] + +const TOP = OUTLETS.slice(0, 8) +const BOTTOM = OUTLETS.slice(8) + +const Logo = ({ outlet }: { outlet: Outlet }) => ( + +) + +const Mark = ({ outlet, focusable = true }: { outlet: Outlet; focusable?: boolean }) => ( + + + +) + +const Row = ({ outlets, focusable }: { outlets: Outlet[]; focusable: boolean }) => ( +
+ {outlets.map((outlet) => ( + + ))} +
+) + +/** + * Three copies move together, so `-100%` loops without a seam. The duplicates + * stay clickable but leave the tab order and the accessibility tree. + */ +const Track = ({ outlets, reverse, paused }: { outlets: Outlet[]; reverse?: boolean; paused: boolean }) => ( +
+ {[0, 1, 2].map((copy) => ( +
0 ? true : undefined} + className={reverse ? 'animate-press-ribbon-reverse flex' : 'animate-press-ribbon flex'} + style={{ animationPlayState: paused ? 'paused' : 'running' }} + > + +
+ ))} +
+) + +export const PressRibbon = ({ attribution }: { attribution?: ReactNode }) => { + const [paused, setPaused] = useState(false) + + return ( + <> + +
+ {OUTLETS.map((outlet) => ( + + ))} +
+ {attribution &&
{attribution}
} +
+ +
+
+ + +
+ + + + {attribution} + +
+ + ) +} diff --git a/src/app/(home)/_components/scroll-drift.tsx b/src/app/(home)/_components/scroll-drift.tsx new file mode 100644 index 0000000..e12308d --- /dev/null +++ b/src/app/(home)/_components/scroll-drift.tsx @@ -0,0 +1,110 @@ +'use client' + +import { useRef, type ReactNode } from 'react' +import gsap from 'gsap' +import { useGSAP } from '@gsap/react' +import { withMotionPreference } from '@/lib/motion-preference' + +gsap.registerPlugin(useGSAP) + +type ScrollDriftProps = { + children: ReactNode + className?: string + /** Only drifts where this matches. Defaults to the `lg` breakpoint. */ + media?: string + /** Share of the cell's spare height to drift through. Lower keeps it nearer the top. */ + range?: number + /** Share of the remaining distance closed each frame. Lower feels heavier. */ + ease?: number +} + +/** + * Drifts a child down the spare height of its cell as that cell scrolls past. + * + * The child sits at the top of its cell until that cell is wholly on screen, then climbs + * slower than whatever sits beside it. Its cell bounds the travel. + * `ease` is weight on top of that drift, not the source of it, since damping alone settles + * at a fixed offset under constant scroll speed and just looks parked lower. + * + * Writes `translate`, not `transform`, which `Reveal` already owns on the same node. + */ +export function ScrollDrift({ + children, + className, + media = '(min-width: 64rem)', + range = 1, + ease = 0.08, +}: ScrollDriftProps) { + const ref = useRef(null) + + useGSAP( + () => { + const el = ref.current + if (!el) return + + return withMotionPreference(() => { + const mq = window.matchMedia(media) + // The scroll range the drift runs over, in document coordinates, and its travel. + let from = 0 + let span = 1 + let travel = 0 + let offset = 0 + + const target = () => gsap.utils.clamp(0, 1, (window.scrollY - from) / span) * travel + const place = () => (el.style.translate = `0 ${offset.toFixed(2)}px`) + + const measure = () => { + const cell = el.parentElement + travel = cell && mq.matches ? Math.max(0, (cell.offsetHeight - el.offsetHeight) * range) : 0 + + if (!cell || !travel) { + el.style.translate = '' + return + } + + let docTop = 0 + for (let node: HTMLElement | null = cell; node; node = node.offsetParent as HTMLElement | null) { + docTop += node.offsetTop + } + + // Starts once the cell is wholly on screen, so nothing moves while it arrives. + from = docTop + cell.offsetHeight - window.innerHeight + span = window.innerHeight + // Start settled, so loading partway down the page does not animate from the top. + offset = target() + place() + } + + const tick = () => { + if (!travel) return + + const to = target() + // Converging never quite lands, so stop below the precision actually written. + if (Math.abs(to - offset) < 0.01) return + + offset += (to - offset) * ease + place() + } + + measure() + gsap.ticker.add(tick) + window.addEventListener('resize', measure) + mq.addEventListener('change', measure) + + return () => { + gsap.ticker.remove(tick) + window.removeEventListener('resize', measure) + mq.removeEventListener('change', measure) + el.style.translate = '' + } + }) + }, + { scope: ref }, + ) + + return ( +
+ {children} +
+ ) +} diff --git a/src/app/(home)/_components/section-title.tsx b/src/app/(home)/_components/section-title.tsx new file mode 100644 index 0000000..d6e294f --- /dev/null +++ b/src/app/(home)/_components/section-title.tsx @@ -0,0 +1,18 @@ +'use client' + +import { type ReactNode } from 'react' +import { Reveal } from '@/components/ui/reveal' +import { useWidthBreath } from '@/hooks/use-width-breath' + +/** Section heading that slightly stands out and settles its width on reveal. */ +export const SectionTitle = ({ children }: { children: ReactNode }) => { + const ref = useWidthBreath({ from: 84, duration: 0.9, scroll: true }) + + return ( + +

+ {children} +

+
+ ) +} diff --git a/src/app/(home)/_components/sections/about.tsx b/src/app/(home)/_components/sections/about.tsx new file mode 100644 index 0000000..98ed0d1 --- /dev/null +++ b/src/app/(home)/_components/sections/about.tsx @@ -0,0 +1,96 @@ +import { Container } from '@/components/ui/container' +import { TextLink } from '@/components/ui/links' +import { Label } from '@/components/ui/typography' +import { SectionTitle } from '../section-title' +import { Reveal } from '@/components/ui/reveal' +import { ScrollDrift } from '../scroll-drift' +import { SITE_LOCATION } from '@/config/site' +import { CountUp } from '../count-up' + +const FACTS = [ + { term: 'Based', detail: SITE_LOCATION }, + { term: 'Speaks', detail: 'French, English' }, + { term: 'Building', detail: 'Since 2016' }, + { term: 'Previously', detail: 'Beeper' }, + { term: 'Bylines', detail: '9to5Google' }, +] + +export const About = () => ( +
+ + About + + +
+

+ Hi, I'm{' '} + + Dy + + , +

+

+ Self-taught developer and designer, based in Nantes, France, and building for Android since 2016. In 2018 I + released Inware, an app that shows you exactly what your device is made of. It was the first app on the Play + Store to support Material You dynamic color, before the feature was even documented, and has since passed + 500,000 downloads while holding a 4.7 rating. +

+

+ Since 2016, I've been spotting Android features before they're announced:{' '} + Fast Share, which + you now know as Quick Share, and the rename of{' '} + Bard to Gemini, + days before it was official. That's how I ended up contributing to 9to5Google, where I also wrote a few + pieces of my own. +

+

+ In 2024 I joined Texts to work on its Electron desktop app, then moved to Beeper's Android app. I later + took on design work there too. Among other things, I reworked the app's navigation and overhauled its + color system around Material 3. +

+

+ I sometimes dig into other people's software too. Back in 2022, I reported a flaw in{' '} + Huawei's AppGallery that let anyone + download paid apps for free. A year later{' '} + I looked into Nothing Chats, + which was built on a service called Sunbird, and what I found{' '} + + got the app shut down + + . +

+
+ + +
+ +

+ private files exposed by the Sunbird breach I uncovered. +

+
+ +
+ {FACTS.map((fact) => ( +
+
+ +
+
{fact.detail}
+
+ ))} +
+
+
+
+
+) diff --git a/src/app/(home)/_components/sections/contact.tsx b/src/app/(home)/_components/sections/contact.tsx new file mode 100644 index 0000000..f6a87cf --- /dev/null +++ b/src/app/(home)/_components/sections/contact.tsx @@ -0,0 +1,59 @@ +import { EMAIL, SITE_LOCATION, SOCIALS } from '@/config/site' +import { cn } from '@/lib/utils' +import { Container } from '@/components/ui/container' +import { RowLink } from '@/components/ui/links' +import { MaterialSymbol } from '@/components/ui/material-symbol' +import { Reveal } from '@/components/ui/reveal' +import { SectionTitle } from '../section-title' +import { LocalTime } from '../local-time' + +const EmailLink = ({ className }: { className?: string }) => ( + + {EMAIL} + +) + +const SocialRows = () => ( +
    + {SOCIALS.map((social) => ( +
  • + + {social.label} + + +
  • + ))} +
+) + +export const Contact = () => ( +
+ + Contact + +
+

+ Say hello +

+ +
+ +
+ {SITE_LOCATION} + +
+ + +
+
+
+) diff --git a/src/app/(home)/_components/sections/hero.tsx b/src/app/(home)/_components/sections/hero.tsx new file mode 100644 index 0000000..664c766 --- /dev/null +++ b/src/app/(home)/_components/sections/hero.tsx @@ -0,0 +1,85 @@ +import { type ReactNode } from 'react' +import Link from 'next/link' +import { SITE_LOCATION } from '@/config/site' +import { Container } from '@/components/ui/container' +import { MaterialSymbol } from '@/components/ui/material-symbol' +import { Reveal } from '@/components/ui/reveal' +import { HeroMotion } from '../hero-motion' +import { SignatureTime } from '../local-time' + +function HeroName() { + return ( +

+ + + Dy + + lan + + + Roussel + +

+ ) +} + +function NameAside({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} + +function HeroAction() { + return ( + + See the work + + + ) +} + +export function Hero() { + return ( + + + +
+ + + {SITE_LOCATION} + + + +
+ + + +
+

+ Developer and designer for Android and the web. +

+ + +
+
+
+
+ ) +} diff --git a/src/app/(home)/_components/sections/index.ts b/src/app/(home)/_components/sections/index.ts new file mode 100644 index 0000000..f3d74b7 --- /dev/null +++ b/src/app/(home)/_components/sections/index.ts @@ -0,0 +1,6 @@ +export { Hero } from './hero' +export { About } from './about' +export { Work } from './work' +export { Press } from './press' +export { Skills } from './skills' +export { Contact } from './contact' diff --git a/src/app/(home)/_components/sections/press.tsx b/src/app/(home)/_components/sections/press.tsx new file mode 100644 index 0000000..f983c87 --- /dev/null +++ b/src/app/(home)/_components/sections/press.tsx @@ -0,0 +1,37 @@ +import { Container } from '@/components/ui/container' +import { TextLink } from '@/components/ui/links' +import { SectionTitle } from '../section-title' +import { Reveal } from '@/components/ui/reveal' +import { PressRibbon } from '../press-ribbon' + +export const Press = () => ( +
+ +
+ Press + +

+ Some of the outlets my apps, my security research, and the occasional Android scoop have run in since 2017. +

+
+
+
+ + {/* Larger than the gap between the two rows, so the pair reads as one block. */} +
+ + Section inspired by{' '} + + ThatJoshGuy + +

+ } + /> +
+
+) diff --git a/src/app/(home)/_components/sections/skills.tsx b/src/app/(home)/_components/sections/skills.tsx new file mode 100644 index 0000000..4a1d5a9 --- /dev/null +++ b/src/app/(home)/_components/sections/skills.tsx @@ -0,0 +1,63 @@ +import { skills } from '@/app/(home)/_data/skills' +import { skillLogos } from '@/components/svg' +import { Container } from '@/components/ui/container' +import { RowLink } from '@/components/ui/links' +import { Label } from '@/components/ui/typography' +import { SectionTitle } from '../section-title' +import { Reveal } from '@/components/ui/reveal' + +const SKILL_GROUPS = [ + { label: 'Languages', kinds: ['language'] }, + { label: 'Frameworks', kinds: ['framework'] }, + { label: 'Tools and more', kinds: ['tool', 'other'] }, +].map((group) => ({ + label: group.label, + items: skills.filter((skill) => group.kinds.includes(skill.kind)), +})) + +export const Skills = () => ( +
+ +
+ Skills + +

+ The languages, frameworks, and tools I reach for every day. +

+
+
+ + + {SKILL_GROUPS.map((group) => ( +
+
+ + {group.items.length} +
+ +
    + {group.items.map((skill) => { + const Logo = skillLogos[skill.logo] + return ( +
  • + + + + + + {skill.name} + + +
  • + ) + })} +
+
+ ))} +
+
+
+) diff --git a/src/app/(home)/_components/sections/work.tsx b/src/app/(home)/_components/sections/work.tsx new file mode 100644 index 0000000..13cd42a --- /dev/null +++ b/src/app/(home)/_components/sections/work.tsx @@ -0,0 +1,184 @@ +import Image from 'next/image' +import { projects, type Project } from '@/app/(home)/_data/projects' +import { cn } from '@/lib/utils' +import { Container } from '@/components/ui/container' +import { ActionLink, RowLink, SmartLink } from '@/components/ui/links' +import { MaterialSymbol } from '@/components/ui/material-symbol' +import { SectionTitle } from '../section-title' +import { Reveal } from '@/components/ui/reveal' +import { CountUp } from '../count-up' + +const SENTENCES: Record = { + Inware: 'An Android app I have been building since 2018 to answer one question: what is actually inside your phone?', + 'Personal Website': 'The page you are reading, built with Next.js and open source from front to back.', + Cosmose: 'A small demo gallery of Jetpack Compose components and animations.', + Common: 'A Kotlin library of the utilities and extensions I kept rewriting for Android.', + 'De-Gmojify': 'A Chrome extension that swaps Google emojis for the ones your system already ships.', + Actio: 'A Figma plugin that resizes frames to an aspect ratio, so nobody does the math by hand.', +} + +const STAT_LABELS: Record = { + downloads: 'Downloads', + rating: 'Rating, about 2,000 reviews', +} + +const lead = projects.find((project) => project.spotlight) +const rest = projects.filter((project) => !project.spotlight && !project.deprecated) + +const sentenceFor = (project: Project) => SENTENCES[project.title] ?? project.description + +const statValue = (stat: { value: string; label: string }) => (stat.label === 'rating' ? `${stat.value}/5` : stat.value) + +/** Counts "500k+" up. Leaves non-integer values like "4.7/5" static. */ +const StatValue = ({ value }: { value: string }) => { + const match = /^(\d+)(\D*)$/.exec(value) + if (!match) return <>{value} + return +} + +const LeadArtwork = ({ project }: { project: Project }) => { + if (!project.image) return null + + return ( +
+ {project.image.alt} +
+
+
+ ) +} + +const Lead = ({ project }: { project: Project }) => ( +
+ + +
+
+

+ {project.title} +

+ {project.techStack.join(' / ')} +
+ +
+
+

{sentenceFor(project)}

+ +
+ {project.links[0] && ( + // Not `ActionLink`: its `primary` hover is wrong over a picture. + + {project.links[0].label} + + + )} + {project.caseStudies?.map((study) => ( + + {study.label} + + + ))} +
+
+ + {/* flex-col-reverse keeps a valid dt-then-dd order with the figure on top. */} +
+ {project.spotlightStats?.map((stat) => ( +
+
{STAT_LABELS[stat.label] ?? stat.label}
+
+ +
+
+ ))} +
+
+
+
+) + +const Row = ({ project }: { project: Project }) => { + const link = project.links[0] + + return ( + + {/* w-56, not w-48: "Personal Website" measures 181px and would wrap. */} +

+ {project.title} +

+

{sentenceFor(project)}

+ {link ? ( + + ) : ( + // Keeps the sentence column ending on the same edge as the linked rows. + + )} +
+ ) +} + +export const Work = () => ( +
+ +
+ Work + +

+ Ten years of shipping. Everything here is mine, built end to end. +

+
+
+ +
+ {lead && ( + + + + )} + + +
    + {rest.map((project) => ( +
  • + +
  • + ))} + +
  • +

    Libraries, experiments and more.

    + Everything else lives on GitHub +
  • +
+
+
+
+
+) diff --git a/src/app/(home)/_data/projects.ts b/src/app/(home)/_data/projects.ts new file mode 100644 index 0000000..cfee2ff --- /dev/null +++ b/src/app/(home)/_data/projects.ts @@ -0,0 +1,113 @@ +export type CaseStudyLink = { + slug: string + label: string +} + +export type ProjectLink = { + url: string + label: string +} + +const githubLink = (repo: string): ProjectLink => ({ + url: `https://github.com/${repo}`, + label: 'See on GitHub', +}) + +const playStoreLink = (packageName: string): ProjectLink => ({ + url: `https://play.google.com/store/apps/details?id=${packageName}`, + label: 'Google Play', +}) + +export type SpotlightStat = { + value: string + label: string +} + +export type ProjectImage = { + path: string + alt: string +} + +export type Project = { + title: string + description: string + image?: ProjectImage + techStack: string[] + openSource: boolean + featured?: boolean + spotlight?: boolean // Rendered as the oversized lead block in Selected Work + spotlightStats?: SpotlightStat[] + deprecated?: boolean + caseStudies?: CaseStudyLink[] + links: ProjectLink[] +} + +export const projects: Project[] = [ + { + title: 'Inware', + description: + "An Android app I've been building since 2018 to answer one question: what is actually inside your phone? It digs into the hardware and software (CPU, display, sensors, camera, battery) and themes itself to your wallpaper with Material You.", + image: { + path: '/content/projects/inware-promo.png', + alt: 'Screens from the Inware app on Android', + }, + techStack: ['Kotlin', 'Jetpack Compose', 'Android'], + openSource: false, + featured: true, + spotlight: true, + spotlightStats: [ + { value: '500k+', label: 'downloads' }, + { value: '4.7', label: 'rating' }, + ], + caseStudies: [{ slug: 'inware', label: 'Case Study' }], + links: [playStoreLink('com.evo.inware')], + }, + { + title: 'Personal Website', + description: "You're looking at it! Built with Next.js and Tailwind CSS.", + techStack: ['Next.js', 'TypeScript', 'Tailwind CSS'], + openSource: true, + featured: false, + links: [githubLink('evowizz/evowizz.dev')], + }, + { + title: 'Cosmose', + description: 'A simple Jetpack Compose demo gallery showcasing various UI components and animations.', + techStack: ['Kotlin', 'Jetpack Compose', 'Android'], + openSource: true, + links: [githubLink('evowizz/cosmose')], + }, + { + title: 'Common', + description: + 'A Kotlin library providing common utilities and extensions for Android development, including helpers for views, networking, and data processing.', + techStack: ['Kotlin', 'Android'], + openSource: true, + links: [githubLink('evowizz/common')], + }, + { + title: 'De-Gmojify', + description: + 'A Chrome extension that replaces Google emojis with standard system emojis for a more consistent browsing experience.', + techStack: ['JavaScript', 'Chrome Extension'], + openSource: true, + links: [githubLink('evowizz/De-Gmojify')], + }, + { + title: 'Actio', + description: + 'A small Figma plugin for resizing frames and rectangles based on aspect ratios with the purpose of eliminating manual dimension calculations.', + techStack: ['TypeScript', 'Figma Plugin API'], + openSource: false, + links: [], + }, + { + title: 'Compose to Edge', + description: + 'Edge-to-edge display implementation for Android apps using Jetpack Compose, providing a modern full-screen experience.', + techStack: ['Kotlin', 'Jetpack Compose', 'Android'], + openSource: true, + deprecated: true, + links: [githubLink('evowizz/compose-to-edge')], + }, +] diff --git a/src/app/(home)/_data/skills.ts b/src/app/(home)/_data/skills.ts new file mode 100644 index 0000000..089fd3b --- /dev/null +++ b/src/app/(home)/_data/skills.ts @@ -0,0 +1,107 @@ +import type { SkillLogos } from '@/components/svg' + +export type Skill = { + name: string + logo: SkillLogos + url: string + kind: 'language' | 'framework' | 'tool' | 'other' +} + +export const skills: Skill[] = [ + { + name: 'Kotlin', + logo: 'kotlin', + url: 'https://kotlinlang.org/', + kind: 'language', + }, + { + name: 'Java', + logo: 'java', + url: 'https://www.java.com/', + kind: 'language', + }, + { + name: 'Dart', + logo: 'dart', + url: 'https://dart.dev/', + kind: 'language', + }, + { + name: 'TypeScript', + logo: 'typescript', + url: 'https://www.typescriptlang.org/', + kind: 'language', + }, + { + name: 'JavaScript', + logo: 'javascript', + url: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript', + kind: 'language', + }, + { + name: 'Python', + logo: 'python', + url: 'https://www.python.org/', + kind: 'language', + }, + { + name: 'Jetpack Compose', + logo: 'compose', + url: 'https://developer.android.com/jetpack/compose', + kind: 'framework', + }, + { + name: 'Flutter', + logo: 'flutter', + url: 'https://flutter.dev/', + kind: 'framework', + }, + { + name: 'React', + logo: 'react', + url: 'https://react.dev/', + kind: 'framework', + }, + { + name: 'Next.js', + logo: 'nextjs', + url: 'https://nextjs.org/', + kind: 'framework', + }, + { + name: 'Tailwind CSS', + logo: 'tailwindcss', + url: 'https://tailwindcss.com/', + kind: 'framework', + }, + { + name: 'Figma', + logo: 'figma', + url: 'https://www.figma.com/', + kind: 'tool', + }, + { + name: 'Android Studio', + logo: 'androidstudio', + url: 'https://developer.android.com/studio', + kind: 'tool', + }, + { + name: 'Visual Studio Code', + logo: 'vscode', + url: 'https://code.visualstudio.com/', + kind: 'tool', + }, + { + name: 'IntelliJ IDEA', + logo: 'intellijidea', + url: 'https://www.jetbrains.com/idea/', + kind: 'tool', + }, + { + name: 'Material Design', + logo: 'materialdesign', + url: 'https://material.io/', + kind: 'other', + }, +] diff --git a/src/app/(home)/_hooks/use-paris-clock.ts b/src/app/(home)/_hooks/use-paris-clock.ts new file mode 100644 index 0000000..a150292 --- /dev/null +++ b/src/app/(home)/_hooks/use-paris-clock.ts @@ -0,0 +1,39 @@ +import { useEffect, useState } from 'react' + +const PARIS = 'Europe/Paris' + +/** Reads the name and offset off the zone. Paris is CET in winter, CEST in summer. */ +const readParisClock = () => { + const now = new Date() + + const time = new Intl.DateTimeFormat('en-GB', { + timeZone: PARIS, + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23', + }).format(now) + + const zoneName = (style: 'short' | 'shortOffset') => + new Intl.DateTimeFormat('en-GB', { timeZone: PARIS, timeZoneName: style }) + .formatToParts(now) + .find((part) => part.type === 'timeZoneName')?.value ?? '' + + const offset = zoneName('shortOffset').replace('GMT', 'UTC') + const name = zoneName('short') + + return { time, zone: name && !name.startsWith('GMT') ? `${name} (${offset})` : offset } +} + +/** Null until mounted. The page is static, so a server clock would show build time. */ +export const useParisClock = () => { + const [clock, setClock] = useState<{ time: string; zone: string } | null>(null) + + useEffect(() => { + const tick = () => setClock(readParisClock()) + tick() + const id = setInterval(tick, 30000) + return () => clearInterval(id) + }, []) + + return clock +} diff --git a/src/app/(home)/page.tsx b/src/app/(home)/page.tsx new file mode 100644 index 0000000..4bf604d --- /dev/null +++ b/src/app/(home)/page.tsx @@ -0,0 +1,18 @@ +import { About, Contact, Hero, Press, Skills, Work } from './_components/sections' + +export default function Home() { + return ( +
+
+ +
+
+ + + + + +
+
+ ) +} diff --git a/src/app/api/mcu/image/route.tsx b/src/app/api/mcu/image/route.tsx new file mode 100644 index 0000000..7ec41c2 --- /dev/null +++ b/src/app/api/mcu/image/route.tsx @@ -0,0 +1,71 @@ +import { NextRequest, NextResponse } from 'next/server' +import { ImageResponse } from 'next/og' +import { parsePaletteRequest, requestPalette } from '@/lib/mcu' + +export async function GET(request: NextRequest) { + const params = request.nextUrl.searchParams + const paletteRequest = parsePaletteRequest(params) + + if (!paletteRequest) { + return NextResponse.json({ error: 'Something went wrong' }, { status: 500 }) + } + + const palette = requestPalette(paletteRequest) + const labels = params.has('labels') && !paletteRequest.full + + return new ImageResponse( +
+ {Object.entries(palette).map(([key, value]) => ( +
+ {labels && ( + + {key} + + )} + + {paletteRequest.full && key === '500' && ( + + + + + )} +
+ ))} +
, + { + width: 1920, + height: 1080, + }, + ) +} diff --git a/src/app/api/mcu/route.ts b/src/app/api/mcu/route.ts new file mode 100644 index 0000000..f0a1cba --- /dev/null +++ b/src/app/api/mcu/route.ts @@ -0,0 +1,12 @@ +import { NextRequest, NextResponse } from 'next/server' +import { parsePaletteRequest, requestPalette } from '@/lib/mcu' + +export async function GET(request: NextRequest) { + const palette = parsePaletteRequest(request.nextUrl.searchParams) + + if (!palette) { + return NextResponse.json({ error: 'Something went wrong' }, { status: 500 }) + } + + return NextResponse.json(requestPalette(palette)) +} diff --git a/src/app/api/placeholder/[width]/[height]/route.tsx b/src/app/api/placeholder/[width]/[height]/route.tsx new file mode 100644 index 0000000..20bb82b --- /dev/null +++ b/src/app/api/placeholder/[width]/[height]/route.tsx @@ -0,0 +1,59 @@ +import { ImageResponse } from 'next/og' +import { NextRequest } from 'next/server' + +export async function GET(request: NextRequest, { params }: { params: Promise<{ width: string; height: string }> }) { + const { width, height } = await params + const w = parseInt(width, 10) + const h = parseInt(height, 10) + + // Validate and bound dimensions (min 1, max 4096 to prevent memory issues) + const validW = Math.min(4096, Math.max(1, isNaN(w) ? 800 : w)) + const validH = Math.min(4096, Math.max(1, isNaN(h) ? 600 : h)) + + const { searchParams } = new URL(request.url) + const bgColor = searchParams.get('bgColor') || '#1f1f1f' + + return new ImageResponse( + , + { + width: validW, + height: validH, + }, + ) +} + +function StandardPlaceholder({ + width, + height, + bgColor, + textColor, +}: { + width: number + height: number + bgColor: string + textColor: string +}) { + return ( +
+
+ {`${width} × ${height}`} +
+
+ ) +} diff --git a/src/app/apple-icon.png b/src/app/apple-icon.png new file mode 100644 index 0000000..a0a6da9 Binary files /dev/null and b/src/app/apple-icon.png differ diff --git a/src/app/avatar.png b/src/app/avatar.png new file mode 100644 index 0000000..56d05e4 Binary files /dev/null and b/src/app/avatar.png differ diff --git a/src/app/blog/[slug]/_components/view-reporter.tsx b/src/app/blog/[slug]/_components/view-reporter.tsx new file mode 100644 index 0000000..df7ca7a --- /dev/null +++ b/src/app/blog/[slug]/_components/view-reporter.tsx @@ -0,0 +1,21 @@ +'use client' + +import { increment } from '@/db/views/actions' +import { useEffect, useRef } from 'react' + +type ViewReporterProps = { + slug: string +} + +export const ViewReporter = ({ slug }: ViewReporterProps) => { + const lastReportedSlug = useRef(null) + + useEffect(() => { + if (lastReportedSlug.current !== slug) { + lastReportedSlug.current = slug + void increment(slug).catch(() => {}) + } + }, [slug]) + + return null +} diff --git a/src/app/blog/[slug]/opengraph-image.tsx b/src/app/blog/[slug]/opengraph-image.tsx new file mode 100644 index 0000000..94f8477 --- /dev/null +++ b/src/app/blog/[slug]/opengraph-image.tsx @@ -0,0 +1,12 @@ +import { allPosts } from '@/content' +import { buildOgImage, OG_IMAGE_SIZE } from '@/lib/og/og-image' + +export const size = OG_IMAGE_SIZE +export const contentType = 'image/png' + +export default async function OpengraphImage({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params + const post = allPosts.find((post) => post.slug === slug) + + return buildOgImage({ title: post?.title ?? null, description: post?.summary }) +} diff --git a/src/app/blog/[slug]/page.tsx b/src/app/blog/[slug]/page.tsx new file mode 100644 index 0000000..60fcf55 --- /dev/null +++ b/src/app/blog/[slug]/page.tsx @@ -0,0 +1,157 @@ +import { Suspense } from 'react' +import Image from 'next/image' +import dayjs from 'dayjs' +import { notFound } from 'next/navigation' +import { connection } from 'next/server' +import { allPosts, Post } from '@/content' +import MDXContent from '@/components/mdx/mdx-content' +import { ArticleWithRuler } from '@/components/article/article-with-ruler' +import { ArticleLoadingShell, ArticlePageShell } from '@/components/article/article-page-shell' +import { ViewReporter } from './_components/view-reporter' +import { MaterialSymbol } from '@/components/ui/material-symbol' +import { ThemeOverride } from '@/theme/material-theme' +import { EditOnGitHub } from '@/components/article/edit-on-github' +import { Reveal } from '@/components/ui/reveal' +import { getViewsBySlug } from '@/db/views/queries' +import { countWords, formatWords } from '@/lib/words' +import { TWITTER_HANDLE } from '@/config/site' + +export const prefetch = 'partial' + +type BlogPostProps = { + params: Promise<{ slug: string }> +} + +export async function generateStaticParams() { + return allPosts.map((post) => ({ slug: post.slug })) +} + +export async function generateMetadata({ params }: BlogPostProps) { + 'use cache' + + const { slug } = await params + const post = findPost(slug) + if (!post) return + + const images = post.image ? [{ url: post.image }] : undefined + + return { + title: post.title, + description: post.summary, + robots: post.hidden ? { index: false, follow: false } : undefined, + openGraph: { + title: post.title, + description: post.summary, + type: 'article', + publishedTime: post.publishedAt, + url: `https://evowizz.dev/blog/${slug}`, + images, + }, + twitter: { + card: 'summary_large_image', + title: post.title, + description: post.summary, + creator: TWITTER_HANDLE, + images, + }, + } +} + +async function Views({ slug }: { slug: string }) { + await connection() + + const view = await getViewsBySlug(slug) + const count = view?.count ?? 0 + + return ( + <> + + {count > 0 && ( + <> + + / + + + + {count.toLocaleString()} + views + + + )} + + ) +} + +export default function BlogPost({ params }: BlogPostProps) { + return ( + + }> + + + + ) +} + +async function BlogPostContent({ params }: BlogPostProps) { + const { slug } = await params + const post = findPost(slug) + + if (!post) { + notFound() + } + + return ( + <> + + + +

+ + + / + + + + {formatWords(countWords(post.content))} + words + + + + +

+ +

+ {post.title} +

+ +

{post.summary}

+ + {post.image && ( +
+ +
+ )} +
+ +

+ ---- +

+ +
+ +
+
+ +
+ +
+ + ) +} + +function findPost(slug: string) { + return allPosts.find((post: Post) => post.slug === slug) +} diff --git a/src/app/blog/opengraph-image.tsx b/src/app/blog/opengraph-image.tsx new file mode 100644 index 0000000..1982f0b --- /dev/null +++ b/src/app/blog/opengraph-image.tsx @@ -0,0 +1,9 @@ +import { buildOgImage, OG_IMAGE_SIZE } from '@/lib/og/og-image' +import { title, description } from './page' + +export const size = OG_IMAGE_SIZE +export const contentType = 'image/png' + +export default async function OpengraphImage() { + return buildOgImage({ title, description }) +} diff --git a/src/app/blog/page.tsx b/src/app/blog/page.tsx new file mode 100644 index 0000000..55d312d --- /dev/null +++ b/src/app/blog/page.tsx @@ -0,0 +1,130 @@ +import { Suspense } from 'react' +import Link from 'next/link' +import { connection } from 'next/server' +import dayjs from 'dayjs' +import { allPosts } from '@/content' +import { getViewsCount } from '@/db/views/queries' +import { Container } from '@/components/ui/container' +import { PageTitle } from '@/components/ui/typography' +import { MaterialSymbol } from '@/components/ui/material-symbol' +import { TWITTER_HANDLE } from '@/config/site' +import type { Metadata } from 'next' + +export const title = 'Blog' +export const description = "Whatever I've been building, breaking, or thinking about." + +export const metadata: Metadata = { + title, + description, + openGraph: { + type: 'website', + url: '/blog', + title, + description, + }, + twitter: { + card: 'summary_large_image', + title, + description, + creator: TWITTER_HANDLE, + }, +} + +export default function BlogPage() { + const posts = allPosts + .filter((post) => !post.hidden) + .sort((a, b) => dayjs(b.publishedAt).unix() - dayjs(a.publishedAt).unix()) + + const years = posts.map((post) => dayjs(post.publishedAt).year()) + const firstYear = Math.min(...years) + const lastYear = Math.max(...years) + const span = firstYear === lastYear ? `${firstYear}` : `${firstYear}-${lastYear}` + + return ( +
+ +
+ {title} +

{description}

+

+ + {posts.length} {posts.length === 1 ? 'entry' : 'entries'} + + + / + + {span} +

+
+ +
    + {posts.map((post, index) => ( +
  • + +
  • + ))} +
+
+
+ ) +} + +type PostEntry = (typeof allPosts)[number] + +const PostMeta = ({ post, latest }: { post: PostEntry; latest?: boolean }) => ( +

+ {latest && ( + <> + Latest + + / + + + )} + + + + +

+) + +const PostRow = ({ post, latest }: { post: PostEntry; latest?: boolean }) => ( + +
+ + +

+ {post.title} +

+ +

{post.summary}

+
+ +) + +async function Views({ slug }: { slug: string }) { + await connection() + + // getViewsCount uses 'use cache' with cacheLife('seconds'), so parallel + // calls from multiple Views components share the same cached result. + const allViews = await getViewsCount() + const count = allViews.find((v) => v.slug === slug)?.count ?? 0 + + if (!count) return null + + return ( + <> + + / + + + + {count.toLocaleString()} + views + + + ) +} diff --git a/src/app/case-studies/[slug]/_components/case-study-header.tsx b/src/app/case-studies/[slug]/_components/case-study-header.tsx new file mode 100644 index 0000000..95e2859 --- /dev/null +++ b/src/app/case-studies/[slug]/_components/case-study-header.tsx @@ -0,0 +1,54 @@ +import Image from 'next/image' +import { Label } from '@/components/ui/typography' +import { Reveal } from '@/components/ui/reveal' + +export type CaseStudyMeta = { + title: string + overview: string + stack: string[] + role?: string + image?: string +} + +type CaseStudyHeaderProps = { + meta: CaseStudyMeta +} + +export function CaseStudyHeader({ meta }: CaseStudyHeaderProps) { + return ( +
+ +

+ {meta.title} +

+ +

+ {meta.overview} +

+ +
+ {meta.role && ( +
+ + {meta.role} +
+ )} +
+ + {meta.stack.join(' / ')} +
+
+
+ + {meta.image && ( + +
+
+ {meta.title} +
+
+
+ )} +
+ ) +} diff --git a/src/app/case-studies/[slug]/page.tsx b/src/app/case-studies/[slug]/page.tsx new file mode 100644 index 0000000..926f8a4 --- /dev/null +++ b/src/app/case-studies/[slug]/page.tsx @@ -0,0 +1,97 @@ +import { Suspense } from 'react' +import { notFound } from 'next/navigation' +import { allCaseStudies, CaseStudy } from '@/content' +import MDXContent from '@/components/mdx/mdx-content' +import { ArticleWithRuler } from '@/components/article/article-with-ruler' +import { ArticleLoadingShell, ArticlePageShell } from '@/components/article/article-page-shell' +import { CaseStudyHeader } from './_components/case-study-header' +import { ThemeOverride } from '@/theme/material-theme' +import { EditOnGitHub } from '@/components/article/edit-on-github' +import { TWITTER_HANDLE } from '@/config/site' + +export const prefetch = 'partial' + +type CaseStudyPageProps = { + params: Promise<{ slug: string }> +} + +export async function generateStaticParams() { + return allCaseStudies.map((item) => ({ slug: item.slug })) +} + +export async function generateMetadata({ params }: CaseStudyPageProps) { + 'use cache' + + const { slug } = await params + const item = findCaseStudy(slug) + if (!item) return + const image = item.image + + return { + title: item.title, + description: item.overview, + robots: item.hidden ? { index: false, follow: false } : undefined, + openGraph: { + title: item.title, + description: item.overview, + type: 'article', + url: `https://evowizz.dev/case-studies/${slug}`, + images: [{ url: image }], + }, + twitter: { + card: 'summary_large_image', + title: item.title, + description: item.overview, + creator: TWITTER_HANDLE, + images: [{ url: image }], + }, + } +} + +export default function CaseStudyPage({ params }: CaseStudyPageProps) { + return ( + + }> + + + + ) +} + +async function CaseStudyContent({ params }: CaseStudyPageProps) { + const { slug } = await params + const item = findCaseStudy(slug) + + if (!item) { + notFound() + } + + return ( + <> + + + + +
+ +
+
+ +
+ +
+ + ) +} + +function findCaseStudy(slug: string) { + return allCaseStudies.find((caseStudy: CaseStudy) => caseStudy.slug === slug) +} diff --git a/src/app/case-studies/opengraph-image.tsx b/src/app/case-studies/opengraph-image.tsx new file mode 100644 index 0000000..1982f0b --- /dev/null +++ b/src/app/case-studies/opengraph-image.tsx @@ -0,0 +1,9 @@ +import { buildOgImage, OG_IMAGE_SIZE } from '@/lib/og/og-image' +import { title, description } from './page' + +export const size = OG_IMAGE_SIZE +export const contentType = 'image/png' + +export default async function OpengraphImage() { + return buildOgImage({ title, description }) +} diff --git a/src/app/case-studies/page.tsx b/src/app/case-studies/page.tsx new file mode 100644 index 0000000..aed8e97 --- /dev/null +++ b/src/app/case-studies/page.tsx @@ -0,0 +1,73 @@ +import Link from 'next/link' +import { allCaseStudies } from '@/content' +import { Container } from '@/components/ui/container' +import { PageTitle } from '@/components/ui/typography' +import { TWITTER_HANDLE } from '@/config/site' +import type { Metadata } from 'next' + +export const title = 'Case Studies' +export const description = 'The decisions I made, and the ones I deleted.' + +export const metadata: Metadata = { + title, + description, + openGraph: { + type: 'website', + url: '/case-studies', + title, + description, + }, + twitter: { + card: 'summary_large_image', + title, + description, + creator: TWITTER_HANDLE, + }, +} + +export default function CaseStudiesPage() { + const caseStudies = allCaseStudies.filter((item) => !item.hidden) + + return ( +
+ +
+ {title} +

{description}

+

+ + {caseStudies.length} {caseStudies.length === 1 ? 'case study' : 'case studies'} + +

+
+ +
    + {caseStudies.map((item) => ( +
  • + +
  • + ))} +
+
+
+ ) +} + +type CaseStudyEntry = (typeof allCaseStudies)[number] + +const CaseStudyRow = ({ item }: { item: CaseStudyEntry }) => ( + +
+

{item.stack.join(' / ')}

+ +

+ {item.title} +

+ +

{item.overview}

+
+ +) diff --git a/src/app/favicon.ico b/src/app/favicon.ico index 718d6fe..8888f40 100644 Binary files a/src/app/favicon.ico and b/src/app/favicon.ico differ diff --git a/src/app/globals.css b/src/app/globals.css index 5e289f6..56dec79 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,27 +1,1046 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; +@layer external; +@import 'material-symbols/rounded.css' layer(external); +@import 'tailwindcss'; +@plugin '@tailwindcss/typography'; + +/* Default Material Design 3 Color Tokens */ :root { - --color-background: oklch(98.51% 0 89.88); + color-scheme: light; + + /* Light mode tokens */ + --md-sys-color-primary-light: #006d3b; + --md-sys-color-on-primary-light: #ffffff; + --md-sys-color-primary-container-light: #98f7b5; + --md-sys-color-on-primary-container-light: #00522b; + --md-sys-color-inverse-primary-light: #7dda9b; + --md-sys-color-secondary-light: #4f6353; + --md-sys-color-on-secondary-light: #ffffff; + --md-sys-color-secondary-container-light: #d2e8d4; + --md-sys-color-on-secondary-container-light: #384b3c; + --md-sys-color-tertiary-light: #3a646f; + --md-sys-color-on-tertiary-light: #ffffff; + --md-sys-color-tertiary-container-light: #beeaf6; + --md-sys-color-on-tertiary-container-light: #214c57; + --md-sys-color-error-light: #ba1a1a; + --md-sys-color-on-error-light: #ffffff; + --md-sys-color-error-container-light: #ffdad6; + --md-sys-color-on-error-container-light: #93000a; + --md-sys-color-background-light: #f9f9f9; + --md-sys-color-on-background-light: #1b1b1b; + --md-sys-color-surface-light: #f9f9f9; + --md-sys-color-on-surface-light: #1b1b1b; + --md-sys-color-surface-variant-light: #e2e2e2; + --md-sys-color-on-surface-variant-light: #474747; + --md-sys-color-surface-dim-light: #dadada; + --md-sys-color-surface-bright-light: #f9f9f9; + --md-sys-color-surface-container-lowest-light: #ffffff; + --md-sys-color-surface-container-low-light: #f3f3f3; + --md-sys-color-surface-container-light: #eeeeee; + --md-sys-color-surface-container-high-light: #e8e8e8; + --md-sys-color-surface-container-highest-light: #e2e2e2; + --md-sys-color-outline-light: #777777; + --md-sys-color-outline-variant-light: #c6c6c6; + --md-sys-color-shadow-light: #000000; + --md-sys-color-scrim-light: #000000; + --md-sys-color-inverse-surface-light: #303030; + --md-sys-color-inverse-on-surface-light: #f1f1f1; + --md-sys-color-surface-tint-light: #006d3b; + --md-sys-color-primary-fixed-light: #98f7b5; + --md-sys-color-primary-fixed-dim-light: #7dda9b; + --md-sys-color-on-primary-fixed-light: #00210e; + --md-sys-color-on-primary-fixed-variant-light: #00522b; + --md-sys-color-secondary-fixed-light: #d2e8d4; + --md-sys-color-secondary-fixed-dim-light: #b6ccb9; + --md-sys-color-on-secondary-fixed-light: #0d1f13; + --md-sys-color-on-secondary-fixed-variant-light: #384b3c; + --md-sys-color-tertiary-fixed-light: #beeaf6; + --md-sys-color-tertiary-fixed-dim-light: #a2ceda; + --md-sys-color-on-tertiary-fixed-light: #001f26; + --md-sys-color-on-tertiary-fixed-variant-light: #214c57; + + /* Dark mode tokens */ + --md-sys-color-primary-dark: #7dda9b; + --md-sys-color-on-primary-dark: #00391c; + --md-sys-color-primary-container-dark: #00522b; + --md-sys-color-on-primary-container-dark: #98f7b5; + --md-sys-color-inverse-primary-dark: #006d3b; + --md-sys-color-secondary-dark: #b6ccb9; + --md-sys-color-on-secondary-dark: #223527; + --md-sys-color-secondary-container-dark: #384b3c; + --md-sys-color-on-secondary-container-dark: #d2e8d4; + --md-sys-color-tertiary-dark: #a2ceda; + --md-sys-color-on-tertiary-dark: #023640; + --md-sys-color-tertiary-container-dark: #214c57; + --md-sys-color-on-tertiary-container-dark: #beeaf6; + --md-sys-color-error-dark: #ffb4ab; + --md-sys-color-on-error-dark: #690005; + --md-sys-color-error-container-dark: #93000a; + --md-sys-color-on-error-container-dark: #ffdad6; + --md-sys-color-background-dark: #131313; + --md-sys-color-on-background-dark: #e2e2e2; + --md-sys-color-surface-dark: #131313; + --md-sys-color-on-surface-dark: #e2e2e2; + --md-sys-color-surface-variant-dark: #474747; + --md-sys-color-on-surface-variant-dark: #c6c6c6; + --md-sys-color-surface-dim-dark: #131313; + --md-sys-color-surface-bright-dark: #393939; + --md-sys-color-surface-container-lowest-dark: #0e0e0e; + --md-sys-color-surface-container-low-dark: #1b1b1b; + --md-sys-color-surface-container-dark: #1f1f1f; + --md-sys-color-surface-container-high-dark: #2a2a2a; + --md-sys-color-surface-container-highest-dark: #353535; + --md-sys-color-outline-dark: #919191; + --md-sys-color-outline-variant-dark: #474747; + --md-sys-color-shadow-dark: #000000; + --md-sys-color-scrim-dark: #000000; + --md-sys-color-inverse-surface-dark: #e2e2e2; + --md-sys-color-inverse-on-surface-dark: #303030; + --md-sys-color-surface-tint-dark: #7dda9b; + --md-sys-color-primary-fixed-dark: #98f7b5; + --md-sys-color-primary-fixed-dim-dark: #7dda9b; + --md-sys-color-on-primary-fixed-dark: #00210e; + --md-sys-color-on-primary-fixed-variant-dark: #00522b; + --md-sys-color-secondary-fixed-dark: #d2e8d4; + --md-sys-color-secondary-fixed-dim-dark: #b6ccb9; + --md-sys-color-on-secondary-fixed-dark: #0d1f13; + --md-sys-color-on-secondary-fixed-variant-dark: #384b3c; + --md-sys-color-tertiary-fixed-dark: #beeaf6; + --md-sys-color-tertiary-fixed-dim-dark: #a2ceda; + --md-sys-color-on-tertiary-fixed-dark: #001f26; + --md-sys-color-on-tertiary-fixed-variant-dark: #214c57; } [data-theme='dark'] { - --color-background: oklch(17.76% 0 89.88); + color-scheme: dark; +} + +@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *)); +@custom-variant mobile-hover (&:hover); + +@theme inline { + /* Keyframes */ + @keyframes hero-opacity { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + + @keyframes press-ribbon { + from { + transform: translateX(0); + } + to { + transform: translateX(-100%); + } + } + + @keyframes press-ribbon-reverse { + from { + transform: translateX(-100%); + } + to { + transform: translateX(0); + } + } + + /* Animation */ + --animate-hero-appear: hero-slide 0.5s ease-in-out forwards, hero-opacity 0.5s ease-in-out forwards; + --animate-hero-slide: hero-slide 0.5s ease-in-out forwards; + --animate-hero-opacity: hero-opacity 0.5s ease-in-out forwards; + --animate-press-ribbon: press-ribbon 50s linear infinite; + --animate-press-ribbon-reverse: press-ribbon-reverse 50s linear infinite; + + /* Animation easing */ + --ease-slow-in: cubic-bezier(0, 0, 0, 1); + --ease-slow-in-out: cubic-bezier(0.5, 0, 0.25, 1); + + /* Font families */ + --font-sans: var(--font-google-sans-flex), system-ui, sans-serif; + --font-sans--font-feature-settings: 'calt'; + + --font-serif: var(--font-roboto-slab), Georgia, serif; + + --font-mono: var(--font-google-sans-code), ui-monospace, monospace; + --font-mono--font-feature-settings: 'ss01'; + + /* Font weights */ + --font-weight-100: 100; + --font-weight-200: 200; + --font-weight-300: 300; + --font-weight-400: 400; + --font-weight-500: 500; + --font-weight-600: 600; + --font-weight-700: 700; + --font-weight-800: 800; + --font-weight-900: 900; + + /* Border radii */ + --radius-5xl: 2.5rem; + + /* Colors - Reset and Material Design tokens */ + --color-*: initial; + + /* Base colors */ + --color-black: oklch(0% 0 0); + --color-white: oklch(100% 0 0); + + /* Primary */ + --color-primary: light-dark(var(--md-sys-color-primary-light), var(--md-sys-color-primary-dark)); + --color-on-primary: light-dark(var(--md-sys-color-on-primary-light), var(--md-sys-color-on-primary-dark)); + --color-primary-container: light-dark( + var(--md-sys-color-primary-container-light), + var(--md-sys-color-primary-container-dark) + ); + --color-on-primary-container: light-dark( + var(--md-sys-color-on-primary-container-light), + var(--md-sys-color-on-primary-container-dark) + ); + --color-inverse-primary: light-dark( + var(--md-sys-color-inverse-primary-light), + var(--md-sys-color-inverse-primary-dark) + ); + + /* Secondary */ + --color-secondary: light-dark(var(--md-sys-color-secondary-light), var(--md-sys-color-secondary-dark)); + --color-on-secondary: light-dark(var(--md-sys-color-on-secondary-light), var(--md-sys-color-on-secondary-dark)); + --color-secondary-container: light-dark( + var(--md-sys-color-secondary-container-light), + var(--md-sys-color-secondary-container-dark) + ); + --color-on-secondary-container: light-dark( + var(--md-sys-color-on-secondary-container-light), + var(--md-sys-color-on-secondary-container-dark) + ); + + /* Tertiary */ + --color-tertiary: light-dark(var(--md-sys-color-tertiary-light), var(--md-sys-color-tertiary-dark)); + --color-on-tertiary: light-dark(var(--md-sys-color-on-tertiary-light), var(--md-sys-color-on-tertiary-dark)); + --color-tertiary-container: light-dark( + var(--md-sys-color-tertiary-container-light), + var(--md-sys-color-tertiary-container-dark) + ); + --color-on-tertiary-container: light-dark( + var(--md-sys-color-on-tertiary-container-light), + var(--md-sys-color-on-tertiary-container-dark) + ); + + /* Error */ + --color-error: light-dark(var(--md-sys-color-error-light), var(--md-sys-color-error-dark)); + --color-on-error: light-dark(var(--md-sys-color-on-error-light), var(--md-sys-color-on-error-dark)); + --color-error-container: light-dark( + var(--md-sys-color-error-container-light), + var(--md-sys-color-error-container-dark) + ); + --color-on-error-container: light-dark( + var(--md-sys-color-on-error-container-light), + var(--md-sys-color-on-error-container-dark) + ); + + /* Background */ + --color-background: light-dark(var(--md-sys-color-background-light), var(--md-sys-color-background-dark)); + --color-on-background: light-dark(var(--md-sys-color-on-background-light), var(--md-sys-color-on-background-dark)); + + /* Surface */ + --color-surface: light-dark(var(--md-sys-color-surface-light), var(--md-sys-color-surface-dark)); + --color-on-surface: light-dark(var(--md-sys-color-on-surface-light), var(--md-sys-color-on-surface-dark)); + --color-surface-variant: light-dark( + var(--md-sys-color-surface-variant-light), + var(--md-sys-color-surface-variant-dark) + ); + --color-on-surface-variant: light-dark( + var(--md-sys-color-on-surface-variant-light), + var(--md-sys-color-on-surface-variant-dark) + ); + --color-surface-dim: light-dark(var(--md-sys-color-surface-dim-light), var(--md-sys-color-surface-dim-dark)); + --color-surface-bright: light-dark(var(--md-sys-color-surface-bright-light), var(--md-sys-color-surface-bright-dark)); + --color-surface-container-lowest: light-dark( + var(--md-sys-color-surface-container-lowest-light), + var(--md-sys-color-surface-container-lowest-dark) + ); + --color-surface-container-low: light-dark( + var(--md-sys-color-surface-container-low-light), + var(--md-sys-color-surface-container-low-dark) + ); + --color-surface-container: light-dark( + var(--md-sys-color-surface-container-light), + var(--md-sys-color-surface-container-dark) + ); + --color-surface-container-high: light-dark( + var(--md-sys-color-surface-container-high-light), + var(--md-sys-color-surface-container-high-dark) + ); + --color-surface-container-highest: light-dark( + var(--md-sys-color-surface-container-highest-light), + var(--md-sys-color-surface-container-highest-dark) + ); + --color-inverse-surface: light-dark( + var(--md-sys-color-inverse-surface-light), + var(--md-sys-color-inverse-surface-dark) + ); + --color-inverse-on-surface: light-dark( + var(--md-sys-color-inverse-on-surface-light), + var(--md-sys-color-inverse-on-surface-dark) + ); + --color-surface-tint: light-dark(var(--md-sys-color-surface-tint-light), var(--md-sys-color-surface-tint-dark)); + + /* Outline */ + --color-outline: light-dark(var(--md-sys-color-outline-light), var(--md-sys-color-outline-dark)); + --color-outline-variant: light-dark( + var(--md-sys-color-outline-variant-light), + var(--md-sys-color-outline-variant-dark) + ); + + /* Other */ + --color-shadow: light-dark(var(--md-sys-color-shadow-light), var(--md-sys-color-shadow-dark)); + --color-scrim: light-dark(var(--md-sys-color-scrim-light), var(--md-sys-color-scrim-dark)); + + /* Fixed colors */ + --color-primary-fixed: light-dark(var(--md-sys-color-primary-fixed-light), var(--md-sys-color-primary-fixed-dark)); + --color-primary-fixed-dim: light-dark( + var(--md-sys-color-primary-fixed-dim-light), + var(--md-sys-color-primary-fixed-dim-dark) + ); + --color-on-primary-fixed: light-dark( + var(--md-sys-color-on-primary-fixed-light), + var(--md-sys-color-on-primary-fixed-dark) + ); + --color-on-primary-fixed-variant: light-dark( + var(--md-sys-color-on-primary-fixed-variant-light), + var(--md-sys-color-on-primary-fixed-variant-dark) + ); + --color-secondary-fixed: light-dark( + var(--md-sys-color-secondary-fixed-light), + var(--md-sys-color-secondary-fixed-dark) + ); + --color-secondary-fixed-dim: light-dark( + var(--md-sys-color-secondary-fixed-dim-light), + var(--md-sys-color-secondary-fixed-dim-dark) + ); + --color-on-secondary-fixed: light-dark( + var(--md-sys-color-on-secondary-fixed-light), + var(--md-sys-color-on-secondary-fixed-dark) + ); + --color-on-secondary-fixed-variant: light-dark( + var(--md-sys-color-on-secondary-fixed-variant-light), + var(--md-sys-color-on-secondary-fixed-variant-dark) + ); + --color-tertiary-fixed: light-dark(var(--md-sys-color-tertiary-fixed-light), var(--md-sys-color-tertiary-fixed-dark)); + --color-tertiary-fixed-dim: light-dark( + var(--md-sys-color-tertiary-fixed-dim-light), + var(--md-sys-color-tertiary-fixed-dim-dark) + ); + --color-on-tertiary-fixed: light-dark( + var(--md-sys-color-on-tertiary-fixed-light), + var(--md-sys-color-on-tertiary-fixed-dark) + ); + --color-on-tertiary-fixed-variant: light-dark( + var(--md-sys-color-on-tertiary-fixed-variant-light), + var(--md-sys-color-on-tertiary-fixed-variant-dark) + ); +} + +/* + The default border color has changed to `currentcolor` in Tailwind CSS v4, + so we've added these compatibility styles to make sure everything still + looks the same as it did with Tailwind CSS v3. + + If we ever want to remove these styles, we need to add an explicit border + color utility to any element that depends on these defaults. +*/ +@layer base { + :root { + font-size: clamp(1rem, 0.25vw + 0.75rem, 1.25rem); + + /* Height taken off the bottom of the viewport by the dev bar. Zero in + production; the bar overrides it in development. Viewport-anchored + overlays inset by it, since they ignore the body padding reserve. */ + --devbar-h: 0px; + } + + ::selection { + @apply bg-tertiary-container text-on-tertiary-container; + } + + *, + ::after, + ::before, + ::backdrop, + ::file-selector-button { + border-color: var(--color-gray-200, currentcolor); + } + + button { + @apply cursor-pointer; + } + + body > main { + @apply bg-surface rounded-b-5xl relative z-10; + } +} + +@layer base { + @media (prefers-reduced-motion: reduce) { + *, + ::before, + ::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } + } +} + +@utility overlay-noise { + background-image: url('/static/bg/noise.png'); +} + +@utility media-logo { + display: block; + width: min(8rem, calc(1.75rem * var(--media-logo-ratio))); + height: 1.75rem; + background-color: currentcolor; + /* One shorthand: Lightning CSS drops var() longhands it merges after a shorthand. */ + mask: var(--media-logo) no-repeat center / contain; +} + +@utility variation-sans { + font-optical-sizing: auto; + font-variation-settings: + 'wdth' var(--font-wdth, 100), + 'GRAD' var(--font-grad, 0), + 'slnt' var(--font-slnt, 0), + 'ROND' var(--font-rond, 0); +} + +@utility variation-serif { + font-optical-sizing: auto; + font-variation-settings: 'wdth' var(--font-wdth, 100); +} + +@utility variation-none { + font-variation-settings: normal; +} + +@utility variation-width-* { + --font-wdth: --value(integer); + --font-wdth: --value([number]); +} + +@utility variation-grade-* { + --font-grad: --value(integer); + --font-grad: --value([number]); +} + +@utility variation-slant-* { + --font-slnt: --value(integer); + --font-slnt: --value([number]); +} + +@utility -variation-slant-* { + --font-slnt: calc(--value(integer) * -1); +} + +@utility variation-roundness-* { + --font-rond: --value(integer); + --font-rond: --value([number]); +} + +@utility text-balance { + text-wrap: balance; +} + +@utility text-display { + font-size: clamp(2.75rem, 9vw, 6.5rem); + line-height: 0.85; + letter-spacing: -0.03em; +} + +@utility running-head { + @apply text-outline font-mono text-xs tracking-[0.2em] uppercase; + @media (min-width: 48rem) { + writing-mode: vertical-rl; + rotate: 180deg; + } +} + +@utility animation-delay-100 { + animation-delay: 100ms; +} + +@utility animation-delay-200 { + animation-delay: 200ms; +} + +@utility animation-delay-300 { + animation-delay: 300ms; +} + +@utility animation-delay-400 { + animation-delay: 400ms; +} + +@utility animation-delay-500 { + animation-delay: 500ms; +} + +@utility animation-delay-600 { + animation-delay: 600ms; +} + +@utility animation-delay-700 { + animation-delay: 700ms; +} + +@utility animation-delay-800 { + animation-delay: 800ms; +} + +/** + * M3 Expressive motion specs, in two families. + * + * Reach for `spatial` when the animation changes a component's shape or bounds, and `effects` + * when it does not, such as a color or alpha change. + */ + +/** A default spatial motion. */ +@utility motion-spatial-default { + transition-timing-function: cubic-bezier(0.38, 1.21, 0.22, 1); + transition-duration: 500ms; +} + +/** A fast spatial motion. */ +@utility motion-spatial-fast { + transition-timing-function: cubic-bezier(0.42, 1.67, 0.21, 0.9); + transition-duration: 350ms; +} + +/** A slow spatial motion. */ +@utility motion-spatial-slow { + transition-timing-function: cubic-bezier(0.39, 1.29, 0.35, 0.98); + transition-duration: 650ms; +} + +/** A default effects motion. */ +@utility motion-effects-default { + transition-timing-function: cubic-bezier(0.34, 0.8, 0.34, 1); + transition-duration: 200ms; +} + +/** A fast effects motion. */ +@utility motion-effects-fast { + transition-timing-function: cubic-bezier(0.31, 0.94, 0.34, 1); + transition-duration: 150ms; +} + +/** A slow effects motion. */ +@utility motion-effects-slow { + transition-timing-function: cubic-bezier(0.34, 0.88, 0.34, 1); + transition-duration: 300ms; +} + +@utility not-found-backspace-segment { + width: 5rem; + transform-origin: right center; + @apply motion-spatial-slow motion-reduce:transition-none; + transition-property: width, opacity, scale; + + .group\/not-found:hover &, + .group\/not-found:focus-within & { + width: 0; + opacity: 0; + scale: 0.85 1; + } +} + +@utility text-emphasized { + font-weight: 700; + --font-wdth: 115; +} + +@utility text-poster { + font-size: clamp(3.25rem, 13vw, 10rem); + line-height: 0.82; + letter-spacing: -0.045em; +} + +@utility text-condensed { + --font-wdth: 80; + line-height: 0.9; + letter-spacing: -0.02em; +} + +@utility text-measure { + max-inline-size: 60ch; +} + +@utility drop-cap { + &::first-letter { + float: left; + margin-right: 0.08em; + font-size: 2.8em; + line-height: 0.72; + font-weight: 800; + font-optical-sizing: auto; + font-variation-settings: + 'wdth' 110, + 'GRAD' 0, + 'slnt' 0, + 'ROND' 0; + color: var(--color-primary); + } +} + +@utility hide-scrollbar { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ + &::-webkit-scrollbar { + display: none; + } +} + +@layer base { + body[data-scroll-locked='true'] { + overflow: hidden; + padding-right: var(--scrollbar-width, 0px); + } +} + +@utility scrollbar-stable { + body[data-scroll-locked='true'] & { + margin-right: var(--scrollbar-width, 0px); + } +} + +@utility mask-fade-sides-* { + --mask-fade-value: --value(percentage); + mask-image: linear-gradient( + to right, + transparent, + black var(--mask-fade-value), + black calc(100% - var(--mask-fade-value)), + transparent + ); +} + +/* Full height of the usable viewport, minus whatever the dev bar takes. */ +@utility min-h-viewport { + min-height: calc(100dvh - var(--devbar-h)); +} + +@utility focus-ring { + &:focus-visible { + outline-width: 2px; + outline-style: solid; + outline-color: var(--color-primary); + outline-offset: 4px; + } +} + +@utility focus-ring-* { + &:focus-visible { + outline-width: 2px; + outline-style: solid; + outline-color: --value(--color-*); + outline-offset: 4px; + } +} + +@utility hover-light { + @media (hover: hover) and (pointer: fine) { + /* Inspired by www.hover.dev/components/text#bubble-text */ + transition: text-shadow 0.3s; + --light-multiplier: 1; + --light-offset-x: 0; + --light-offset-y: calc(8px * var(--light-multiplier)); + --light-blur: 0; + --light-alpha: 0; + --light-color: color-mix(in oklch, var(--color-on-surface) calc(var(--light-alpha) * 100%), transparent); + text-shadow: var(--light-offset-x) var(--light-offset-y) var(--light-blur) var(--light-color); + + &:hover { + --light-offset-x: 0px; + --light-alpha: 0.4; + --light-blur: calc(1px * var(--light-multiplier)); + } + + /* To the right */ + &:hover + & { + --light-offset-x: calc(4px * var(--light-multiplier)); + --light-alpha: 0.33; + --light-blur: calc(4px * var(--light-multiplier)); + } + + &:hover + & + & { + --light-offset-x: calc(6px * var(--light-multiplier)); + --light-alpha: 0.25; + --light-blur: calc(6px * var(--light-multiplier)); + } + + /* To the left */ + &:has(+ &:hover) { + --light-offset-x: calc(-4px * var(--light-multiplier)); + --light-alpha: 0.33; + --light-blur: calc(4px * var(--light-multiplier)); + } + + &:has(+ & + &:hover) { + --bubble-offset-x: calc(-4px * var(--light-multiplier)); + --bubble-alpha: 0.25; + --bubble-blur: calc(6px * var(--light-multiplier)); + } + } +} + +/* Material Symbols Utilities */ +.material-symbols-rounded { + line-height: 1; + font-variation-settings: + 'FILL' var(--icon-fill, 0), + 'wght' var(--icon-wght, 400), + 'GRAD' var(--icon-grad, 0), + 'opsz' 24; +} + +/* Symbol fill utilities */ +@utility symbol-fill-* { + --icon-fill: --value('0', '1'); +} + +/* Symbol weight utilities */ +@utility symbol-weight-* { + --icon-wght: --value(integer); + --icon-wght: --value([number]); +} + +/* Symbol grade utilities */ +@utility symbol-grade-* { + --icon-grad: --value(integer); + --icon-grad: --value([number]); +} + +/* Ported blog styles */ +@utility prose-quoteless { + blockquote p:first-of-type::before { + content: none; + } + blockquote p:first-of-type::after { + content: none; + } + code::before { + content: none; + } + code::after { + content: none; + } } @layer utilities { - .overlay-noise { - /* Create a vignette effect on top of the noise, as a mask */ - mask-image: radial-gradient( - circle, - rgba(0, 0, 0, 0.5) 0%, - rgba(0, 0, 0, 0.2) 100% - ); - background-image: url('/static/bg/noise.png'); - } - - .text-balance { - text-wrap: balance; + /* Case study figures bleed from the text measure to the paper edge. + Without `.paper`, they stay in the text column. */ + .prose-bleed figure { + margin-inline: calc(-1 * var(--paper-pad, 0px)); + background-color: transparent; + padding: 0; + } + /* Once figures bleed to the paper edge (md and up) they read as print + bleed: square corners, with the caption tucked back into the text + column. In-column images (mobile) keep their rounding. */ + .prose-bleed figure img { + width: 100%; + border-radius: 0.75rem; + @media (width >= 48rem) { + border-radius: 0; + } + } + /* Only content figures frame their img directly. The header's hero + borders its container instead, via reading-frame. */ + .prose.prose-bleed figure img { + border: 1px solid transparent; + @apply motion-effects-slow transition-[border-radius,border-color]; + } + .prose-bleed figcaption { + margin-top: calc(var(--spacing) * 3); + padding: 0 var(--paper-pad, 0px); + text-align: left; + font-size: var(--text-sm); + line-height: var(--text-sm--line-height); + color: var(--color-on-surface-variant); + } + + /* The sheet articles are printed on: a raised paper card from md up. + --paper-pad is the sheet's horizontal padding, which prose-bleed figures + extend into. Reading mode lifts the article off the sheet. */ + .paper { + --paper-pad: 0px; + border: 1px solid transparent; + border-radius: var(--radius-md); + @apply motion-effects-slow transition-[background-color,border-color,box-shadow]; + } + @media (width >= 48rem) { + .paper { + --paper-pad: calc(var(--spacing) * 10); + padding: calc(var(--spacing) * 12) var(--paper-pad); + background-color: var(--color-surface-container-lowest); + border-color: color-mix(in oklab, var(--color-outline-variant) 60%, transparent); + box-shadow: + 0px 1px 4px 1px rgb(0 0 0 / 0.05), + 0px 1px 1px 0px rgb(0 0 0 / 0.16); + } + html[data-theme='dark'] .paper { + background-color: var(--color-surface-container-low); + box-shadow: none; + } + } + @media (width >= 64rem) { + .paper { + --paper-pad: calc(var(--spacing) * 16); + padding: calc(var(--spacing) * 20) var(--paper-pad); + } + } + + /* The dev bar marks while it is mounted, shortening the page by its + height. --devbar-h is 0px otherwise, so this costs nothing in production. */ + :root[data-devbar-visible] { + --devbar-h: 1.75rem; + } + :root[data-devbar-visible] body { + padding-bottom: var(--devbar-h); + } + + /* Outlines every page element while the bar's Borders tool is on. Outline is + used over border so nothing reflows; dev surfaces are excluded. */ + :root[data-devbar-borders] + body + *:not([data-devbar], [data-devbar] *, nextjs-portal, nextjs-portal *, #react-scan-root, #react-scan-root *) { + outline: 1px solid color-mix(in oklab, var(--color-error) 55%, transparent); + outline-offset: -1px; + } + + :root[data-devbar-contrast] [data-contrast-fail] { + outline: 2px dashed var(--color-error); + outline-offset: 2px; + } + + /* Reader preferences live on ``. Reading mode hides chrome and clears paper styling. */ + .reading-hide { + @apply motion-effects-slow transition-[opacity,margin,visibility]; + } + :root[data-reading-mode] .reading-hide { + opacity: 0; + visibility: hidden; + pointer-events: none; + } + :root[data-reading-mode] header.reading-hide { + margin-top: calc(var(--spacing) * -16); + } + :root[data-reading-mode] .paper { + background-color: transparent; + border-color: transparent; + box-shadow: none; + } + /* Full-bleed article media regains its frame in reading mode. */ + .reading-frame { + @apply motion-effects-slow transition-[border-radius,border-color]; + } + :root[data-reading-mode] .reading-frame { + @apply rounded-3xl; + border-inline-color: var(--color-outline-variant); + } + :root[data-reading-mode] .prose.prose-bleed figure img { + border-radius: 0.75rem; + border-color: var(--color-outline-variant); + } + /* The ruler overlay stacks blur layers toward the edge. + Keep opacity on the tint. Parent opacity blocks backdrop sampling until the fade ends. */ + .ruler-scrim { + visibility: hidden; + pointer-events: none; + transition: visibility 0s linear 300ms; + } + .ruler-scrim > div { + @apply motion-effects-slow transition-[opacity,backdrop-filter]; + } + .ruler-scrim > div:nth-child(1), + .ruler-scrim > div:nth-child(2), + .ruler-scrim > div:nth-child(3) { + backdrop-filter: blur(0px); + } + .ruler-scrim > div:nth-child(4) { + opacity: 0; + } + :root[data-ruler-overlay] .ruler-scrim { + visibility: visible; + pointer-events: auto; + transition-delay: 0s; + } + :root[data-ruler-overlay] .ruler-scrim > div:nth-child(1) { + backdrop-filter: blur(4px); + } + :root[data-ruler-overlay] .ruler-scrim > div:nth-child(2) { + backdrop-filter: blur(12px); + } + :root[data-ruler-overlay] .ruler-scrim > div:nth-child(3) { + backdrop-filter: blur(24px); + } + :root[data-ruler-overlay] .ruler-scrim > div:nth-child(4) { + opacity: 1; + } + :root[data-ruler-overlay] .ruler-root { + display: block; + z-index: 60; + } + :root[data-ruler-overlay] .ruler-label { + width: max-content; + max-width: 11rem; + opacity: 1; + pointer-events: auto; + } + + .channel-slider { + appearance: none; + height: 12px; + border-radius: 2px; + } + .channel-slider::-webkit-slider-runnable-track { + height: 12px; + background: transparent; + } + .channel-slider::-webkit-slider-thumb { + appearance: none; + height: 12px; + width: 4px; + border-radius: 2px; + background-color: var(--color-on-surface); + box-shadow: 0 0 0 1px var(--color-surface-container-lowest); + } + .channel-slider::-moz-range-track { + height: 12px; + background: transparent; + } + .channel-slider::-moz-range-thumb { + border: none; + height: 12px; + width: 4px; + border-radius: 2px; + background-color: var(--color-on-surface); + box-shadow: 0 0 0 1px var(--color-surface-container-lowest); + } + + /* Classes applied by ui/tooltip.tsx. Engines without anchor-scope keep the + component's absolute fallback, clamped so the bubble never exceeds the viewport. */ + .tooltip-bubble { + max-width: min(20rem, calc(100vw - 2rem)); + } + @supports (anchor-scope: --tooltip) { + .tooltip-scope { + anchor-scope: --tooltip; + } + .tooltip-anchor { + anchor-name: --tooltip; + } + .tooltip-bubble { + position: fixed; + inset: auto; + position-anchor: --tooltip; + position-area: block-end span-inline-end; + position-try-fallbacks: flip-block; + margin-block-start: 0.25rem; + margin-inline-end: 0.75rem; + } + } + + .prose { + /* Links */ + a:not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply text-primary no-underline underline-offset-4; + &:hover { + @apply underline; + } + } + + /* Headings adapted */ + :where(h1, h2, h3, h4):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply variation-sans variation-width-80 variation-grade-50 mt-8 mb-2 scroll-mt-32 font-normal; + } + + /* Reduced italic slant */ + em, + i { + /* not-italic so the slant comes from the variation axis, not the browser. */ + @apply variation-sans -variation-slant-10 not-italic; + } + + /* Pre / Code Blocks */ + pre { + @apply border-outline-variant bg-surface-container relative overflow-x-auto rounded-b-xl border p-5 text-sm leading-6; + &::-webkit-scrollbar { + display: none; + } + scrollbar-width: none; + + /* Joins the block to the title sitting directly above it. */ + &:where([data-rehype-pretty-code-figure] > pre) { + @apply mt-0 rounded-t-none border-t-0; + } + } + + /* Inline code */ + :not(pre) > code { + @apply text-on-surface-variant border-outline-variant bg-surface-container rounded-md border px-1 py-0.5 font-normal; + } + + img:not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply m-0 rounded-xl; + } + + :where(figure > div > video):not(:where([class~='not-prose'] *)) { + @apply m-0; + } + + :where(h2, h3, h4) + aside { + @apply mt-4; + } + + /* Rehype Pretty Code adaptation */ + [data-rehype-pretty-code-figure] code { + @apply grid min-w-full rounded-none border-0 bg-transparent box-decoration-clone p-0 leading-normal wrap-break-word; + counter-reset: line; + } + + [data-rehype-pretty-code-figure] [data-line]::before { + counter-increment: line; + content: counter(line); + @apply text-outline mr-4 inline-block w-4 text-right; + } + + [data-rehype-pretty-code-title] { + @apply border-outline-variant text-on-surface-variant bg-surface-container-high flex items-center rounded-t-xl border border-b-0 px-5 py-3 font-mono text-sm select-none; + } + + /* When title exists, ensure the pre block connects properly */ + [data-rehype-pretty-code-title] + pre { + @apply mt-0 rounded-t-none; + } + + /* If wrapped in div (rehype-pretty-code usually wraps in div or figure) */ + [data-rehype-pretty-code-title] + div > pre { + @apply mt-0 rounded-t-none border-t-0; + } + + /* Multi-theme support for rehype-pretty-code */ + /* Use --shiki-light in light mode */ + [data-rehype-pretty-code-figure] span[style*='--shiki'] { + color: var(--shiki-light); + } + + /* Dark mode: use --shiki-dark */ + html[data-theme='dark'] & [data-rehype-pretty-code-figure] span[style*='--shiki'] { + color: var(--shiki-dark); + } } } diff --git a/src/app/icon.svg b/src/app/icon.svg new file mode 100644 index 0000000..574b0f0 --- /dev/null +++ b/src/app/icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e677a98..ba0f28d 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,7 +1,77 @@ import './globals.css' -import { GeistSans } from 'geist/font/sans' -import { GeistMono } from 'geist/font/mono' -import { BlobBackground } from '@/components/blob-background' + +import type { Metadata } from 'next' +import dynamic from 'next/dynamic' +import { Google_Sans_Code, Google_Sans_Flex, Roboto_Slab } from 'next/font/google' +import { Footer } from '@/components/footer' +import { Header } from '@/components/header' +import { MaterialThemeProvider } from '@/theme/material-theme' +import { ThemeProvider } from 'next-themes' +import { Analytics } from '@vercel/analytics/next' +import { MASTODON_URL, SITE_DESCRIPTION, SITE_NAME, SITE_URL, TWITTER_HANDLE } from '@/config/site' + +// Keep the development toolbar and its dependencies out of production bundles. +const DevelopmentTools = + process.env.NODE_ENV === 'development' + ? dynamic(() => import('@/components/debug/dev-bar').then(({ DevBar }) => DevBar)) + : () => null + +const metadataBaseUrl = + process.env.VERCEL_ENV === 'production' + ? SITE_URL + : process.env.VERCEL_URL + ? `https://${process.env.VERCEL_URL}` + : 'http://localhost:3000' + +export const metadata: Metadata = { + metadataBase: new URL(metadataBaseUrl), + title: { + default: SITE_NAME, + template: `%s | ${SITE_NAME}`, + }, + description: SITE_DESCRIPTION, + applicationName: SITE_NAME, + authors: [{ name: SITE_NAME, url: '/' }], + creator: SITE_NAME, + publisher: SITE_NAME, + alternates: { + canonical: './', + types: { + 'application/rss+xml': '/rss.xml', + }, + }, + openGraph: { + type: 'website', + locale: 'en_US', + url: '/', + siteName: SITE_NAME, + title: SITE_NAME, + description: SITE_DESCRIPTION, + }, + twitter: { + card: 'summary_large_image', + title: SITE_NAME, + description: SITE_DESCRIPTION, + creator: TWITTER_HANDLE, + }, +} + +const googleSansFlex = Google_Sans_Flex({ + subsets: ['latin'], + variable: '--font-google-sans-flex', + axes: ['wdth', 'GRAD', 'slnt', 'ROND'], +}) + +const googleSansCode = Google_Sans_Code({ + subsets: ['latin-ext'], + variable: '--font-google-sans-code', +}) + +const robotoSlab = Roboto_Slab({ + subsets: ['latin'], + variable: '--font-roboto-slab', + weight: 'variable', +}) export default function RootLayout({ children, @@ -9,10 +79,31 @@ export default function RootLayout({ children: React.ReactNode }>) { return ( - - - - {children} + + + + + + + Skip to content + + + +
+ {children} +