diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..847686d --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,43 @@ +name: Deploy website to GitHub Pages +on: + push: + branches: [main] + paths: + - "site/**" + - "docs/**" + - "packaging/icons/ferese.svg" + - "scripts/build-site.py" + - ".github/workflows/pages.yml" + workflow_dispatch: +permissions: + contents: read +concurrency: + group: pages + cancel-in-progress: false +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/configure-pages@v5 + - name: Build website and handbook + run: | + python3 -m venv .site-venv + .site-venv/bin/pip install -r site/requirements.txt + .site-venv/bin/python scripts/build-site.py + - uses: actions/upload-pages-artifact@v4 + with: + path: build/site + deploy: + needs: build + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index ead18d8..a90a9e3 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,8 @@ ChatGPT Image*.png /docs/ferese-window-manager-spec.md /docs/ferese-theme-ui-spec.md /docs/ferese-shell-design.md + +# Generated GitHub Pages site +/build/site/ +/.site-venv/ +__pycache__/ diff --git a/docs/images/ferese-desktop.png b/docs/images/ferese-desktop.png index 9ceb1a0..3826c84 100644 Binary files a/docs/images/ferese-desktop.png and b/docs/images/ferese-desktop.png differ diff --git a/docs/installation.md b/docs/installation.md index adb5fce..6a3f248 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -2,14 +2,18 @@ ## Build and install -On Fedora with SDDM, install a Rust toolchain (1.93 or newer), native build +On Fedora with SDDM, install a Rust toolchain (1.95 or newer), native build dependencies, `desktop-file-utils`, D-Bus and `foot`. Wallpaper browsing also needs `zenity`; locking needs swaylock or swaylock-effects. -Screenshots need `grim`, `slurp`, `satty`, and `wl-clipboard`. +Screenshots need `grim`, `slurp`, `satty`, `wl-clipboard`, and Python 3. +Print Screen captures the active monitor; Super+Shift+S selects an area. +Use `ferese-screenshot --all` to capture all monitors in one image. -From the checkout, run as your normal user: +Clone the repository, then run the installer as your normal user: ```sh +git clone https://github.com/borngraced/ferese.git +cd ferese ./scripts/install.sh ``` @@ -29,6 +33,16 @@ Use `--help` for all options. New configs can be copied from ## Preview and logs +To preview a source build before installing, run these commands from the checkout +inside an existing Wayland desktop: + +```sh +cargo build --release --locked -p ferese -p ferese-shell -p ferese-settings -p feresectl +target/release/ferese --backend nested --grant-effects --grant-shell-control -- \ + target/release/ferese-shell +``` + + Run `ferese-session --nested` inside an existing Wayland desktop. For a hardware session, use the login screen or a spare TTY; never run Ferese as root or start its DRM backend inside an active graphical session. diff --git a/docs/notifications.md b/docs/notifications.md index 8570a40..92d8690 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -5,6 +5,8 @@ Cards appear beneath the bar on the focused output and follow the selected theme Click the notification icon in the bar to open history, toggle Do Not Disturb, or clear notifications. Escape or the close button closes the history panel. +## Send a notification + Notifications use the standard `org.freedesktop.Notifications` D-Bus interface. Existing apps and `notify-send` work without Ferese-specific commands: @@ -12,6 +14,8 @@ Existing apps and `notify-send` work without Ferese-specific commands: notify-send --app-name=Ferese --icon=dialog-information "Hello" "Your desktop notification" ``` +## Popups and history + Up to three popup cards are visible at once. History keeps the most recent 100 notifications in memory and clears when the shell exits. Hovering a popup pauses its timer. Critical notifications bypass Do Not Disturb and do not expire unless @@ -19,6 +23,8 @@ the sender supplies a timeout. App action buttons emit the standard action signa a replacement updates the same notification ID. Transient messages are removed from history when they close. +## Settings and Do Not Disturb + Settings → Notifications controls popup visibility, the initial Do Not Disturb state, and the default timeout. The bar's Do Not Disturb toggle is temporary; changing the Settings option saves the preference for future sessions. Apps can @@ -35,6 +41,8 @@ notifications { The timeout must be between 1000 and 30000 milliseconds. Hiding popups still records notifications in history. Appearance follows the Ferese theme. +## Session integration + Only one notification server can own the D-Bus interface. The Ferese session launcher stops the SwayNotificationCenter user service before starting its shell. For a manually started shell, stop that service with `systemctl --user stop diff --git a/packaging/ferese-screenshot b/packaging/ferese-screenshot index fd6279b..b97953f 100755 --- a/packaging/ferese-screenshot +++ b/packaging/ferese-screenshot @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Capture an area or the whole desktop and open it in Satty for annotation. +# Capture an area, active monitor, or all monitors and edit in Satty. set -euo pipefail umask 077 @@ -15,14 +15,15 @@ mode=area case ${1:-} in '') ;; --full) mode=full; shift ;; + --all) mode=all; shift ;; --help|-h) - echo 'Usage: ferese-screenshot [--full]' - echo 'Select an area, or use --full for the whole desktop, then edit in Satty.' + echo 'Usage: ferese-screenshot [--full | --all]' + echo 'Select an area; --full captures the active monitor; --all captures all monitors.' exit 0 ;; - *) fail 'Usage: ferese-screenshot [--full]' ;; + *) fail 'Usage: ferese-screenshot [--full | --all]' ;; esac -(($# == 0)) || fail 'Usage: ferese-screenshot [--full]' +(($# == 0)) || fail 'Usage: ferese-screenshot [--full | --all]' for tool in grim satty wl-copy flock; do command -v "$tool" >/dev/null || fail "Required command not found: $tool" @@ -38,6 +39,22 @@ if [[ $mode == area ]]; then geometry=$(slurp) || exit 0 [[ -n $geometry ]] || exit 0 capture_args=(-g "$geometry") +elif [[ $mode == full ]]; then + for tool in feresectl python3; do + command -v "$tool" >/dev/null || fail "Required command not found: $tool" + done + # Resolve the active monitor before Satty can change keyboard focus. + # Never fall back to grim's all-monitor capture when IPC is unavailable. + output=$(feresectl get-outputs | python3 -c ' +import json, sys +outputs = [output for output in json.load(sys.stdin) if output.get("enabled")] +focused = [output for output in outputs if output.get("focused")] +selected = focused if len(focused) == 1 else outputs if len(outputs) == 1 else [] +if len(selected) != 1 or not selected[0].get("name"): + sys.exit(1) +print(selected[0]["name"]) +') || fail 'Could not determine the active monitor. Use area selection or --all.' + capture_args=(-o "$output") fi pictures=$(xdg-user-dir PICTURES 2>/dev/null) || pictures="$HOME/Pictures" directory=${pictures:-$HOME/Pictures}/Screenshots @@ -48,13 +65,14 @@ trap '[[ -z $input ]] || rm -f -- "$input"; [[ -s $file ]] || rm -f -- "$file"' input=$(mktemp "$XDG_RUNTIME_DIR/ferese-screenshot-XXXXXX.png") || fail 'Could not create the temporary capture.' grim "${capture_args[@]}" "$input" || fail 'Could not capture the screen. Log back into Ferese after enabling screen capture.' # Enter saves the annotated image and copies it; Escape discards the capture. -exec 9>&- +# Keep the parent lock through editing. Close it only in Satty so clipboard +# helpers cannot inherit the lock and keep later screenshots blocked. satty --filename "$input" --output-filename "$file" \ --fullscreen current-screen \ --copy-command 'wl-copy --type image/png' \ --save-after-copy \ --actions-on-enter save-to-file,save-to-clipboard,exit \ - --actions-on-escape exit || fail 'Could not open the screenshot in Satty.' + --actions-on-escape exit 9>&- || fail 'Could not open the screenshot in Satty.' if [[ -s $file ]]; then printf '%s\n' "$file" fi diff --git a/scripts/build-site.py b/scripts/build-site.py new file mode 100644 index 0000000..9de785d --- /dev/null +++ b/scripts/build-site.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Build Ferese's static website and handbook from the project Markdown docs.""" +from pathlib import Path +from html import escape +import json +import re +import shutil +from urllib.parse import urlsplit +from markdown_it import MarkdownIt + +ROOT = Path(__file__).resolve().parent.parent +OUTPUT = ROOT / 'build' / 'site' +REPO = 'https://github.com/borngraced/ferese' +PAGES = [ + ('index', 'Introduction', ROOT / 'site/content/index.md'), + ('installation', 'Installation', ROOT / 'docs/installation.md'), + ('configuration', 'Configuration', ROOT / 'docs/configuration.md'), + ('desktop-widgets', 'Desktop widgets', ROOT / 'docs/desktop-widgets.md'), + ('notifications', 'Notifications', ROOT / 'docs/notifications.md'), +] +md = MarkdownIt('commonmark', {'html': False}).enable('table') +OUTPUT.mkdir(parents=True, exist_ok=True) +for name in ('index.html', 'styles.css', 'app.js'): + shutil.copy2(ROOT / 'site' / name, OUTPUT / name) +(OUTPUT / 'assets').mkdir(exist_ok=True) +for name in ('ferese-desktop.png', 'ferese-monochrome.png'): + shutil.copy2(ROOT / 'docs/images' / name, OUTPUT / 'assets' / name) +shutil.copy2(ROOT / 'packaging/icons/ferese.svg', OUTPUT / 'assets/ferese.svg') +(OUTPUT / 'docs').mkdir(exist_ok=True) +(OUTPUT / '.nojekyll').touch() +search_index = [] + + +def rewrite_link(href, source): + url = urlsplit(href) + if url.scheme or url.netloc or not url.path: + return href + basename = Path(url.path).stem + if url.path.endswith('.md') and basename in {page[0] for page in PAGES}: + return f'{basename}.html' + (f'#{url.fragment}' if url.fragment else '') + destination = (source.parent / url.path).resolve().relative_to(ROOT) + return f'{REPO}/blob/main/{destination}' + (f'#{url.fragment}' if url.fragment else '') + + +for page_number, (slug, title, source) in enumerate(PAGES): + tokens = md.parse(source.read_text()) + toc, ids, sections = [], set(), [] + current = None + for i, token in enumerate(tokens): + if token.type == 'heading_open': + heading = tokens[i + 1].content + base = re.sub(r'[^\w\s-]', '', heading.lower()).strip() + base = re.sub(r'[\s_]+', '-', base) + anchor = base + suffix = 1 + while anchor in ids: + anchor = f'{base}-{suffix}' + suffix += 1 + ids.add(anchor) + token.attrSet('id', anchor) + if token.tag != 'h1': + toc.append((anchor, heading)) + current = {'page': title, 'heading': heading, 'url': f'{slug}.html#{anchor}', 'text': ''} + sections.append(current) + elif current and token.type in ('inline', 'fence', 'code_block'): + current['text'] += token.content + ' ' + for child in token.children or []: + if child.type == 'link_open': + child.attrSet('href', rewrite_link(child.attrGet('href'), source)) + search_index.extend(sections) + body = md.renderer.render(tokens, md.options, {}) + body = body.replace('', '
').replace('
', '') + navigation = ''.join(f'{escape(t)}' for s, t, _ in PAGES) + contents = ''.join(f'{escape(heading)}' for anchor, heading in toc) + pager = '' + if page_number: + prev = PAGES[page_number - 1] + pager += f'PREVIOUS← {escape(prev[1])}' + if page_number + 1 < len(PAGES): + nxt = PAGES[page_number + 1] + pager += f'NEXT{escape(nxt[1])} →' + doc = f''' + +{escape(title)} — Ferese handbook + + + +
+
{body}
View this guide on GitHub ↗
+
+''' + (OUTPUT / 'docs' / f'{slug}.html').write_text(doc) +(OUTPUT / 'docs/search-index.json').write_text(json.dumps(search_index, ensure_ascii=False)) +print(f'Built website and {len(PAGES)} handbook pages at {OUTPUT}') diff --git a/scripts/tests/test_screenshot.py b/scripts/tests/test_screenshot.py new file mode 100644 index 0000000..e50b43a --- /dev/null +++ b/scripts/tests/test_screenshot.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Exercise screenshot selection and locking without a Wayland desktop.""" +import json +import os +from pathlib import Path +import subprocess +import tempfile +import time +import unittest + +SCRIPT = Path(__file__).resolve().parents[2] / 'packaging/ferese-screenshot' + + +class ScreenshotTest(unittest.TestCase): + def test_active_output_all_outputs_and_editor_lock(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + bins = root / 'bin' + bins.mkdir() + env = dict(os.environ, XDG_RUNTIME_DIR=directory, TEST_ROOT=directory, + PATH=f'{bins}:{os.environ["PATH"]}') + (root / 'outputs.json').write_text(json.dumps([ + {'name': 'eDP-1', 'enabled': True, 'focused': False}, + {'name': 'HDMI-A-1', 'enabled': True, 'focused': True}, + ])) + scripts = { + 'grim': '''#!/bin/bash +printf '%s\\n' "$@" >> "$TEST_ROOT/captures" +printf image > "${@: -1}" +''', + 'feresectl': '#!/bin/bash\ncat "$TEST_ROOT/outputs.json"\n', + 'notify-send': '#!/bin/bash\nexit 0\n', + 'wl-copy': '#!/bin/bash\nexit 0\n', + 'xdg-user-dir': '#!/bin/bash\nprintf "%s" "$TEST_ROOT/Pictures"\n', + 'satty': '''#!/bin/bash +if [[ -e /proc/$$/fd/9 ]]; then touch "$TEST_ROOT/inherited-lock"; fi +printf 'editor\\n' >> "$TEST_ROOT/editors" +touch "$TEST_ROOT/ready" +while [[ ! -e "$TEST_ROOT/release" ]]; do sleep 0.02; done +''', + } + for name, contents in scripts.items(): + path = bins / name + path.write_text(contents) + path.chmod(0o755) + def run(mode): + return subprocess.run(['bash', str(SCRIPT), mode], env=env, + capture_output=True, timeout=3) + first = subprocess.Popen(['bash', str(SCRIPT), '--full'], env=env, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + try: + deadline = time.monotonic() + 5 + while not (root / 'ready').exists(): + if time.monotonic() > deadline or first.poll() is not None: + self.fail('First screenshot did not open its editor') + time.sleep(0.02) + second = run('--full') + self.assertEqual(second.returncode, 0, second.stderr) + self.assertEqual((root / 'editors').read_text().splitlines(), ['editor']) + self.assertFalse((root / 'inherited-lock').exists()) + (root / 'release').touch() + _, stderr = first.communicate(timeout=3) + self.assertEqual(first.returncode, 0, stderr) + third = run('--full') + self.assertEqual(third.returncode, 0, third.stderr) + self.assertEqual((root / 'editors').read_text().splitlines(), ['editor', 'editor']) + self.assertFalse(list((root / 'Pictures/Screenshots').glob('*.png'))) + self.assertFalse(list(root.glob('ferese-screenshot-*.png'))) + captures = (root / 'captures').read_text().splitlines() + self.assertEqual(captures[0:2], ['-o', 'HDMI-A-1']) + self.assertEqual(captures[3:5], ['-o', 'HDMI-A-1']) + all_monitors = run('--all') + self.assertEqual(all_monitors.returncode, 0, all_monitors.stderr) + captures = (root / 'captures').read_text().splitlines() + self.assertEqual(len(captures), 7) # --all passes only the filename + for outputs in [[], [{'enabled': True, 'name': 'A'}, {'enabled': True, 'name': 'B'}]]: + (root / 'outputs.json').write_text(json.dumps(outputs)) + unavailable = run('--full') + self.assertNotEqual(unavailable.returncode, 0) + self.assertIn(b'Could not determine the active monitor', unavailable.stderr) + self.assertEqual((root / 'captures').read_text().splitlines(), captures) + (root / 'outputs.json').write_text(json.dumps([ + {'name': 'eDP-1', 'enabled': False, 'focused': True}, + {'name': 'HDMI-A-1', 'enabled': True, 'focused': False}, + ])) + single = run('--full') + self.assertEqual(single.returncode, 0, single.stderr) + self.assertEqual((root / 'captures').read_text().splitlines()[-3:-1], ['-o', 'HDMI-A-1']) + finally: + (root / 'release').touch() + if first.poll() is None: + first.terminate() + first.communicate(timeout=3) + + +if __name__ == '__main__': + unittest.main() diff --git a/shell/ferese-settings/src/main.rs b/shell/ferese-settings/src/main.rs index f62fcc4..37ad9ac 100644 --- a/shell/ferese-settings/src/main.rs +++ b/shell/ferese-settings/src/main.rs @@ -500,7 +500,8 @@ impl cosmic::Application for App { .push(self.label("Ferese", 16.)) .width(Length::Fill), ) - .on_drag(Message::DragWindow), + .on_press(Message::DragWindow) + .interaction(cosmic::iced::mouse::Interaction::Grab), ) .push(widget::Space::new().height(14)) .push( @@ -865,7 +866,11 @@ impl cosmic::Application for App { } } - let mut content = column([]).spacing(16).push(heading); + let mut content = column([]).spacing(16).push( + widget::mouse_area(heading.width(Length::Fill)) + .on_press(Message::DragWindow) + .interaction(cosmic::iced::mouse::Interaction::Grab), + ); if let Some(error) = &self.error { content = content.push( diff --git a/site/README.md b/site/README.md new file mode 100644 index 0000000..21d45df --- /dev/null +++ b/site/README.md @@ -0,0 +1,38 @@ +# Ferese website and handbook + +The public site is static HTML and CSS. The handbook is generated from the +project's Markdown guides, so website and repository documentation stay in sync. +There are no client frameworks, external fonts, or tracking scripts. + +## Local preview + +```sh +python3 -m venv .site-venv +.site-venv/bin/pip install -r site/requirements.txt +.site-venv/bin/python scripts/build-site.py +python3 -m http.server 8080 --bind 127.0.0.1 --directory build/site +``` + +Open http://localhost:8080 or http://localhost:8080/docs/. Rebuild after editing +site sources or Markdown guides, then refresh the browser. + +- `site/index.html` and `site/styles.css`: homepage and shared visual design. +- `site/content/index.md`: handbook introduction. +- `docs/{installation,configuration,desktop-widgets,notifications}.md`: source guides. +- `scripts/build-site.py`: renders Markdown, resolves links, generates section + navigation, previous/next links, and the search index. +- `site/app.js`: handbook search and code copying. Reading and navigation work + without JavaScript. Search is local to the browser, with no third-party service. + +## GitHub Pages + +Select **Settings → Pages → Source → GitHub Actions**. The Pages workflow +publishes site and documentation changes pushed to `main`, or can run manually. +Expected URL: https://borngraced.github.io/ferese/. + +Local links are relative, including documentation search, so publishing under +the `/ferese/` project path works. Screenshots are copied from `docs/images` +during the build; the generated `build/site` directory is ignored by Git. + +See [GitHub's custom workflow documentation](https://docs.github.com/en/pages/getting-started-with-github-pages/using-custom-workflows-with-github-pages) +for deployment setup. diff --git a/site/app.js b/site/app.js new file mode 100644 index 0000000..811f0c7 --- /dev/null +++ b/site/app.js @@ -0,0 +1,130 @@ +// Progressive enhancement for the handbook. Navigation and content work without JS. +const search = document.querySelector("#docs-search"); +const results = document.querySelector("#search-results"); +const status = document.querySelector("#search-status"); +let indexPromise; +if (search) { + search.closest(".docs-search").hidden = false; + const loadIndex = () => + (indexPromise ||= fetch("search-index.json") + .then((response) => { + if (!response.ok) throw new Error("Search index unavailable"); + return response.json(); + }) + .catch((error) => { + indexPromise = null; + throw error; + })); + search.addEventListener("focus", () => { + loadIndex().catch(() => {}); + }); + search.addEventListener("input", async () => { + const query = search.value.trim().toLocaleLowerCase(); + if (!query) { + results.hidden = true; + results.replaceChildren(); + status.textContent = ""; + return; + } + try { + const index = await loadIndex(); + if (search.value.trim().toLocaleLowerCase() !== query) return; + const words = query.split(/\s+/); + const matches = index + .filter((item) => + words.every((word) => + `${item.page} ${item.heading} ${item.text}` + .toLocaleLowerCase() + .includes(word), + ), + ) + .sort( + (a, b) => + Number(b.heading.toLocaleLowerCase().includes(query)) - + Number(a.heading.toLocaleLowerCase().includes(query)), + ) + .slice(0, 10); + results.replaceChildren(); + for (const item of matches) { + const link = document.createElement("a"); + link.href = item.url; + const title = document.createElement("strong"); + title.textContent = `${item.page} / ${item.heading}`; + const snippet = document.createElement("span"); + const position = Math.max( + 0, + item.text.toLocaleLowerCase().indexOf(words[0]) - 45, + ); + snippet.textContent = `${position ? "…" : ""}${item.text.slice(position, position + 165)}${item.text.length > position + 165 ? "…" : ""}`; + link.append(title, snippet); + results.append(link); + } + if (!matches.length) { + const empty = document.createElement("p"); + empty.textContent = + "No matching sections. Try “wallpaper”, “shortcuts”, or “notifications”."; + results.append(empty); + } + results.hidden = false; + status.textContent = `${matches.length} matching sections.`; + } catch { + if (search.value.trim().toLocaleLowerCase() !== query) return; + results.hidden = true; + status.textContent = "Search is unavailable. Browse the guides below."; + } + }); + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + results.hidden = true; + search.focus(); + } + if ( + event.key === "/" && + !["INPUT", "TEXTAREA"].includes(document.activeElement.tagName) && + !event.ctrlKey && + !event.metaKey && + !event.altKey + ) { + event.preventDefault(); + search.focus(); + } + if ( + event.key === "ArrowDown" && + document.activeElement === search && + !results.hidden + ) { + const first = results.querySelector("a"); + if (first) { + event.preventDefault(); + first.focus(); + } + } + }); + document.addEventListener("click", (event) => { + if (!event.target.closest(".docs-search")) results.hidden = true; + }); +} +if (navigator.clipboard?.writeText) { + for (const block of document.querySelectorAll(".prose pre")) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "code-copy"; + button.textContent = "Copy"; + button.setAttribute("aria-label", "Copy code example"); + button.addEventListener("click", async () => { + try { + await navigator.clipboard.writeText( + block.querySelector("code").textContent, + ); + button.textContent = "Copied"; + if (status) status.textContent = "Code example copied."; + } catch { + button.textContent = "Select to copy"; + if (status) + status.textContent = + "Copy failed. Select the code to copy it manually."; + } + }); + block.append(button); + } +} diff --git a/site/content/index.md b/site/content/index.md new file mode 100644 index 0000000..ce92213 --- /dev/null +++ b/site/content/index.md @@ -0,0 +1,59 @@ +# The Ferese handbook + +Ferese is a complete Wayland desktop built around personalization. Its custom shell, native Settings, live themes, and flexible layouts work together as one cohesive experience. + +This handbook covers installing Ferese, finding your way around, and making the desktop your own. + +## Start with your first session + +1. Read [Installation](installation.md) for the supported setup, building, and recovery. +2. Open **Control Center → Settings** to choose a theme and configure your desktop. +3. Explore the [configuration reference](configuration.md) for layouts, shortcuts, window rules, and displays. + +Want to try it before switching desktops? The [preview instructions](installation.md#preview-and-logs) cover running Ferese inside an existing Wayland session. + +## Find your way around + +Ferese supports scrolling columns, tree tiling, floating windows, and a workspace overview. These are the built-in keyboard shortcuts; all can be changed in Settings. + +| Shortcut | Action | +| --- | --- | +| Super + Enter | Open a terminal | +| Super + H / J / K / L | Focus a window | +| Super + R | Cycle column width | +| Super + Tab | Open workspace and window overview | +| Super + M | Switch scrolling / tree layout | +| Super + Shift + S | Select a screenshot area | + +Three-finger swipes move between workspaces and windows. Open **Control Center → Settings → Shortcuts** to change the defaults or assign gestures to other actions. + +## Make it feel like yours + +Use **Settings** for everyday customization. Choose an appearance preset, change the wallpaper, adjust shell and window corners, or add a clock and sticky notes through [Desktop widgets](desktop-widgets.md). + +[Notifications](notifications.md) explains popup cards, history, Do Not Disturb, and app actions. + +If you prefer a text editor, your configuration lives at: + +```text +~/.config/ferese/config.kdl +``` + +Ferese reloads changes as you make them. An invalid edit keeps the last working configuration. Settings also preserves a backup of your previous save. See [Saving and validation](configuration.md#saving-and-validation). + +## Keep a recovery path + +Keep another desktop session available while testing hardware behavior. Save your work before logging out: `feresectl exit` and **Super + Shift + E** end the current session immediately. + +The [recovery guide](installation.md#logout-and-recovery) explains logs, installed releases, and rollback. + +## Work on Ferese + +Ferese is built in Rust on Smithay. Use release builds when evaluating motion and startup performance. From the repository checkout: + +```sh +cargo test --workspace --locked +cargo fmt --all --check +``` + +The [source repository](https://github.com/borngraced/ferese) is the place to explore implementation details and report issues. When reporting a problem, include how to reproduce it, whether you used the nested or DRM backend, and relevant logs. diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..e4eb852 --- /dev/null +++ b/site/index.html @@ -0,0 +1,128 @@ + + + + + + + + + + + + + Ferese — A desktop that feels like yours + + + + + + +
+
+

Ferese

+

+ A beautiful, fluid Wayland desktop made to feel like yours. +

+

+ A complete Linux desktop with a custom shell, native settings, + scrolling and tiling layouts, and live-reloading themes. +

+ +
+
+ + Ferese Blue desktop with the default wallpaper, Settings, Control Center and clock widget + +
+ Ferese Blue with Settings, Control Center, and the default wallpaper. +
+
+
+
+

Flexible layouts

+

+ Scrolling columns, tree tiling, floating windows, and a workspace + overview. Use keyboard shortcuts or gestures to move around. +

+ Configure layouts → +
+
+

A native desktop

+

+ A menu bar, Control Center, Settings, notifications, and desktop + widgets, designed to work together. +

+ Explore the handbook → +
+
+

Make it yours

+

+ Choose your theme, wallpaper, colors, and motion. Change settings + through the app or a KDL config, with live reload. +

+ Customize Ferese → +
+
+
+

Getting started

+

+ The installation guide covers + Fedora with SDDM. You can also + preview Ferese + inside an existing Wayland session before installing. +

+

+ Built in Rust on Smithay. Development happens + on GitHub. +

+
+
+ + + diff --git a/site/requirements.txt b/site/requirements.txt new file mode 100644 index 0000000..3816d1c --- /dev/null +++ b/site/requirements.txt @@ -0,0 +1,2 @@ +markdown-it-py==3.0.0 +mdurl==0.1.2 diff --git a/site/styles.css b/site/styles.css new file mode 100644 index 0000000..77625f6 --- /dev/null +++ b/site/styles.css @@ -0,0 +1,1141 @@ +:root { + --paper: #10151c; + --white: #18212c; + --ink: #edf2f8; + --muted: #a5b1c1; + --line: #2c3745; + --blue: #91b8ff; + --soft: #1c2838; + --mono: + ui-monospace, "SFMono-Regular", Consolas, "Liberation Mono", monospace; + --sans: Inter, "Helvetica Neue", Arial, sans-serif; + color-scheme: dark; +} +* { + box-sizing: border-box; +} +html { + scroll-behavior: smooth; + scroll-padding-top: 95px; +} +body { + margin: 0; + background: var(--paper); + color: var(--ink); + font: 16px/1.65 var(--sans); + -webkit-font-smoothing: antialiased; +} +a { + color: inherit; + text-decoration: none; +} +button, +input { + font: inherit; +} +button { + cursor: pointer; +} +a:focus-visible, +button:focus-visible, +input:focus-visible, +summary:focus-visible { + outline: 3px solid var(--blue); + outline-offset: 4px; +} +img { + display: block; + max-width: 100%; + height: auto; +} +h1, +h2, +h3, +p, +figure, +dl { + margin: 0; +} +.page-width, +.header-inner { + width: min(1280px, calc(100% - 96px)); + margin-inline: auto; +} +.site-header { + border-bottom: 1px solid var(--line); + background: var(--paper); +} +.header-inner { + display: flex; + align-items: center; + justify-content: space-between; + height: 88px; + gap: 30px; +} +.wordmark { + font-size: 29px; + font-weight: 750; + letter-spacing: -1.4px; + display: inline-flex; + align-items: center; + gap: 10px; +} +.wordmark svg { + width: 26px; + height: 26px; + fill: var(--blue); +} +.site-header nav { + display: flex; + gap: 36px; + align-items: center; + font-size: 13px; +} +.site-header nav a { + padding: 10px 0; + position: relative; +} +.site-header nav a[aria-current="page"]:after { + content: ""; + position: absolute; + height: 2px; + bottom: 0; + left: 0; + right: 0; + background: var(--ink); +} +.site-header nav a:hover, +.inline-link:hover, +.rail-link:hover, +.site-footer > a:last-child:hover { + color: var(--blue); +} +.eyebrow { + font: 10px/1.7 var(--mono); + letter-spacing: 1.2px; +} +.hero { + padding-block: 58px 64px; +} +.hero-kicker { + display: flex; + justify-content: space-between; + color: var(--muted); + margin-bottom: 34px; +} +.hero-grid { + display: grid; + grid-template-columns: 1.35fr 1fr; + gap: 80px; + align-items: end; +} +h1 { + font-size: clamp(47px, 5.5vw, 78px); + font-weight: 500; + letter-spacing: -3.7px; + line-height: 1.08; +} +h1 span { + color: var(--blue); +} +.hero-description { + max-width: 410px; + padding-bottom: 5px; +} +.hero-description > p { + font-size: 15px; + line-height: 1.9; + color: var(--muted); +} +.hero-links { + display: flex; + align-items: center; + gap: 24px; + margin-top: 26px; +} +.button { + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 33px; + background: var(--ink); + color: #fff; + padding: 13px 19px; + font-size: 13px; + transition: background 0.15s; +} +.button:hover { + background: var(--blue); +} +.inline-link { + font-size: 12px; + display: inline-flex; + gap: 12px; + align-items: center; +} +.desktop-hero { + scroll-margin-top: 25px; +} +.image-label { + display: flex; + justify-content: space-between; + padding: 12px 0; + border-top: 1px solid var(--ink); + font: 9px var(--mono); + letter-spacing: 1px; +} +.desktop-hero img { + width: 100%; + border: 1px solid var(--line); +} +figcaption { + display: flex; + justify-content: space-between; + gap: 20px; + color: var(--muted); + font-size: 11px; + padding-top: 17px; +} +figcaption > span:last-child { + font: 9px/1.7 var(--mono); +} +.editorial-section { + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + column-gap: 65px; + padding-block: 105px; + border-top: 1px solid var(--line); + margin-top: 76px; +} +.section-rail { + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: flex-start; + gap: 30px; + color: var(--muted); +} +.rail-link { + font-size: 11px; +} +h2 { + font-size: clamp(32px, 3.2vw, 46px); + font-weight: 500; + line-height: 1.18; + letter-spacing: -1.7px; +} +.section-intro { + max-width: 630px; + font-size: 16px; + line-height: 1.85; + color: var(--muted); + margin-top: 24px; +} +.layout-explanations { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 40px; + margin-top: 46px; +} +.layout-explanations article { + border-top: 1px solid var(--line); + padding-top: 20px; +} +h3 { + font-size: 18px; + font-weight: 550; + letter-spacing: -0.4px; +} +.layout-explanations p, +.appearance-notes p { + font-size: 13px; + line-height: 1.9; + color: var(--muted); + margin-top: 12px; +} +.key-line { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + font: 10px var(--mono); + margin-top: 23px; +} +.key-line > span:last-child { + font: 10px var(--sans); + margin-left: 4px; + color: var(--muted); +} +kbd { + border: 1px solid var(--line); + border-bottom-width: 2px; + padding: 3px 6px; + background: var(--white); + font: 10px var(--mono); +} +.aside-note { + font-size: 11px; + color: var(--muted); + margin-top: 30px; +} +.appearance-section { + background: var(--soft); +} +.appearance-section .editorial-section { + border: 0; + margin-top: 0; + padding-block: 85px; +} +.appearance-shot { + grid-column: 1/-1; + margin-top: 46px; +} +.appearance-shot img { + width: 100%; + border: 1px solid var(--line); +} +.appearance-shot figcaption > span:first-child { + font: 10px var(--mono); +} +.appearance-notes { + grid-column: 1/-1; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 45px; + margin-top: 44px; +} +.appearance-notes h3 { + font-size: 16px; +} +.setup-section { + margin-top: 0; + border-top: 0; +} +.capabilities { + margin-top: 37px; +} +.capabilities > div { + display: grid; + grid-template-columns: 150px 1fr; + gap: 25px; + border-top: 1px solid var(--line); + padding: 21px 0; +} +.capabilities dt { + font-size: 13px; + font-weight: 600; +} +.capabilities dd { + margin: 0; + font-size: 13px; + color: var(--muted); + max-width: 480px; + line-height: 1.9; +} +.start-section { + background: #202a24; + color: #f7f8f5; + padding-block: 75px; +} +.start-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 100px; +} +.start-section .eyebrow { + color: #b9c9b5; +} +.start-section h2 { + font-size: 51px; + margin-block: 22px; +} +.start-section p { + font-size: 13px; + line-height: 1.9; + color: #c1ccc3; + max-width: 410px; + margin-bottom: 28px; +} +.button.light { + background: #edf3e8; + color: #202a24; +} +.button.light:hover { + background: #d9e6d0; +} +.start-links { + align-self: center; +} +.start-links a { + display: flex; + justify-content: space-between; + align-items: center; + gap: 20px; + padding: 25px 0; + border-top: 1px solid #4a564a; +} +.start-links a:last-child { + border-bottom: 1px solid #4a564a; +} +.start-links small { + display: block; + color: #b9c9b5; + font: 9px var(--mono); + letter-spacing: 1.2px; + margin-bottom: 8px; +} +.start-links strong { + font-size: 19px; + font-weight: 400; +} +.start-links a:hover strong { + text-decoration: underline; + text-underline-offset: 5px; +} +.site-footer { + display: flex; + align-items: center; + gap: 25px; + padding-block: 35px; +} +.site-footer p { + font-size: 11px; + color: var(--muted); +} +.site-footer > a:last-child { + margin-left: auto; + font-size: 11px; +} +.skip { + position: fixed; + left: 14px; + top: -100px; + padding: 12px 18px; + color: #10151c; + background: var(--blue); + z-index: 10; +} +.skip:focus { + top: 12px; +} +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} +/* The handbook shares the site's typography and navigation. */ +.docs-page .site-header { + position: sticky; + top: 0; + z-index: 5; +} +.docs-layout { + display: grid; + grid-template-columns: 205px minmax(0, 760px) 180px; + gap: 42px; + width: min(1360px, calc(100% - 72px)); + margin: 0 auto; + padding-block: 42px 80px; + align-items: start; +} +.docs-sidebar, +.docs-toc { + position: sticky; + top: 124px; + max-height: calc(100vh - 155px); + overflow: auto; +} +.docs-sidebar summary { + font: 10px var(--mono); + letter-spacing: 1px; + list-style: none; + margin-bottom: 18px; + color: var(--muted); +} +.docs-sidebar nav { + display: grid; + gap: 3px; +} +.docs-sidebar nav a { + font-size: 13px; + padding: 8px 12px; + border-left: 2px solid transparent; +} +.docs-sidebar nav a:hover { + background: var(--soft); +} +.docs-sidebar nav a[aria-current="page"] { + border-color: var(--blue); + color: var(--blue); + background: var(--soft); + font-weight: 600; +} +.sidebar-links { + border-top: 1px solid var(--line); + padding-top: 17px; + margin-top: 25px; + font-size: 11px; + display: grid; + gap: 12px; + color: var(--muted); +} +.docs-article { + min-width: 0; +} +.breadcrumb { + font: 10px var(--mono); + color: var(--muted); + margin-bottom: 18px; + display: flex; + gap: 10px; +} +.docs-article h1 { + font-size: 46px; + letter-spacing: -1.7px; + margin-bottom: 28px; + line-height: 1.15; +} +.prose { + font-size: 14px; + line-height: 1.9; +} +.prose > p { + margin-block: 18px; + color: #c2ccd9; +} +.prose > p:first-of-type { + font-size: 16px; + line-height: 1.85; + color: var(--muted); +} +.prose h2 { + font-size: 27px; + letter-spacing: -0.8px; + border-top: 1px solid var(--line); + padding-top: 28px; + margin-top: 48px; + margin-bottom: 17px; + scroll-margin-top: 115px; +} +.prose h3 { + font-size: 18px; + margin-block: 30px 12px; + scroll-margin-top: 115px; +} +.prose a { + color: var(--blue); + text-decoration: underline; + text-underline-offset: 3px; + overflow-wrap: anywhere; +} +.prose code { + font-family: var(--mono); + font-size: 0.88em; + background: var(--soft); + padding: 2px 5px; + overflow-wrap: anywhere; +} +.prose pre { + position: relative; + border: 1px solid var(--line); + background: #151e29; + margin-block: 25px; + padding: 40px 18px 18px; + overflow-x: auto; + line-height: 1.8; + font-size: 12px; +} +.prose pre code { + padding: 0; + background: none; + white-space: pre; + overflow-wrap: normal; + font-size: inherit; +} +.code-copy { + position: absolute; + right: 8px; + top: 7px; + background: var(--paper); + border: 1px solid var(--line); + padding: 3px 9px; + font: 10px var(--sans); + color: var(--muted); +} +.prose li { + margin-bottom: 8px; +} +.prose ul, +.prose ol { + padding-left: 23px; +} +.table-scroll { + overflow-x: auto; + margin-block: 25px; + border: 1px solid var(--line); +} +.prose table { + border-collapse: collapse; + width: 100%; + font-size: 12px; + line-height: 1.7; +} +.prose th { + text-align: left; + background: var(--soft); + font-weight: 600; + white-space: nowrap; +} +.prose td, +.prose th { + padding: 12px; + border-bottom: 1px solid var(--line); + vertical-align: top; + min-width: 110px; +} +.prose td:first-child { + min-width: 160px; +} +.prose tr:last-child td { + border-bottom: 0; +} +.prose blockquote { + border-left: 2px solid var(--blue); + padding: 2px 18px; + margin: 25px 0; + background: var(--soft); +} +.prose blockquote p { + margin-block: 12px; +} +.docs-toc > p { + font: 9px var(--mono); + letter-spacing: 1px; + color: var(--muted); + margin: 0 0 15px; +} +.docs-toc nav { + display: grid; + gap: 12px; +} +.docs-toc a { + font-size: 11px; + line-height: 1.5; + color: var(--muted); + border-left: 1px solid var(--line); + padding-left: 13px; +} +.docs-toc a:hover { + color: var(--blue); + border-color: var(--blue); +} +.docs-pager { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + margin-top: 50px; + padding-top: 25px; + border-top: 1px solid var(--line); +} +.docs-pager a { + font-size: 14px; +} +.docs-pager a:last-child { + text-align: right; + grid-column: 2; +} +.docs-pager small { + display: block; + color: var(--muted); + font: 9px var(--mono); + margin-bottom: 6px; +} +.edit-link { + font-size: 11px; + color: var(--muted); + display: inline-block; + margin-top: 35px; +} +.docs-search { + margin-bottom: 24px; + position: relative; +} +.docs-search input { + width: 100%; + border: 1px solid var(--line); + background: var(--white); + color: var(--ink); + padding: 10px 12px; + font-size: 12px; + border-radius: 0; +} +.search-results { + position: absolute; + top: 47px; + left: 0; + width: min(660px, calc(100vw - 48px)); + max-height: 65vh; + overflow: auto; + background: var(--white); + border: 1px solid var(--line); + box-shadow: 0 12px 35px #0006; + z-index: 8; +} +.search-results[hidden] { + display: none; +} +.search-results a { + display: block; + padding: 14px 17px; + border-bottom: 1px solid var(--line); +} +.search-results a:hover { + background: var(--soft); +} +.search-results strong { + display: block; + font-size: 13px; + color: var(--blue); +} +.search-results span { + display: block; + font-size: 11px; + color: var(--muted); + line-height: 1.7; + margin-top: 5px; +} +.search-results > p { + padding: 15px; + font-size: 12px; +} +.search-summary { + color: var(--muted); + font-size: 11px; + margin-top: 6px; +} +.docs-sidebar:has(.search-results:not([hidden])) { + overflow: visible; +} +@media (max-width: 1100px) { + .hero-grid { + gap: 40px; + } + .editorial-section { + grid-template-columns: 175px minmax(0, 1fr); + gap: 35px; + } + .docs-layout { + grid-template-columns: 185px minmax(0, 1fr); + gap: 35px; + } + .docs-toc { + display: none; + } + .start-grid { + gap: 60px; + } +} +@media (max-width: 760px) { + .page-width, + .header-inner { + width: calc(100% - 40px); + } + .header-inner { + height: 74px; + gap: 18px; + } + .wordmark { + font-size: 25px; + gap: 6px; + } + .wordmark svg { + width: 23px; + } + .site-header nav { + gap: 18px; + font-size: 11px; + } + .site-header nav a:first-child { + display: none; + } + .hero { + padding-block: 35px 40px; + } + .hero-platform { + display: none; + } + .hero-kicker { + margin-bottom: 25px; + } + .hero-grid { + grid-template-columns: 1fr; + gap: 25px; + } + h1 { + font-size: 54px; + letter-spacing: -2.8px; + } + .hero-description { + max-width: 480px; + } + .hero-description > p { + font-size: 14px; + } + .hero-links { + gap: 22px; + margin-top: 22px; + } + .hero-links .button { + font-size: 12px; + } + .inline-link { + font-size: 11px; + } + .image-label { + font-size: 8px; + } + .desktop-hero figcaption { + font-size: 10px; + display: block; + } + .desktop-hero figcaption > span:last-child { + display: block; + margin-top: 5px; + font-size: 8px; + } + .editorial-section { + grid-template-columns: 1fr; + gap: 25px; + margin-top: 40px; + padding-block: 45px; + } + .section-rail { + flex-direction: row; + align-items: center; + gap: 15px; + } + .eyebrow { + font-size: 9px; + letter-spacing: 0.7px; + } + .rail-link { + font-size: 10px; + } + h2 { + font-size: 33px; + letter-spacing: -1px; + } + .section-intro { + font-size: 14px; + margin-top: 20px; + } + .layout-explanations { + gap: 25px; + margin-top: 32px; + grid-template-columns: 1fr; + } + .layout-explanations h3 { + font-size: 18px; + } + .key-line { + margin-top: 16px; + } + .aside-note { + margin-top: 24px; + } + .appearance-section .editorial-section { + padding-block: 45px; + } + .appearance-shot { + margin-top: 6px; + } + .appearance-shot figcaption { + font-size: 9px; + gap: 15px; + } + .appearance-shot figcaption > span:first-child { + font-size: 8px; + white-space: nowrap; + } + .appearance-shot figcaption > span:last-child { + font: 9px var(--sans); + } + .appearance-notes { + grid-template-columns: 1fr; + gap: 24px; + margin-top: 3px; + } + .appearance-notes p { + margin-top: 6px; + } + .setup-section { + margin-top: 0; + } + .capabilities { + margin-top: 25px; + } + .capabilities > div { + grid-template-columns: 1fr; + gap: 7px; + padding-block: 17px; + } + .start-section { + padding-block: 43px; + } + .start-grid { + grid-template-columns: 1fr; + gap: 30px; + } + .start-section h2 { + font-size: 43px; + } + .start-section p { + font-size: 13px; + } + .start-links strong { + font-size: 17px; + } + .site-footer { + gap: 12px; + flex-wrap: wrap; + padding-block: 26px; + } + .site-footer p { + font-size: 10px; + } + .site-footer > a:last-child { + font-size: 10px; + } + .docs-layout { + width: calc(100% - 36px); + grid-template-columns: 1fr; + gap: 30px; + padding-top: 24px; + } + .docs-sidebar { + position: relative; + top: auto; + max-height: none; + overflow: visible; + } + .docs-sidebar details { + border-bottom: 1px solid var(--line); + padding-bottom: 16px; + } + .docs-sidebar summary { + cursor: pointer; + list-style: revert; + margin: 0 0 13px; + } + .docs-sidebar nav { + grid-template-columns: 1fr 1fr; + gap: 0; + } + .docs-sidebar nav a { + font-size: 12px; + padding: 6px 10px; + } + .sidebar-links { + display: none; + } + .docs-search { + margin-bottom: 18px; + } + .search-results { + width: 100%; + } + .docs-article h1 { + font-size: 37px; + letter-spacing: -1px; + } + .prose { + font-size: 14px; + } + .prose h2 { + font-size: 25px; + } + .prose pre { + font-size: 11px; + } + .docs-page .site-header { + position: relative; + } + .prose h2, + .prose h3 { + scroll-margin-top: 20px; + } +} +@media (max-width: 380px) { + h1 { + font-size: 47px; + } + .site-header nav { + gap: 12px; + } + .hero-links { + gap: 14px; + } + .button { + gap: 20px; + } + .site-footer > a:last-child { + margin-left: 0; + } + .rail-link { + max-width: 130px; + text-align: right; + } +} +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + *, + *::before, + *::after { + animation: none !important; + transition: none !important; + } +} + +/* Compact product homepage. */ +.simple-home .page-width { + max-width: 1000px; +} +.product-intro { + text-align: center; + padding-block: 48px 34px; +} +.product-intro h1 { + font-size: 64px; + font-weight: 700; + letter-spacing: -2.5px; + color: var(--blue); +} +.product-tagline { + font-size: 22px; + line-height: 1.5; + margin-top: 16px; +} +.product-description { + color: var(--muted); + max-width: 600px; + margin: 13px auto 0; + font-size: 15px; +} +.product-links { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: 14px; + margin-top: 24px; +} +.product-links .button { + border-radius: 5px; + background: #285ec2; + border: 1px solid #285ec2; + padding: 11px 20px; +} +.product-links .button:hover { + background: #3471db; +} +.product-links .secondary { + background: transparent; + color: var(--blue); +} +.product-links .secondary:hover { + background: var(--soft); +} +.source-link { + font-size: 13px; + color: var(--muted); + margin-left: 5px; +} +.product-screenshot img { + width: 100%; + border: 1px solid var(--line); + border-radius: 8px; +} +.product-screenshot figcaption { + justify-content: center; + padding-top: 10px; + font-size: 11px; +} +.product-features { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 35px; + padding-block: 40px; +} +.product-features h2, +.product-getting-started h2 { + font-size: 20px; + font-weight: 600; + letter-spacing: -0.4px; + line-height: 1.4; +} +.product-features p, +.product-getting-started p { + font-size: 14px; + color: var(--muted); + margin-top: 12px; + line-height: 1.8; +} +.product-features a { + display: inline-block; + font-size: 12px; + color: var(--blue); + margin-top: 12px; +} +.product-getting-started { + border-top: 1px solid var(--line); + padding-block: 28px 42px; +} +.product-getting-started a { + color: var(--blue); + text-decoration: underline; + text-underline-offset: 3px; +} +.simple-home + .site-footer { + max-width: 1000px; + border-top: 1px solid var(--line); +} +@media (max-width: 760px) { + .product-intro { + padding-block: 35px 27px; + } + .product-intro h1 { + font-size: 52px; + } + .product-tagline { + font-size: 19px; + } + .product-description { + font-size: 14px; + } + .product-links { + gap: 10px; + } + .product-links .button { + padding: 10px 15px; + font-size: 12px; + } + .source-link { + font-size: 12px; + } + .product-features { + grid-template-columns: 1fr; + gap: 25px; + padding-block: 30px; + } + .product-features p { + margin-top: 7px; + } +}