git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

commit4adc21725bebfd1172c399d1274280c3d20d5c1d
parentbfd35898eb
authorLucas Galante <[email protected]>
date2026-08-13 18:06
feat: edge bevel — a lit chamfer around the inside of decorated windows

scenefx gains WLR_SCENE_NODE_BEVEL alongside the box-shadow node it is
modelled on. The shader derives everything from the signed distance to the
rounded rect, so the highlight sweeps around the corner arcs instead of
being mitred at 45 degrees: the SDF gradient gives the rim's in-plane
normal, a slope profile across the rim gives the chamfer (a shoulder knob
blends flat chamfer to rounded roll-off), and a Lambert term against the
light direction splits it into a lit side and a shaded side.

The compositor drives one bevel node per window, created after the surfaces
so it draws OVER the client's outermost pixels — the rim is inner, costing a
few px of the app rather than needing room outside it. Thickness and corner
radius scale with the camera zoom exactly like the blur radius and shadow
sigma do, and update_bevel is called from BOTH render paths (the
transaction one and render_viewport_update) so it cannot freeze at the
pre-gesture zoom the way the shadow did.

Lighting follows the DE's existing convention rather than inventing one: the
default is the same top-left source that shadow_offset_x/y point away from.
Configurable under surface { bevel { } } — enabled, thickness, light (a
compass point), light_intensity, shade_intensity, shoulder, color — and the
scene node takes a light vector, so pointing it at the traveling
light_source segment later is a matter of writing it per frame.

Applies to every window the compositor decorates (cce-* and rounded_apps,
which is what want_shadow already gates on), so claude-desktop gets it.

 scenefx/include/render/fx_renderer/fx_renderer.h |   1 +
 scenefx/include/render/fx_renderer/shaders.h     |  17 +++
 scenefx/include/scenefx/render/pass.h            |  24 ++++
 scenefx/include/scenefx/types/wlr_scene.h        |  39 ++++++
 scenefx/render/fx_renderer/fx_pass.c             |  45 +++++++
 scenefx/render/fx_renderer/fx_renderer.c         |   6 +
 scenefx/render/fx_renderer/shaders.c             |  22 ++++
 scenefx/render/fx_renderer/shaders/bevel.frag    |  91 ++++++++++++++
 scenefx/render/fx_renderer/shaders/meson.build   |   1 +
 scenefx/types/scene/wlr_scene.c                  | 145 ++++++++++++++++++++++-
 src/server/config.rs                             | 125 +++++++++++++++++++
 src/server/window.rs                             |  71 +++++++++++
 12 files changed, 586 insertions(+), 1 deletion(-)

diff --git a/scenefx/include/render/fx_renderer/fx_renderer.h b/scenefx/include/render/fx_renderer/fx_renderer.h
index ba6cc4b..c4700c8 100644
--- a/scenefx/include/render/fx_renderer/fx_renderer.h
+++ b/scenefx/include/render/fx_renderer/fx_renderer.h
@@ -200,6 +200,7 @@ struct fx_renderer {
 		struct tex_shader tex_effects_ext;
 
 		struct box_shadow_shader box_shadow;
+		struct bevel_shader bevel;
 		struct blur_shader blur1;
 		struct blur_shader blur2;
 		struct blur_effects_shader blur_effects;
diff --git a/scenefx/include/render/fx_renderer/shaders.h b/scenefx/include/render/fx_renderer/shaders.h
index cc64327..1353ab4 100644
--- a/scenefx/include/render/fx_renderer/shaders.h
+++ b/scenefx/include/render/fx_renderer/shaders.h
@@ -159,6 +159,23 @@ struct box_shadow_shader {
 
 bool link_box_shadow_program(struct box_shadow_shader *shader);
 
+struct bevel_shader {
+	GLuint program;
+	GLint proj;
+	GLint color;
+	GLint pos_attrib;
+	GLint position;
+	GLint size;
+	GLint corner_radius;
+	GLint thickness;
+	GLint light_dir;
+	GLint light_intensity;
+	GLint shade_intensity;
+	GLint shoulder;
+};
+
+bool link_bevel_program(struct bevel_shader *shader);
+
 struct blur_shader {
 	GLuint program;
 	GLint proj;
diff --git a/scenefx/include/scenefx/render/pass.h b/scenefx/include/scenefx/render/pass.h
index 75f0fd5..5c58dda 100644
--- a/scenefx/include/scenefx/render/pass.h
+++ b/scenefx/include/scenefx/render/pass.h
@@ -83,6 +83,24 @@ struct fx_render_box_shadow_options {
 	struct wlr_render_color color;
 };
 
+struct fx_render_bevel_options {
+	struct wlr_box box;
+	/* Clip region, leave NULL to disable clipping */
+	const pixman_region32_t *clip;
+
+	int corner_radius;
+	/* Rim width in px: how far in from the edge the chamfer reaches. */
+	float thickness;
+	/* Direction TOWARD the light, screen space with y down. */
+	float light_dir[2];
+	float light_intensity;
+	float shade_intensity;
+	/* 0 = hard flat chamfer, 1 = fully rounded shoulder. */
+	float shoulder;
+	/* Tint of the highlight; alpha scales the whole effect. */
+	struct wlr_render_color color;
+};
+
 struct fx_render_blur_pass_options {
 	struct fx_render_texture_options tex_options;
 	struct fx_framebuffer *current_buffer;
@@ -139,6 +157,12 @@ void fx_render_pass_add_rounded_rect_grad(struct fx_gles_render_pass *render_pas
 void fx_render_pass_add_box_shadow(struct fx_gles_render_pass *pass,
 		const struct fx_render_box_shadow_options *options);
 
+/**
+ * Render an edge bevel: a lit chamfer around the inside of a rounded rect.
+ */
+void fx_render_pass_add_bevel(struct fx_gles_render_pass *pass,
+		const struct fx_render_bevel_options *options);
+
 /**
  * Render blur.
  */
diff --git a/scenefx/include/scenefx/types/wlr_scene.h b/scenefx/include/scenefx/types/wlr_scene.h
index 7e59617..9c7e1e4 100644
--- a/scenefx/include/scenefx/types/wlr_scene.h
+++ b/scenefx/include/scenefx/types/wlr_scene.h
@@ -61,6 +61,7 @@ enum wlr_scene_node_type {
 	WLR_SCENE_NODE_RECT,
 	WLR_SCENE_NODE_BUFFER,
 	WLR_SCENE_NODE_SHADOW,
+	WLR_SCENE_NODE_BEVEL,
 	WLR_SCENE_NODE_OPTIMIZED_BLUR,
 	WLR_SCENE_NODE_BLUR,
 };
@@ -170,6 +171,23 @@ struct wlr_scene_shadow {
 	struct clipped_region clipped_region;
 };
 
+/** A lit chamfer around the inside of a rounded rect. */
+struct wlr_scene_bevel {
+	struct wlr_scene_node node;
+	int width, height;
+	int corner_radius;
+	/** Rim width in px: how far in from the edge the chamfer reaches. */
+	float thickness;
+	/** Direction TOWARD the light, screen space with y down. */
+	float light_dir[2];
+	float light_intensity;
+	float shade_intensity;
+	/** 0 = hard flat chamfer, 1 = fully rounded shoulder. */
+	float shoulder;
+	/** Highlight tint; alpha scales the whole effect. */
+	float color[4];
+};
+
 struct wlr_scene_blur {
 	struct wlr_scene_node node;
 	int width, height;
@@ -517,6 +535,12 @@ struct wlr_scene_rect *wlr_scene_rect_from_node(struct wlr_scene_node *node);
  */
 struct wlr_scene_shadow *wlr_scene_shadow_from_node(struct wlr_scene_node *node);
 
+/**
+ * If this node represents a wlr_scene_bevel, that structure is returned.
+ * Asserts otherwise.
+ */
+struct wlr_scene_bevel *wlr_scene_bevel_from_node(struct wlr_scene_node *node);
+
 struct wlr_scene_blur *wlr_scene_blur_from_node(struct wlr_scene_node *node);
 
 /**
@@ -590,6 +614,21 @@ struct wlr_scene_shadow *wlr_scene_shadow_create(struct wlr_scene_tree *parent,
  */
 void wlr_scene_shadow_set_size(struct wlr_scene_shadow *shadow, int width, int height);
 
+/**
+ * Add a bevel node: a lit chamfer drawn around the inside of the given box.
+ */
+struct wlr_scene_bevel *wlr_scene_bevel_create(struct wlr_scene_tree *parent,
+	int width, int height, int corner_radius, float thickness,
+	const float color[static 4]);
+
+void wlr_scene_bevel_set_size(struct wlr_scene_bevel *bevel, int width, int height);
+void wlr_scene_bevel_set_corner_radius(struct wlr_scene_bevel *bevel, int radius);
+void wlr_scene_bevel_set_thickness(struct wlr_scene_bevel *bevel, float thickness);
+void wlr_scene_bevel_set_light(struct wlr_scene_bevel *bevel, float dir_x, float dir_y,
+	float light_intensity, float shade_intensity);
+void wlr_scene_bevel_set_shoulder(struct wlr_scene_bevel *bevel, float shoulder);
+void wlr_scene_bevel_set_color(struct wlr_scene_bevel *bevel, const float color[static 4]);
+
 /**
  * Change the corner radius of an existing shadow node.
  */
diff --git a/scenefx/render/fx_renderer/fx_pass.c b/scenefx/render/fx_renderer/fx_pass.c
index 38eefad..740db98 100644
--- a/scenefx/render/fx_renderer/fx_pass.c
+++ b/scenefx/render/fx_renderer/fx_pass.c
@@ -1116,6 +1116,51 @@ static struct fx_framebuffer *get_main_buffer_blur(struct fx_gles_render_pass *p
 	return fx_options->current_buffer;
 }
 
+void fx_render_pass_add_bevel(struct fx_gles_render_pass *pass,
+		const struct fx_render_bevel_options *options) {
+	struct fx_renderer *renderer = pass->buffer->renderer;
+
+	struct wlr_box box = options->box;
+	assert(box.width > 0 && box.height > 0);
+
+	pixman_region32_t clip_region;
+	if (options->clip) {
+		pixman_region32_init(&clip_region);
+		pixman_region32_copy(&clip_region, options->clip);
+	} else {
+		pixman_region32_init_rect(&clip_region, box.x, box.y, box.width, box.height);
+	}
+
+	push_fx_debug(renderer);
+
+	// The highlight adds light and the shade subtracts it, both premultiplied
+	// into the same draw — ordinary source-over blending.
+	setup_blending(WLR_RENDER_BLEND_MODE_PREMULTIPLIED);
+	glBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ZERO, GL_ONE);
+
+	glUseProgram(renderer->shaders.bevel.program);
+
+	const struct wlr_render_color *color = &options->color;
+	set_proj_matrix(renderer->shaders.bevel.proj, pass->projection_matrix, &box);
+	glUniform4f(renderer->shaders.bevel.color, color->r, color->g, color->b, color->a);
+	glUniform2f(renderer->shaders.bevel.size, box.width, box.height);
+	glUniform2f(renderer->shaders.bevel.position, box.x, box.y);
+	glUniform1f(renderer->shaders.bevel.corner_radius, options->corner_radius);
+	glUniform1f(renderer->shaders.bevel.thickness, options->thickness);
+	glUniform2f(renderer->shaders.bevel.light_dir,
+			options->light_dir[0], options->light_dir[1]);
+	glUniform1f(renderer->shaders.bevel.light_intensity, options->light_intensity);
+	glUniform1f(renderer->shaders.bevel.shade_intensity, options->shade_intensity);
+	glUniform1f(renderer->shaders.bevel.shoulder, options->shoulder);
+
+	render(&box, &clip_region, renderer->shaders.bevel.pos_attrib);
+	pixman_region32_fini(&clip_region);
+
+	glBlendFuncSeparate(GL_ONE, GL_ONE_MINUS_SRC_ALPHA, GL_ZERO, GL_ONE);
+
+	pop_fx_debug(renderer);
+}
+
 void fx_render_pass_add_blur(struct fx_gles_render_pass *pass,
 		struct fx_render_blur_pass_options *fx_options) {
 	if (pass->fx_offscreen_buffers == NULL) {
diff --git a/scenefx/render/fx_renderer/fx_renderer.c b/scenefx/render/fx_renderer/fx_renderer.c
index 8d0a7b2..8d9322f 100644
--- a/scenefx/render/fx_renderer/fx_renderer.c
+++ b/scenefx/render/fx_renderer/fx_renderer.c
@@ -95,6 +95,7 @@ static inline void free_shaders(struct fx_renderer *renderer) {
 	glDeleteProgram(renderer->shaders.tex_effects_rgbx.program);
 	glDeleteProgram(renderer->shaders.tex_effects_ext.program);
 	glDeleteProgram(renderer->shaders.box_shadow.program);
+	glDeleteProgram(renderer->shaders.bevel.program);
 	glDeleteProgram(renderer->shaders.blur1.program);
 	glDeleteProgram(renderer->shaders.blur2.program);
 	glDeleteProgram(renderer->shaders.blur_effects.program);
@@ -422,6 +423,11 @@ static bool link_shaders(struct fx_renderer *renderer) {
 		wlr_log(WLR_ERROR, "Could not link box shadow shader");
 		goto error;
 	}
+	// bevel shader
+	if (!link_bevel_program(&renderer->shaders.bevel)) {
+		wlr_log(WLR_ERROR, "Could not link bevel shader");
+		goto error;
+	}
 
 	// Blur shaders
 	if (!link_blur1_program(&renderer->shaders.blur1)) {
diff --git a/scenefx/render/fx_renderer/shaders.c b/scenefx/render/fx_renderer/shaders.c
index 3b321fb..05cbcd9 100644
--- a/scenefx/render/fx_renderer/shaders.c
+++ b/scenefx/render/fx_renderer/shaders.c
@@ -18,6 +18,7 @@
 #include "quad_grad_round_frag_src.h"
 #include "tex_frag_src.h"
 #include "box_shadow_frag_src.h"
+#include "bevel_frag_src.h"
 #include "blur1_frag_src.h"
 #include "blur2_frag_src.h"
 #include "blur_effects_frag_src.h"
@@ -333,6 +334,27 @@ bool link_box_shadow_program(struct box_shadow_shader *shader) {
 	return true;
 }
 
+bool link_bevel_program(struct bevel_shader *shader) {
+	GLuint prog;
+	shader->program = prog = link_program(bevel_frag_src);
+	if (!shader->program) {
+		return false;
+	}
+	shader->proj = glGetUniformLocation(prog, "proj");
+	shader->color = glGetUniformLocation(prog, "color");
+	shader->pos_attrib = glGetAttribLocation(prog, "pos");
+	shader->position = glGetUniformLocation(prog, "position");
+	shader->size = glGetUniformLocation(prog, "size");
+	shader->corner_radius = glGetUniformLocation(prog, "corner_radius");
+	shader->thickness = glGetUniformLocation(prog, "thickness");
+	shader->light_dir = glGetUniformLocation(prog, "light_dir");
+	shader->light_intensity = glGetUniformLocation(prog, "light_intensity");
+	shader->shade_intensity = glGetUniformLocation(prog, "shade_intensity");
+	shader->shoulder = glGetUniformLocation(prog, "shoulder");
+
+	return true;
+}
+
 bool link_blur1_program(struct blur_shader *shader) {
 	GLuint prog;
 	shader->program = prog = link_program(blur1_frag_src);
diff --git a/scenefx/render/fx_renderer/shaders/bevel.frag b/scenefx/render/fx_renderer/shaders/bevel.frag
new file mode 100644
index 0000000..9a1890b
--- /dev/null
+++ b/scenefx/render/fx_renderer/shaders/bevel.frag
@@ -0,0 +1,91 @@
+// Edge bevel: a lit chamfer around the inside of a rounded rect.
+//
+// The rim is shaded as if the window edge were rolled off toward the surface
+// plane, so the side facing the light picks up a highlight and the far side
+// falls into shade, with the transition sweeping smoothly around the corner
+// arcs. Everything is derived from the signed distance to the rounded-rect
+// boundary, which makes the corners fall out for free — the gradient follows
+// the curve instead of being mitred at 45 degrees.
+
+#ifdef GL_FRAGMENT_PRECISION_HIGH
+precision highp float;
+#else
+precision mediump float;
+#endif
+
+varying vec4 v_color;
+varying vec2 v_texcoord;
+
+uniform vec2 position;
+uniform vec2 size;
+uniform float corner_radius;
+// Rim width in px: how far in from the edge the chamfer reaches.
+uniform float thickness;
+// Unit vector pointing toward the light (screen space, y down).
+uniform vec2 light_dir;
+// Highlight and shade strengths, 0..1.
+uniform float light_intensity;
+uniform float shade_intensity;
+// Shoulder: how much of the rim is the rounded roll-off vs the flat face.
+// 0 = a hard flat chamfer, 1 = fully rounded shoulder.
+uniform float shoulder;
+
+// Signed distance to a rounded rect; negative inside.
+float rounded_rect_sdf(vec2 p, vec2 half_size, float radius) {
+    vec2 q = abs(p) - half_size + radius;
+    return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - radius;
+}
+
+void main() {
+    vec2 half_size = size * 0.5;
+    vec2 center = position + half_size;
+    vec2 p = gl_FragCoord.xy - center;
+
+    float dist = rounded_rect_sdf(p, half_size, corner_radius);
+
+    // Outside the rect, or deeper in than the rim: nothing to draw.
+    float rim = max(thickness, 1.0);
+    if (dist > 0.0 || dist < -rim) {
+        discard;
+    }
+
+    // t: 0 at the outer edge, 1 where the rim meets the flat surface.
+    float t = clamp(-dist / rim, 0.0, 1.0);
+
+    // The surface normal of the chamfer. Its in-plane part points OUT of the
+    // rect (the gradient of the SDF), and its steepness falls off across the
+    // rim — steep at the edge, flat where it meets the face.
+    vec2 grad = normalize(vec2(
+        rounded_rect_sdf(p + vec2(1.0, 0.0), half_size, corner_radius) -
+        rounded_rect_sdf(p - vec2(1.0, 0.0), half_size, corner_radius),
+        rounded_rect_sdf(p + vec2(0.0, 1.0), half_size, corner_radius) -
+        rounded_rect_sdf(p - vec2(0.0, 1.0), half_size, corner_radius)
+    ) + vec2(1e-6));
+
+    // Slope profile across the rim. Mixing linear and smoothstep gives the
+    // "shoulder" control: a hard chamfer keeps a constant slope, a rounded
+    // one eases off at both ends.
+    float slope = mix(1.0 - t, 1.0 - smoothstep(0.0, 1.0, t), shoulder);
+
+    // Lambert against the light, using only the in-plane direction (the
+    // chamfer's tilt is what varies; the face itself is flat-on).
+    float facing = dot(grad, normalize(light_dir + vec2(1e-6)));
+
+    // Positive = lit side, negative = shaded side.
+    float lit = max(facing, 0.0) * light_intensity;
+    float shade = max(-facing, 0.0) * shade_intensity;
+
+    float highlight = lit * slope;
+    float shadow = shade * slope;
+
+    // White highlight over dark shade, both premultiplied. v_color carries
+    // the tint (and its alpha scales the whole effect).
+    vec3 rgb = v_color.rgb * highlight;
+    float alpha = highlight + shadow;
+
+    // Feather the very outer pixel so the rim doesn't alias against the
+    // window's own rounded edge.
+    float edge_aa = clamp(-dist, 0.0, 1.0);
+
+    gl_FragColor = vec4(rgb, alpha) * v_color.a * edge_aa;
+}
diff --git a/scenefx/render/fx_renderer/shaders/meson.build b/scenefx/render/fx_renderer/shaders/meson.build
index 1bafd4f..e1fe345 100644
--- a/scenefx/render/fx_renderer/shaders/meson.build
+++ b/scenefx/render/fx_renderer/shaders/meson.build
@@ -11,6 +11,7 @@ shaders = [
 	'quad_grad_round.frag',
 	'tex.frag',
 	'box_shadow.frag',
+	'bevel.frag',
 	'blur1.frag',
 	'blur2.frag',
 	'blur_effects.frag',
diff --git a/scenefx/types/scene/wlr_scene.c b/scenefx/types/scene/wlr_scene.c
index 33ce240..79b4f47 100644
--- a/scenefx/types/scene/wlr_scene.c
+++ b/scenefx/types/scene/wlr_scene.c
@@ -82,6 +82,12 @@ struct wlr_scene_shadow *wlr_scene_shadow_from_node(struct wlr_scene_node *node)
 	return shadow;
 }
 
+struct wlr_scene_bevel *wlr_scene_bevel_from_node(struct wlr_scene_node *node) {
+	assert(node->type == WLR_SCENE_NODE_BEVEL);
+	struct wlr_scene_bevel *bevel = wl_container_of(node, bevel, node);
+	return bevel;
+}
+
 struct wlr_scene_blur *wlr_scene_blur_from_node(struct wlr_scene_node *node) {
 	assert(node->type == WLR_SCENE_NODE_BLUR);
 	struct wlr_scene_blur *blur = wl_container_of(node, blur, node);
@@ -280,6 +286,7 @@ static bool _scene_nodes_in_box(struct wlr_scene_node *node, struct wlr_box *box
 	case WLR_SCENE_NODE_RECT:
 	case WLR_SCENE_NODE_BUFFER:
 	case WLR_SCENE_NODE_SHADOW:
+	case WLR_SCENE_NODE_BEVEL:
 	case WLR_SCENE_NODE_OPTIMIZED_BLUR:
 	case WLR_SCENE_NODE_BLUR:;
 		struct wlr_box node_box = { .x = lx, .y = ly };
@@ -391,7 +398,9 @@ static void scene_node_opaque_region(struct wlr_scene_node *node, int x, int y,
 	} else if (node->type == WLR_SCENE_NODE_SHADOW) {
 		// TODO: test & handle case of blur sigma = 0 and color[3] = 1?
 		return;
-	} else if (node->type == WLR_SCENE_NODE_OPTIMIZED_BLUR || node->type == WLR_SCENE_NODE_BLUR) {
+	} else if (node->type == WLR_SCENE_NODE_BEVEL
+			|| node->type == WLR_SCENE_NODE_OPTIMIZED_BLUR
+			|| node->type == WLR_SCENE_NODE_BLUR) {
 		// Always transparent
 		return;
 	}
@@ -1101,6 +1110,88 @@ struct wlr_scene_shadow *wlr_scene_shadow_create(struct wlr_scene_tree *parent,
 	return scene_shadow;
 }
 
+struct wlr_scene_bevel *wlr_scene_bevel_create(struct wlr_scene_tree *parent,
+		int width, int height, int corner_radius, float thickness,
+		const float color[static 4]) {
+	struct wlr_scene_bevel *scene_bevel = calloc(1, sizeof(*scene_bevel));
+	if (scene_bevel == NULL) {
+		return NULL;
+	}
+	assert(parent);
+	scene_node_init(&scene_bevel->node, WLR_SCENE_NODE_BEVEL, parent);
+
+	scene_bevel->width = width;
+	scene_bevel->height = height;
+	scene_bevel->corner_radius = corner_radius;
+	scene_bevel->thickness = thickness;
+	// Default light: top-left, matching the DE's shadow convention.
+	scene_bevel->light_dir[0] = -0.7071f;
+	scene_bevel->light_dir[1] = -0.7071f;
+	scene_bevel->light_intensity = 1.0f;
+	scene_bevel->shade_intensity = 1.0f;
+	scene_bevel->shoulder = 0.5f;
+	memcpy(scene_bevel->color, color, sizeof(scene_bevel->color));
+
+	scene_node_update(&scene_bevel->node, NULL);
+
+	return scene_bevel;
+}
+
+void wlr_scene_bevel_set_size(struct wlr_scene_bevel *bevel, int width, int height) {
+	if (bevel->width == width && bevel->height == height) {
+		return;
+	}
+	bevel->width = width;
+	bevel->height = height;
+	scene_node_update(&bevel->node, NULL);
+}
+
+void wlr_scene_bevel_set_corner_radius(struct wlr_scene_bevel *bevel, int radius) {
+	if (bevel->corner_radius == radius) {
+		return;
+	}
+	bevel->corner_radius = radius;
+	scene_node_update(&bevel->node, NULL);
+}
+
+void wlr_scene_bevel_set_thickness(struct wlr_scene_bevel *bevel, float thickness) {
+	if (bevel->thickness == thickness) {
+		return;
+	}
+	bevel->thickness = thickness;
+	scene_node_update(&bevel->node, NULL);
+}
+
+void wlr_scene_bevel_set_light(struct wlr_scene_bevel *bevel, float dir_x, float dir_y,
+		float light_intensity, float shade_intensity) {
+	if (bevel->light_dir[0] == dir_x && bevel->light_dir[1] == dir_y
+			&& bevel->light_intensity == light_intensity
+			&& bevel->shade_intensity == shade_intensity) {
+		return;
+	}
+	bevel->light_dir[0] = dir_x;
+	bevel->light_dir[1] = dir_y;
+	bevel->light_intensity = light_intensity;
+	bevel->shade_intensity = shade_intensity;
+	scene_node_update(&bevel->node, NULL);
+}
+
+void wlr_scene_bevel_set_shoulder(struct wlr_scene_bevel *bevel, float shoulder) {
+	if (bevel->shoulder == shoulder) {
+		return;
+	}
+	bevel->shoulder = shoulder;
+	scene_node_update(&bevel->node, NULL);
+}
+
+void wlr_scene_bevel_set_color(struct wlr_scene_bevel *bevel, const float color[static 4]) {
+	if (memcmp(bevel->color, color, sizeof(bevel->color)) == 0) {
+		return;
+	}
+	memcpy(bevel->color, color, sizeof(bevel->color));
+	scene_node_update(&bevel->node, NULL);
+}
+
 void wlr_scene_shadow_set_size(struct wlr_scene_shadow *shadow, int width, int height) {
 	if (shadow->width == width && shadow->height == height) {
 		return;
@@ -1862,6 +1953,11 @@ void scene_node_get_size(struct wlr_scene_node *node, int *width, int *height) {
 		*width = scene_shadow->width;
 		*height = scene_shadow->height;
 		break;
+	case WLR_SCENE_NODE_BEVEL:;
+		struct wlr_scene_bevel *scene_bevel = wlr_scene_bevel_from_node(node);
+		*width = scene_bevel->width;
+		*height = scene_bevel->height;
+		break;
 	case WLR_SCENE_NODE_OPTIMIZED_BLUR:;
 		struct wlr_scene_optimized_blur *scene_blur =
 			wlr_scene_optimized_blur_from_node(node);
@@ -2056,6 +2152,7 @@ static bool scene_node_at_iterator(struct wlr_scene_node *node,
 			return false;
 		}
 	} else if (node->type == WLR_SCENE_NODE_SHADOW
+			|| node->type == WLR_SCENE_NODE_BEVEL
 			|| node->type == WLR_SCENE_NODE_OPTIMIZED_BLUR
 			|| node->type == WLR_SCENE_NODE_BLUR) {
 		// Disable interaction
@@ -2314,6 +2411,48 @@ static void scene_entry_render(struct render_list_entry *entry, const struct ren
 		};
 		fx_render_pass_add_box_shadow(fx_pass, &shadow_options);
 		break;
+	case WLR_SCENE_NODE_BEVEL:;
+		struct wlr_scene_bevel *scene_bevel = wlr_scene_bevel_from_node(node);
+
+		// The light direction is authored in screen space, so it has to
+		// follow the output transform along with the box it lights.
+		float bevel_dir_x = scene_bevel->light_dir[0];
+		float bevel_dir_y = scene_bevel->light_dir[1];
+		switch (node_transform) {
+		case WL_OUTPUT_TRANSFORM_90:
+			bevel_dir_x = scene_bevel->light_dir[1];
+			bevel_dir_y = -scene_bevel->light_dir[0];
+			break;
+		case WL_OUTPUT_TRANSFORM_180:
+			bevel_dir_x = -scene_bevel->light_dir[0];
+			bevel_dir_y = -scene_bevel->light_dir[1];
+			break;
+		case WL_OUTPUT_TRANSFORM_270:
+			bevel_dir_x = -scene_bevel->light_dir[1];
+			bevel_dir_y = scene_bevel->light_dir[0];
+			break;
+		default:
+			break;
+		}
+
+		struct fx_render_bevel_options bevel_options = {
+			.box = dst_box,
+			.corner_radius = scene_bevel->corner_radius * data->scale,
+			.thickness = scene_bevel->thickness * data->scale,
+			.light_dir = { bevel_dir_x, bevel_dir_y },
+			.light_intensity = scene_bevel->light_intensity,
+			.shade_intensity = scene_bevel->shade_intensity,
+			.shoulder = scene_bevel->shoulder,
+			.color = {
+				.r = scene_bevel->color[0],
+				.g = scene_bevel->color[1],
+				.b = scene_bevel->color[2],
+				.a = scene_bevel->color[3],
+			},
+			.clip = &render_region,
+		};
+		fx_render_pass_add_bevel(fx_pass, &bevel_options);
+		break;
 	case WLR_SCENE_NODE_OPTIMIZED_BLUR:;
 		struct wlr_scene_optimized_blur *scene_blur = wlr_scene_optimized_blur_from_node(node);
 		// Re-render the optimized blur buffer when needed. Retry rendering
@@ -2770,6 +2909,10 @@ static bool scene_node_invisible(struct wlr_scene_node *node) {
 		struct wlr_scene_shadow *shadow = wlr_scene_shadow_from_node(node);
 
 		return shadow->color[3] == 0.f;
+	} else if (node->type == WLR_SCENE_NODE_BEVEL) {
+		struct wlr_scene_bevel *bevel = wlr_scene_bevel_from_node(node);
+
+		return bevel->color[3] == 0.f || bevel->thickness <= 0.f;
 	}
 
 	return false;
diff --git a/src/server/config.rs b/src/server/config.rs
index e627eaa..b3fa4e6 100644
--- a/src/server/config.rs
+++ b/src/server/config.rs
@@ -67,6 +67,22 @@ pub struct Layout {
     /// `light_source_position` (down-right for the default top-left light).
     pub shadow_offset_x: i32,
     pub shadow_offset_y: i32,
+    /// Edge bevel: a lit chamfer drawn around the INSIDE of a decorated
+    /// window's edge (scenefx bevel node), lit from `bevel_light_*` — the
+    /// same top-left source the drop shadow is offset away from.
+    pub bevel_enabled: bool,
+    /// Rim width in logical px.
+    pub bevel_thickness: f32,
+    /// Direction toward the light; y is down, so the default is up-left.
+    pub bevel_light_x: f32,
+    pub bevel_light_y: f32,
+    /// Strength of the lit and shaded sides, 0..1.
+    pub bevel_light_intensity: f32,
+    pub bevel_shade_intensity: f32,
+    /// 0 = hard flat chamfer, 1 = fully rounded shoulder.
+    pub bevel_shoulder: f32,
+    /// Highlight tint, premultiplied RGBA; alpha scales the whole effect.
+    pub bevel_color: [f32; 4],
     /// Magnetic grid snap for interactive move/resize.
     pub desktop_snap: bool,
     /// Speed ramp + duration (ms) for the overview enter/exit transition.
@@ -169,6 +185,14 @@ impl Default for Layout {
             desktop_cell_corner_radius: 0,
             desktop_cell_fade_inset: 0,
             desktop_grid_fade_mode: "linear".to_string(),
+            bevel_enabled: true,
+            bevel_thickness: 6.0,
+            bevel_light_x: -0.7071,
+            bevel_light_y: -0.7071,
+            bevel_light_intensity: 0.35,
+            bevel_shade_intensity: 0.30,
+            bevel_shoulder: 0.65,
+            bevel_color: [1.0, 1.0, 1.0, 1.0],
             shadow_enabled: true,
             shadow_sigma: 22.0,
             shadow_color: [0.0, 0.0, 0.0, 0.55],
@@ -413,6 +437,20 @@ pub struct SurfaceConfig {
     pub shadow_offset_x: i64,
     #[serde(default = "default_shadow_offset_y")]
     pub shadow_offset_y: i64,
+    #[serde(default = "default_bevel_enabled")]
+    pub bevel_enabled: bool,
+    #[serde(default = "default_bevel_thickness")]
+    pub bevel_thickness: f64,
+    #[serde(default = "default_bevel_light")]
+    pub bevel_light: String,
+    #[serde(default = "default_bevel_light_intensity")]
+    pub bevel_light_intensity: f64,
+    #[serde(default = "default_bevel_shade_intensity")]
+    pub bevel_shade_intensity: f64,
+    #[serde(default = "default_bevel_shoulder")]
+    pub bevel_shoulder: f64,
+    #[serde(default = "default_bevel_color")]
+    pub bevel_color: String,
 }
 
 fn default_shadow_enabled() -> bool { true }
@@ -420,6 +458,30 @@ fn default_shadow_sigma() -> f64 { 22.0 }
 fn default_shadow_color() -> String { "#0000008c".to_string() }
 fn default_shadow_offset_x() -> i64 { 7 }
 fn default_shadow_offset_y() -> i64 { 7 }
+fn default_bevel_enabled() -> bool { true }
+fn default_bevel_thickness() -> f64 { 6.0 }
+/// Compass point the light comes FROM, matching the shadow's top-left source.
+fn default_bevel_light() -> String { "top-left".to_string() }
+fn default_bevel_light_intensity() -> f64 { 0.35 }
+fn default_bevel_shade_intensity() -> f64 { 0.30 }
+fn default_bevel_shoulder() -> f64 { 0.65 }
+fn default_bevel_color() -> String { "#ffffffff".to_string() }
+
+/// Map a compass point to a unit vector pointing TOWARD the light, in screen
+/// space (y down). Anything unrecognized keeps the DE's top-left default.
+pub fn parse_light_direction(s: &str) -> (f32, f32) {
+    let d = 0.7071_f32;
+    match s.trim().to_ascii_lowercase().replace('_', "-").as_str() {
+        "top" | "up" | "north" => (0.0, -1.0),
+        "bottom" | "down" | "south" => (0.0, 1.0),
+        "left" | "west" => (-1.0, 0.0),
+        "right" | "east" => (1.0, 0.0),
+        "top-right" | "up-right" | "north-east" => (d, -d),
+        "bottom-left" | "down-left" | "south-west" => (-d, d),
+        "bottom-right" | "down-right" | "south-east" => (d, d),
+        _ => (-d, -d),
+    }
+}
 
 impl Default for SurfaceConfig {
     fn default() -> Self {
@@ -454,6 +516,13 @@ impl Default for SurfaceConfig {
             shadow_color: default_shadow_color(),
             shadow_offset_x: default_shadow_offset_x(),
             shadow_offset_y: default_shadow_offset_y(),
+            bevel_enabled: default_bevel_enabled(),
+            bevel_thickness: default_bevel_thickness(),
+            bevel_light: default_bevel_light(),
+            bevel_light_intensity: default_bevel_light_intensity(),
+            bevel_shade_intensity: default_bevel_shade_intensity(),
+            bevel_shoulder: default_bevel_shoulder(),
+            bevel_color: default_bevel_color(),
         }
      }
 }
@@ -1798,6 +1867,53 @@ fn parse_kdl_config(content: &str) -> Result<Config, String> {
                             }
                         }
                     }
+                    if let Some(bevel_node) = surface_children.nodes().iter().find(|n| n.name().value() == "bevel") {
+                        found_nested = true;
+                        for entry in bevel_node.entries() {
+                            if let Some(id) = entry.name() {
+                                match id.value() {
+                                    "enabled" => {
+                                        if let Some(val) = entry.value().as_bool() {
+                                            surface.bevel_enabled = val;
+                                        }
+                                    }
+                                    "thickness" => {
+                                        if let Some(val) = entry.value().as_f64() {
+                                            surface.bevel_thickness = val;
+                                        } else if let Some(val) = entry.value().as_i64() {
+                                            surface.bevel_thickness = val as f64;
+                                        }
+                                    }
+                                    "light" => {
+                                        if let Some(val) = entry.value().as_string() {
+                                            surface.bevel_light = val.to_string();
+                                        }
+                                    }
+                                    "light_intensity" => {
+                                        if let Some(val) = entry.value().as_f64() {
+                                            surface.bevel_light_intensity = val;
+                                        }
+                                    }
+                                    "shade_intensity" => {
+                                        if let Some(val) = entry.value().as_f64() {
+                                            surface.bevel_shade_intensity = val;
+                                        }
+                                    }
+                                    "shoulder" => {
+                                        if let Some(val) = entry.value().as_f64() {
+                                            surface.bevel_shoulder = val;
+                                        }
+                                    }
+                                    "color" => {
+                                        if let Some(val) = entry.value().as_string() {
+                                            surface.bevel_color = val.to_string();
+                                        }
+                                    }
+                                    _ => {}
+                                }
+                            }
+                        }
+                    }
                     if let Some(shadow_node) = surface_children.nodes().iter().find(|n| n.name().value() == "shadow") {
                         found_nested = true;
                         for entry in shadow_node.entries() {
@@ -2061,6 +2177,15 @@ pub fn parse_config(path: &str, state: &mut crate::window_manager::WindowManager
     state.layout.shadow_color = parse_hex_color_rgba(&config.surface.shadow_color);
     state.layout.shadow_offset_x = config.surface.shadow_offset_x as i32;
     state.layout.shadow_offset_y = config.surface.shadow_offset_y as i32;
+    state.layout.bevel_enabled = config.surface.bevel_enabled;
+    state.layout.bevel_thickness = config.surface.bevel_thickness.max(0.0) as f32;
+    let (bevel_lx, bevel_ly) = parse_light_direction(&config.surface.bevel_light);
+    state.layout.bevel_light_x = bevel_lx;
+    state.layout.bevel_light_y = bevel_ly;
+    state.layout.bevel_light_intensity = config.surface.bevel_light_intensity.clamp(0.0, 1.0) as f32;
+    state.layout.bevel_shade_intensity = config.surface.bevel_shade_intensity.clamp(0.0, 1.0) as f32;
+    state.layout.bevel_shoulder = config.surface.bevel_shoulder.clamp(0.0, 1.0) as f32;
+    state.layout.bevel_color = parse_hex_color_rgba(&config.surface.bevel_color);
 
     for (key, val) in &config.env {
         let expanded = expand_env_vars(val);
diff --git a/src/server/window.rs b/src/server/window.rs
index 53e42d3..10ec705 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -349,6 +349,11 @@ pub struct Window {
     /// scenefx drop shadow, first child of `tree` so it renders beneath
     /// everything else in the window; null if creation failed (shadow skipped).
     pub shadow: *mut ffi::wlr_scene_shadow,
+    /// scenefx bevel node: the lit chamfer around the inside of the window's
+    /// edge. Created LAST in the window tree so it draws over the surface —
+    /// the rim overlays the client's outermost pixels. Null if creation
+    /// failed (the effect is then simply absent).
+    pub bevel: *mut ffi::wlr_scene_bevel,
     pub decorations_below: ffi::wl_list,
     pub decorations_below_tree: *mut ffi::wlr_scene_tree,
     pub surfaces: crate::scene::SaveableSurfaces,
@@ -542,6 +547,17 @@ impl Window {
             }
         };
 
+        // Created after the surfaces so it is ABOVE them in the window tree:
+        // the bevel is an inner rim drawn over the client's outermost pixels,
+        // not something tucked behind them. Geometry, light and colour are
+        // synced per frame in update_bevel; a null pointer disables the
+        // effect rather than failing window creation.
+        let bevel_color = [1.0f32, 1.0f32, 1.0f32, 1.0f32];
+        let bevel = ffi::wlr_scene_bevel_create(tree, 0, 0, 0, 0.0, bevel_color.as_ptr());
+        if !bevel.is_null() {
+            ffi::wlr_scene_node_set_enabled(&mut (*bevel).node, false);
+        }
+
         // The invisible hit catchers stay in the window tree so pointer
         // hit-testing and z-order are unchanged. The visible segments live in
         // a sibling tree parented to the global border overlay layer, so a
@@ -576,6 +592,7 @@ impl Window {
             fullscreen_background,
             window_background,
             shadow,
+            bevel,
             decorations_below: std::mem::zeroed(),
             decorations_below_tree,
             surfaces,
@@ -2112,6 +2129,7 @@ impl Window {
             );
             let want_shadow = !is_status && (self.wm_requested.ssd || is_decorated) && !self.is_fullscreen();
             self.update_shadow(width, height, radius, want_shadow);
+                self.update_bevel(width, height, radius, want_shadow);
             ffi::river_scene_node_set_opacity(self.tree as *mut ffi::wlr_scene_node, requested.opacity);
 
             // Device px, like the blur radius above: the surface content is
@@ -2575,6 +2593,7 @@ impl Window {
                 // window and swallows the shadow whole.
                 let want_shadow = !is_status && (self.wm_requested.ssd || is_decorated) && !self.is_fullscreen();
                 self.update_shadow(width, height, radius, want_shadow);
+                self.update_bevel(width, height, radius, want_shadow);
             }
 
             self.scale_only_render_finish();
@@ -2622,6 +2641,58 @@ impl Window {
         });
     }
 
+    /// Sync the edge bevel with the current geometry. `width`/`height` are the
+    /// content size in device px and `radius` the corner radius in logical px,
+    /// exactly as `update_shadow` takes them. The rim is drawn INSIDE that box
+    /// (see the shader), so it overlays the client's outermost pixels and needs
+    /// no room of its own.
+    ///
+    /// The light direction is the DE's convention — the same top-left source
+    /// the drop shadow is offset away from — so a window reads as a slab lit
+    /// from the same place as everything else on the desktop.
+    pub unsafe fn update_bevel(&self, width: i32, height: i32, radius: i32, want: bool) {
+        if self.bevel.is_null() {
+            return;
+        }
+        let node = &mut (*self.bevel).node as *mut ffi::wlr_scene_node;
+        let layout = &(*self.server).wm.layout;
+        let enabled = want
+            && layout.bevel_enabled
+            && layout.bevel_thickness > 0.0
+            && width > 0
+            && height > 0;
+        ffi::wlr_scene_node_set_enabled(node, enabled);
+        if !enabled {
+            return;
+        }
+
+        // Device px, like the blur radius and shadow sigma: the content is
+        // scaled to its dest size, so an unscaled rim would keep its zoom-1
+        // width while the window shrinks.
+        let thickness = (layout.bevel_thickness as f64 * self.scale) as f32;
+        let radius_dev = (radius as f64 * self.scale) as i32;
+
+        // Light from the top-left, matching shadow_offset_x/y pointing away
+        // from it. Normalized here so the shader can take it as-is.
+        let (lx, ly) = (layout.bevel_light_x, layout.bevel_light_y);
+        let len = (lx * lx + ly * ly).sqrt();
+        let (lx, ly) = if len > 1e-6 { (lx / len, ly / len) } else { (-0.7071, -0.7071) };
+
+        ffi::wlr_scene_bevel_set_size(self.bevel, width, height);
+        ffi::wlr_scene_bevel_set_corner_radius(self.bevel, radius_dev);
+        ffi::wlr_scene_bevel_set_thickness(self.bevel, thickness.max(1.0));
+        ffi::wlr_scene_bevel_set_light(
+            self.bevel,
+            lx,
+            ly,
+            layout.bevel_light_intensity,
+            layout.bevel_shade_intensity,
+        );
+        ffi::wlr_scene_bevel_set_shoulder(self.bevel, layout.bevel_shoulder);
+        ffi::wlr_scene_bevel_set_color(self.bevel, layout.bevel_color.as_ptr());
+        ffi::river_scene_node_set_position_if_changed(node, 0, 0);
+    }
+
     /// Advance the hover fade one tick. Every zone eases toward 1.0 if it is
     /// the one under the pointer and 0.0 otherwise. Returns true while any
     /// zone is still in motion, so the caller knows to schedule another tick.